@luckystack/sync 0.1.4 → 0.1.5

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.
package/CHANGELOG.md CHANGED
@@ -1,14 +1,14 @@
1
- # Changelog
2
-
3
- All notable changes to `@luckystack/sync` are documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
-
8
- ## [Unreleased]
9
-
10
- ## [0.1.0]
11
-
12
- ### Added
13
-
14
- - Initial public release as part of the LuckyStack package split.
1
+ # Changelog
2
+
3
+ All notable changes to `@luckystack/sync` are documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ ### Added
13
+
14
+ - Initial public release as part of the LuckyStack package split.
@@ -1,257 +1,257 @@
1
- # callback-registration
2
-
3
- > How recipients subscribe to sync events on the client. Covers `useSyncEvents` (the React hook for component-scoped callbacks), the `upsertSyncEventCallback` / `upsertSyncEventStreamCallback` registration APIs, `useSyncEventTrigger` (manual local fire for testing), and `initSyncRequest` (one-time wiring of socket lifecycle handlers to the socket-status provider).
4
-
5
- For the originator side see [`./sync-request.md`](./sync-request.md). For streaming payload shapes see [`./streaming.md`](./streaming.md).
6
-
7
- ---
8
-
9
- ## 1. `useSyncEvents()` — the component-scoped hook
10
-
11
- ```ts
12
- import { useSyncEvents } from '@luckystack/sync/client';
13
-
14
- const { upsertSyncEventCallback, upsertSyncEventStreamCallback } = useSyncEvents();
15
- ```
16
-
17
- Returns two stable (`useCallback`-wrapped) registrar functions. The hook also installs a cleanup `useEffect` that removes every callback registered through this hook instance on unmount.
18
-
19
- Key properties:
20
-
21
- - **Component-scoped local registry.** Each `useSyncEvents()` call holds its own `Map<fullName, Callback>` so the cleanup effect only removes callbacks added by that component, not callbacks other components registered for the same route.
22
- - **Automatic deduplication of same-key re-registration.** Calling `upsertSyncEventCallback` twice with the same name+version from the same component replaces the previous callback (idempotent). Calling it from different components adds independent entries.
23
- - **`useCallback`-stable identity.** The registrar functions don't change identity between renders, so dependency arrays (`useEffect(..., [upsertSyncEventCallback])`) don't churn.
24
-
25
- ### Typical usage
26
-
27
- ```tsx
28
- import { useEffect } from 'react';
29
- import { useSyncEvents } from '@luckystack/sync/client';
30
-
31
- export const BoardSubscriber = () => {
32
- const { upsertSyncEventCallback } = useSyncEvents();
33
-
34
- useEffect(() => {
35
- const unsubscribe = upsertSyncEventCallback({
36
- name: 'board/moveCard',
37
- version: 'v1',
38
- callback: ({ serverOutput, clientOutput }) => {
39
- if (serverOutput.status !== 'success') return;
40
- setCards(prev => moveCardLocally(prev, serverOutput.cardId));
41
- },
42
- });
43
- return unsubscribe;
44
- }, [upsertSyncEventCallback]);
45
-
46
- return null;
47
- };
48
- ```
49
-
50
- The returned `unsubscribe` lets you cancel before the component unmounts — useful when the subscription is conditional on props or routing.
51
-
52
- ---
53
-
54
- ## 2. `upsertSyncEventCallback({ name, version, callback })`
55
-
56
- Subscribes to **non-stream** sync payloads — the `{ serverOutput, clientOutput, status: 'success' }` frames that `handleSyncRequest` emits at the end of the per-recipient loop.
57
-
58
- ```ts
59
- upsertSyncEventCallback<F, V>(params: {
60
- name: F; // SyncFullName literal, typed
61
- version: V; // VersionsForFullName<F> literal
62
- callback: ({
63
- clientOutput: ClientOutputForFullName<F, V>,
64
- serverOutput: ServerOutputForFullName<F, V>,
65
- }) => void;
66
- }): () => void // returns unsubscribe function
67
- ```
68
-
69
- ### Typed payloads (rule 16, no casts)
70
-
71
- Both `clientOutput` and `serverOutput` are typed via the generated `SyncTypeMap`. The framework's discriminated unions surface every possible status:
72
-
73
- ```ts
74
- upsertSyncEventCallback({
75
- name: 'board/moveCard',
76
- version: 'v1',
77
- callback: ({ serverOutput, clientOutput }) => {
78
- // serverOutput is the typed union (success | error variants) from the generated map.
79
- if (serverOutput.status !== 'success') return;
80
- // After the narrowing, TS knows the success-shape fields are available.
81
- console.log(serverOutput.cardId, serverOutput.movedBy);
82
- },
83
- });
84
- ```
85
-
86
- Never cast these to `unknown` or `any` (root `CLAUDE.md` rule 16). If inference fails, regenerate the type map (`npm run ai:index`) — that's the source of truth.
87
-
88
- ### Validation rules
89
-
90
- `upsertSyncEventCallback` rejects (returns the `noop` unsubscriber, logs in dev mode) when:
91
-
92
- - `params.version` isn't a string -> `sync.invalidVersion`.
93
- - `params.callback` isn't a function -> `sync.invalidCallback`.
94
- - `params.name` isn't a valid service route name -> `routing.invalidServiceRouteName`.
95
-
96
- These are dev-time guards. In production the runtime errors surface only if generated types are mis-applied at the call site.
97
-
98
- ### Multiple components subscribing to the same route
99
-
100
- Supported and intentional. Each component's registrar pushes a separate callback into the route's array; `triggerSyncCallbacks` iterates and fires each. Cleanup is per-callback — unmounting Component A doesn't affect Component B's subscription.
101
-
102
- ### Duplicate callback handling (same reference)
103
-
104
- If the **exact same callback function reference** is registered twice (rare — usually a bug where `useEffect` runs without dependency-checking), the registrar warns in dev mode and ignores the second add. The local registry still tracks the callback so the cleanup works. This guards against runaway callback arrays without breaking legitimate multi-component subscriptions.
105
-
106
- ---
107
-
108
- ## 3. `upsertSyncEventStreamCallback({ name, version, callback })`
109
-
110
- Subscribes to **stream chunks** — the `{ status: 'stream', cb, fullName, ...payload }` frames emitted via `broadcastStream`, `streamTo`, or `_client.stream(...)`.
111
-
112
- ```ts
113
- upsertSyncEventStreamCallback<F, V>(params: {
114
- name: F;
115
- version: V;
116
- callback: ({ stream }: { stream: SyncRouteStreamEvent<...> }) => void;
117
- }): () => void
118
- ```
119
-
120
- The `stream` parameter type is the union of:
121
-
122
- - The route's `serverStream` type (from `broadcastStream` and `streamTo` payloads).
123
- - The route's `clientStream` type (from per-recipient `_client.stream(payload)`).
124
-
125
- If neither side ever streams, the callback type collapses to `never` and TS rejects the registration call at compile time — a compile-time guarantee that you can't subscribe to a non-streaming route.
126
-
127
- ```ts
128
- upsertSyncEventStreamCallback({
129
- name: 'chat/sendMessage',
130
- version: 'v1',
131
- callback: ({ stream }) => {
132
- if ('chunk' in stream) appendToken(stream.chunk);
133
- else if (stream.status === 'started') showTypingIndicator(stream.author);
134
- else if (stream.status === 'done') hideTypingIndicator();
135
- },
136
- });
137
- ```
138
-
139
- Stream callbacks run for **every chunk** of every fanout to this recipient — there is no per-request scoping. Use `onStream` on `syncRequest` instead when you want per-request scoping for originator-targeted chunks.
140
-
141
- The same dedup / multi-subscriber / unsubscribe semantics from `upsertSyncEventCallback` apply.
142
-
143
- ---
144
-
145
- ## 4. `useSyncEventTrigger()` — manual local fire
146
-
147
- ```ts
148
- const { triggerSyncEvent, triggerSyncStreamEvent } = useSyncEventTrigger();
149
-
150
- triggerSyncEvent('sync/board/moveCard/v1', { /* clientOutput */ }, { /* serverOutput */ });
151
- triggerSyncStreamEvent('sync/board/moveCard/v1', { chunk: 'partial' });
152
- ```
153
-
154
- Bypasses the wire entirely. The framework's internal `triggerSyncCallbacks` / `triggerSyncStreamCallbacks` are called directly, firing every registered subscriber as if the payload had arrived from the server.
155
-
156
- Use cases:
157
-
158
- - **Local testing** — fire a fake payload to verify your UI subscriber works without spinning up the server pipeline.
159
- - **Optimistic local echo** — apply a payload to your own UI without sending it through the network (rare — usually optimistic UI just mutates local state directly, no need to round-trip the sync system).
160
- - **Storybook / fixtures** — drive subscriber components with deterministic payloads.
161
-
162
- The argument names are different from the wire format:
163
-
164
- - First argument: the **full route name** (`sync/<page>/<name>/v<N>`). The `sync/` prefix is part of the internal routing key.
165
- - Second argument: `clientOutput`.
166
- - Third argument: `serverOutput`.
167
-
168
- Note: the parameter order is `(name, clientOutput, serverOutput)` — opposite of what you might expect from reading the wire frame, which lists `serverOutput` first. The internal callback signature is `{ clientOutput, serverOutput }` (object) but the trigger helper passes positionally for ergonomics.
169
-
170
- ---
171
-
172
- ## 5. `initSyncRequest({ setSocketStatus, sessionRef })`
173
-
174
- One-time wiring of socket lifecycle events to a status setter. Called at app boot from `SocketStatusProvider`. Not consumed by app code directly.
175
-
176
- ```ts
177
- initSyncRequest({
178
- setSocketStatus, // Dispatch from useState<{ self, [userId]: statusContent }>
179
- sessionRef, // RefObject<SessionLayout | null>
180
- });
181
- ```
182
-
183
- Wires the following Socket.io events:
184
-
185
- | Socket event | Action |
186
- |---|---|
187
- | `connect` | `setSocketStatus(prev => ({ ...prev, self: { ...self, status: 'CONNECTED' } }))` |
188
- | `disconnect` | `setSocketStatus(prev => ({ ...prev, self: { ...self, status: 'DISCONNECTED' } }))` |
189
- | `reconnectAttempt(attempt)` | Status `'RECONNECTING'` with `reconnectAttempt` set to the attempt number. |
190
- | `userAfk({ userId, endTime })` | If `userId === session.id`, mark `self` disconnected with `endTime`. Otherwise mark the user as disconnected in the per-user map. |
191
- | `userBack({ userId })` | Mark the user as `'CONNECTED'`, clear `endTime`. |
192
- | `connect_error(err)` | Mark `self` disconnected, log + notify in dev mode. |
193
-
194
- Idempotent: if `initSyncRequest` is called again later, the previous handlers are removed before new ones are attached (the module holds `activeLifecycleHandlers` for this). Safe to call across hot-reloads.
195
-
196
- `sessionRef` is a `RefObject` (not a value) because the handlers must always read the **current** session at event-firing time, not the session at handler-construction time. Without the ref, a logout followed by a `userAfk` event would still treat the AFK as if it belonged to the old user.
197
-
198
- ---
199
-
200
- ## 6. Why callbacks must never cast `serverOutput` / `clientOutput`
201
-
202
- Rule 16 in the repo root `CLAUDE.md`: generated types are mandatory; no `unknown` / `any` casts on typed transports.
203
-
204
- Both `upsertSyncEventCallback` and `upsertSyncEventStreamCallback` give you fully typed payloads via the generated `SyncTypeMap`. The framework's discriminated unions handle the status branching cleanly:
205
-
206
- ```ts
207
- // GOOD — uses the generated discriminated union
208
- callback: ({ serverOutput }) => {
209
- if (serverOutput.status !== 'success') return;
210
- console.log(serverOutput.cardId); // typed
211
- }
212
-
213
- // BAD — defeats inference, breaks contract evolution
214
- callback: ({ serverOutput }: any) => {
215
- console.log((serverOutput as { cardId: string }).cardId);
216
- }
217
-
218
- // BAD — same problem, different syntax
219
- const data = serverOutput as unknown as { cardId: string };
220
- ```
221
-
222
- When inference fails:
223
-
224
- 1. Regenerate the type map: `npm run ai:index`.
225
- 2. Verify the `_server_v{N}.ts` return type is well-typed (no `any` in the return path).
226
- 3. If you still hit a wall, fix the typing source — don't paper over with casts at the call site.
227
-
228
- ---
229
-
230
- ## 7. Subscription cleanup checklist
231
-
232
- - `useSyncEvents()` auto-cleans on component unmount (via `useEffect` cleanup). For most cases, just call the registrars in a `useEffect` and return their unsubscribers. That's belt-and-suspenders but cheap.
233
- - Calling `upsertSyncEventCallback` outside React (e.g. from a `_provider` module-level subscribe) means you own the cleanup. Save the returned function and call it on teardown.
234
- - Re-registering with the same name+version from the same component (same `useSyncEvents()` instance) replaces the previous callback — no leak.
235
- - Re-registering from a **different** component adds an independent entry — both fire.
236
-
237
- ---
238
-
239
- ## 8. Quick reference
240
-
241
- | API | Purpose | Scope |
242
- |---|---|---|
243
- | `useSyncEvents()` | React hook returning `{ upsertSyncEventCallback, upsertSyncEventStreamCallback }` | Component lifetime — auto-clean on unmount |
244
- | `upsertSyncEventCallback({ name, version, callback })` | Subscribe to `{ serverOutput, clientOutput }` frames | Returns unsubscribe |
245
- | `upsertSyncEventStreamCallback({ name, version, callback })` | Subscribe to stream chunks (`broadcastStream`, `streamTo`, `_client.stream`) | Returns unsubscribe |
246
- | `useSyncEventTrigger()` | React hook returning `{ triggerSyncEvent, triggerSyncStreamEvent }` for local fire | Component-stable identity |
247
- | `initSyncRequest({ setSocketStatus, sessionRef })` | One-time wire-up of socket lifecycle to status provider | App boot |
248
-
249
- ---
250
-
251
- ## 9. Related
252
-
253
- - Originator API: [`./sync-request.md`](./sync-request.md)
254
- - Stream payload shapes: [`./streaming.md`](./streaming.md)
255
- - Server / client handler contracts (what produces the payloads): [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
256
- - Type-generation contract (rule 16): repo root [`.claude/CLAUDE.md`](../../../.claude/CLAUDE.md)
257
- - `SocketStatusProvider` wiring: `src/_providers/SocketStatusProvider.tsx` (installer side)
1
+ # callback-registration
2
+
3
+ > How recipients subscribe to sync events on the client. Covers `useSyncEvents` (the React hook for component-scoped callbacks), the `upsertSyncEventCallback` / `upsertSyncEventStreamCallback` registration APIs, `useSyncEventTrigger` (manual local fire for testing), and `initSyncRequest` (one-time wiring of socket lifecycle handlers to the socket-status provider).
4
+
5
+ For the originator side see [`./sync-request.md`](./sync-request.md). For streaming payload shapes see [`./streaming.md`](./streaming.md).
6
+
7
+ ---
8
+
9
+ ## 1. `useSyncEvents()` — the component-scoped hook
10
+
11
+ ```ts
12
+ import { useSyncEvents } from '@luckystack/sync/client';
13
+
14
+ const { upsertSyncEventCallback, upsertSyncEventStreamCallback } = useSyncEvents();
15
+ ```
16
+
17
+ Returns two stable (`useCallback`-wrapped) registrar functions. The hook also installs a cleanup `useEffect` that removes every callback registered through this hook instance on unmount.
18
+
19
+ Key properties:
20
+
21
+ - **Component-scoped local registry.** Each `useSyncEvents()` call holds its own `Map<fullName, Callback>` so the cleanup effect only removes callbacks added by that component, not callbacks other components registered for the same route.
22
+ - **Automatic deduplication of same-key re-registration.** Calling `upsertSyncEventCallback` twice with the same name+version from the same component replaces the previous callback (idempotent). Calling it from different components adds independent entries.
23
+ - **`useCallback`-stable identity.** The registrar functions don't change identity between renders, so dependency arrays (`useEffect(..., [upsertSyncEventCallback])`) don't churn.
24
+
25
+ ### Typical usage
26
+
27
+ ```tsx
28
+ import { useEffect } from 'react';
29
+ import { useSyncEvents } from '@luckystack/sync/client';
30
+
31
+ export const BoardSubscriber = () => {
32
+ const { upsertSyncEventCallback } = useSyncEvents();
33
+
34
+ useEffect(() => {
35
+ const unsubscribe = upsertSyncEventCallback({
36
+ name: 'board/moveCard',
37
+ version: 'v1',
38
+ callback: ({ serverOutput, clientOutput }) => {
39
+ if (serverOutput.status !== 'success') return;
40
+ setCards(prev => moveCardLocally(prev, serverOutput.cardId));
41
+ },
42
+ });
43
+ return unsubscribe;
44
+ }, [upsertSyncEventCallback]);
45
+
46
+ return null;
47
+ };
48
+ ```
49
+
50
+ The returned `unsubscribe` lets you cancel before the component unmounts — useful when the subscription is conditional on props or routing.
51
+
52
+ ---
53
+
54
+ ## 2. `upsertSyncEventCallback({ name, version, callback })`
55
+
56
+ Subscribes to **non-stream** sync payloads — the `{ serverOutput, clientOutput, status: 'success' }` frames that `handleSyncRequest` emits at the end of the per-recipient loop.
57
+
58
+ ```ts
59
+ upsertSyncEventCallback<F, V>(params: {
60
+ name: F; // SyncFullName literal, typed
61
+ version: V; // VersionsForFullName<F> literal
62
+ callback: ({
63
+ clientOutput: ClientOutputForFullName<F, V>,
64
+ serverOutput: ServerOutputForFullName<F, V>,
65
+ }) => void;
66
+ }): () => void // returns unsubscribe function
67
+ ```
68
+
69
+ ### Typed payloads (rule 16, no casts)
70
+
71
+ Both `clientOutput` and `serverOutput` are typed via the generated `SyncTypeMap`. The framework's discriminated unions surface every possible status:
72
+
73
+ ```ts
74
+ upsertSyncEventCallback({
75
+ name: 'board/moveCard',
76
+ version: 'v1',
77
+ callback: ({ serverOutput, clientOutput }) => {
78
+ // serverOutput is the typed union (success | error variants) from the generated map.
79
+ if (serverOutput.status !== 'success') return;
80
+ // After the narrowing, TS knows the success-shape fields are available.
81
+ console.log(serverOutput.cardId, serverOutput.movedBy);
82
+ },
83
+ });
84
+ ```
85
+
86
+ Never cast these to `unknown` or `any` (root `CLAUDE.md` rule 16). If inference fails, regenerate the type map (`npm run ai:index`) — that's the source of truth.
87
+
88
+ ### Validation rules
89
+
90
+ `upsertSyncEventCallback` rejects (returns the `noop` unsubscriber, logs in dev mode) when:
91
+
92
+ - `params.version` isn't a string -> `sync.invalidVersion`.
93
+ - `params.callback` isn't a function -> `sync.invalidCallback`.
94
+ - `params.name` isn't a valid service route name -> `routing.invalidServiceRouteName`.
95
+
96
+ These are dev-time guards. In production the runtime errors surface only if generated types are mis-applied at the call site.
97
+
98
+ ### Multiple components subscribing to the same route
99
+
100
+ Supported and intentional. Each component's registrar pushes a separate callback into the route's array; `triggerSyncCallbacks` iterates and fires each. Cleanup is per-callback — unmounting Component A doesn't affect Component B's subscription.
101
+
102
+ ### Duplicate callback handling (same reference)
103
+
104
+ If the **exact same callback function reference** is registered twice (rare — usually a bug where `useEffect` runs without dependency-checking), the registrar warns in dev mode and ignores the second add. The local registry still tracks the callback so the cleanup works. This guards against runaway callback arrays without breaking legitimate multi-component subscriptions.
105
+
106
+ ---
107
+
108
+ ## 3. `upsertSyncEventStreamCallback({ name, version, callback })`
109
+
110
+ Subscribes to **stream chunks** — the `{ status: 'stream', cb, fullName, ...payload }` frames emitted via `broadcastStream`, `streamTo`, or `_client.stream(...)`.
111
+
112
+ ```ts
113
+ upsertSyncEventStreamCallback<F, V>(params: {
114
+ name: F;
115
+ version: V;
116
+ callback: ({ stream }: { stream: SyncRouteStreamEvent<...> }) => void;
117
+ }): () => void
118
+ ```
119
+
120
+ The `stream` parameter type is the union of:
121
+
122
+ - The route's `serverStream` type (from `broadcastStream` and `streamTo` payloads).
123
+ - The route's `clientStream` type (from per-recipient `_client.stream(payload)`).
124
+
125
+ If neither side ever streams, the callback type collapses to `never` and TS rejects the registration call at compile time — a compile-time guarantee that you can't subscribe to a non-streaming route.
126
+
127
+ ```ts
128
+ upsertSyncEventStreamCallback({
129
+ name: 'chat/sendMessage',
130
+ version: 'v1',
131
+ callback: ({ stream }) => {
132
+ if ('chunk' in stream) appendToken(stream.chunk);
133
+ else if (stream.status === 'started') showTypingIndicator(stream.author);
134
+ else if (stream.status === 'done') hideTypingIndicator();
135
+ },
136
+ });
137
+ ```
138
+
139
+ Stream callbacks run for **every chunk** of every fanout to this recipient — there is no per-request scoping. Use `onStream` on `syncRequest` instead when you want per-request scoping for originator-targeted chunks.
140
+
141
+ The same dedup / multi-subscriber / unsubscribe semantics from `upsertSyncEventCallback` apply.
142
+
143
+ ---
144
+
145
+ ## 4. `useSyncEventTrigger()` — manual local fire
146
+
147
+ ```ts
148
+ const { triggerSyncEvent, triggerSyncStreamEvent } = useSyncEventTrigger();
149
+
150
+ triggerSyncEvent('sync/board/moveCard/v1', { /* clientOutput */ }, { /* serverOutput */ });
151
+ triggerSyncStreamEvent('sync/board/moveCard/v1', { chunk: 'partial' });
152
+ ```
153
+
154
+ Bypasses the wire entirely. The framework's internal `triggerSyncCallbacks` / `triggerSyncStreamCallbacks` are called directly, firing every registered subscriber as if the payload had arrived from the server.
155
+
156
+ Use cases:
157
+
158
+ - **Local testing** — fire a fake payload to verify your UI subscriber works without spinning up the server pipeline.
159
+ - **Optimistic local echo** — apply a payload to your own UI without sending it through the network (rare — usually optimistic UI just mutates local state directly, no need to round-trip the sync system).
160
+ - **Storybook / fixtures** — drive subscriber components with deterministic payloads.
161
+
162
+ The argument names are different from the wire format:
163
+
164
+ - First argument: the **full route name** (`sync/<page>/<name>/v<N>`). The `sync/` prefix is part of the internal routing key.
165
+ - Second argument: `clientOutput`.
166
+ - Third argument: `serverOutput`.
167
+
168
+ Note: the parameter order is `(name, clientOutput, serverOutput)` — opposite of what you might expect from reading the wire frame, which lists `serverOutput` first. The internal callback signature is `{ clientOutput, serverOutput }` (object) but the trigger helper passes positionally for ergonomics.
169
+
170
+ ---
171
+
172
+ ## 5. `initSyncRequest({ setSocketStatus, sessionRef })`
173
+
174
+ One-time wiring of socket lifecycle events to a status setter. Called at app boot from `SocketStatusProvider`. Not consumed by app code directly.
175
+
176
+ ```ts
177
+ initSyncRequest({
178
+ setSocketStatus, // Dispatch from useState<{ self, [userId]: statusContent }>
179
+ sessionRef, // RefObject<SessionLayout | null>
180
+ });
181
+ ```
182
+
183
+ Wires the following Socket.io events:
184
+
185
+ | Socket event | Action |
186
+ |---|---|
187
+ | `connect` | `setSocketStatus(prev => ({ ...prev, self: { ...self, status: 'CONNECTED' } }))` |
188
+ | `disconnect` | `setSocketStatus(prev => ({ ...prev, self: { ...self, status: 'DISCONNECTED' } }))` |
189
+ | `reconnectAttempt(attempt)` | Status `'RECONNECTING'` with `reconnectAttempt` set to the attempt number. |
190
+ | `userAfk({ userId, endTime })` | If `userId === session.id`, mark `self` disconnected with `endTime`. Otherwise mark the user as disconnected in the per-user map. |
191
+ | `userBack({ userId })` | Mark the user as `'CONNECTED'`, clear `endTime`. |
192
+ | `connect_error(err)` | Mark `self` disconnected, log + notify in dev mode. |
193
+
194
+ Idempotent: if `initSyncRequest` is called again later, the previous handlers are removed before new ones are attached (the module holds `activeLifecycleHandlers` for this). Safe to call across hot-reloads.
195
+
196
+ `sessionRef` is a `RefObject` (not a value) because the handlers must always read the **current** session at event-firing time, not the session at handler-construction time. Without the ref, a logout followed by a `userAfk` event would still treat the AFK as if it belonged to the old user.
197
+
198
+ ---
199
+
200
+ ## 6. Why callbacks must never cast `serverOutput` / `clientOutput`
201
+
202
+ Rule 16 in the repo root `CLAUDE.md`: generated types are mandatory; no `unknown` / `any` casts on typed transports.
203
+
204
+ Both `upsertSyncEventCallback` and `upsertSyncEventStreamCallback` give you fully typed payloads via the generated `SyncTypeMap`. The framework's discriminated unions handle the status branching cleanly:
205
+
206
+ ```ts
207
+ // GOOD — uses the generated discriminated union
208
+ callback: ({ serverOutput }) => {
209
+ if (serverOutput.status !== 'success') return;
210
+ console.log(serverOutput.cardId); // typed
211
+ }
212
+
213
+ // BAD — defeats inference, breaks contract evolution
214
+ callback: ({ serverOutput }: any) => {
215
+ console.log((serverOutput as { cardId: string }).cardId);
216
+ }
217
+
218
+ // BAD — same problem, different syntax
219
+ const data = serverOutput as unknown as { cardId: string };
220
+ ```
221
+
222
+ When inference fails:
223
+
224
+ 1. Regenerate the type map: `npm run ai:index`.
225
+ 2. Verify the `_server_v{N}.ts` return type is well-typed (no `any` in the return path).
226
+ 3. If you still hit a wall, fix the typing source — don't paper over with casts at the call site.
227
+
228
+ ---
229
+
230
+ ## 7. Subscription cleanup checklist
231
+
232
+ - `useSyncEvents()` auto-cleans on component unmount (via `useEffect` cleanup). For most cases, just call the registrars in a `useEffect` and return their unsubscribers. That's belt-and-suspenders but cheap.
233
+ - Calling `upsertSyncEventCallback` outside React (e.g. from a `_provider` module-level subscribe) means you own the cleanup. Save the returned function and call it on teardown.
234
+ - Re-registering with the same name+version from the same component (same `useSyncEvents()` instance) replaces the previous callback — no leak.
235
+ - Re-registering from a **different** component adds an independent entry — both fire.
236
+
237
+ ---
238
+
239
+ ## 8. Quick reference
240
+
241
+ | API | Purpose | Scope |
242
+ |---|---|---|
243
+ | `useSyncEvents()` | React hook returning `{ upsertSyncEventCallback, upsertSyncEventStreamCallback }` | Component lifetime — auto-clean on unmount |
244
+ | `upsertSyncEventCallback({ name, version, callback })` | Subscribe to `{ serverOutput, clientOutput }` frames | Returns unsubscribe |
245
+ | `upsertSyncEventStreamCallback({ name, version, callback })` | Subscribe to stream chunks (`broadcastStream`, `streamTo`, `_client.stream`) | Returns unsubscribe |
246
+ | `useSyncEventTrigger()` | React hook returning `{ triggerSyncEvent, triggerSyncStreamEvent }` for local fire | Component-stable identity |
247
+ | `initSyncRequest({ setSocketStatus, sessionRef })` | One-time wire-up of socket lifecycle to status provider | App boot |
248
+
249
+ ---
250
+
251
+ ## 9. Related
252
+
253
+ - Originator API: [`./sync-request.md`](./sync-request.md)
254
+ - Stream payload shapes: [`./streaming.md`](./streaming.md)
255
+ - Server / client handler contracts (what produces the payloads): [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
256
+ - Type-generation contract (rule 16): repo root [`.claude/CLAUDE.md`](../../../.claude/CLAUDE.md)
257
+ - `SocketStatusProvider` wiring: `src/_providers/SocketStatusProvider.tsx` (installer side)