@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,362 +1,5 @@
1
- # version-policy
1
+ # Sync route version policy
2
2
 
3
- > Every sync route is keyed by a string-literal `version` (`'v1'`, `'v2'`, ...). Versions live in the **filename** (`{name}_server_v{N}.ts` / `{name}_client_v{N}.ts`), travel as part of the wire route key (`sync/<page>/<name>/v<N>`), and are exposed to TypeScript via the generated `SyncTypeMap` so `syncRequest` and `upsertSyncEventCallback` enforce them at compile time. Adding a `v2` never replaces `v1` — both files coexist on disk and both versions resolve independently at runtime. This is how the framework lets you ship a breaking change to one set of consumers while older clients keep working unchanged.
3
+ Versioning lets old and new clients coexist during contract changes. Keep an existing version's request, response and stream meaning stable while consumers migrate; ship a separate version when a change breaks that contract. A bug fix that preserves the contract does not require a new version. Route filenames, generated maps and wire keys are code-owned (`../../devkit/`, `../src/syncRequest.ts`).
4
4
 
5
- For the originator-side call signature see [`./sync-request.md`](./sync-request.md). For the handler files see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md).
6
-
7
- ---
8
-
9
- ## 1. Why versions are mandatory string literals
10
-
11
- `syncRequest` and `upsertSyncEventCallback` both require `version` as a string-literal property:
12
-
13
- ```ts
14
- await syncRequest({
15
- name: 'board/moveCard',
16
- version: 'v1', // <- string literal, required
17
- data: { cardId, toLane },
18
- receiver: roomCode,
19
- });
20
- ```
21
-
22
- The type system traces it back through the generated map:
23
-
24
- ```
25
- SyncTypeMap (generated from _sync files)
26
- -> SyncRouteRecord (page/name -> version -> shape map)
27
- -> SyncFullName = keyof SyncRouteRecord
28
- -> VersionsForFullName<F> = Extract<keyof SyncRouteRecord[F], string>
29
- -> ClientInputForFullName / ServerOutputForFullName / ClientOutputForFullName / *Stream
30
- ```
31
-
32
- In `packages/sync/src/syncRequest.ts`:
33
-
34
- ```ts
35
- type SyncRouteRecord = UnionToIntersection<{
36
- [P in keyof SyncTypeMap]: {
37
- [N in keyof SyncTypeMap[P] as P extends 'root'
38
- ? `system/${Extract<N, string>}`
39
- : `${Extract<P, string>}/${Extract<N, string>}`]: SyncTypeMap[P][N]
40
- }
41
- }[keyof SyncTypeMap]>;
42
-
43
- type SyncFullName = Extract<keyof SyncRouteRecord, string>;
44
- type VersionsForFullName<F extends SyncFullName> = Extract<keyof SyncRouteRecord[F], string>;
45
- ```
46
-
47
- Three consequences:
48
-
49
- 1. **No `version` argument means no compile.** TypeScript can't pick a payload shape if it doesn't know which version's `clientInput` / `serverOutput` to use.
50
- 2. **A typo in `version` is a compile error.** `version: 'v2'` against a route that only has `v1` widens `VersionsForFullName<F>` to a union that excludes `'v2'`, so the literal fails the constraint.
51
- 3. **`data`, `serverOutput`, `clientOutput`, and stream payloads are all version-scoped.** A `v1` consumer cannot accidentally read the `v2` shape because the type for that branch is inferred per `(name, version)` pair.
52
-
53
- Why **strings** not numbers: string-literal types narrow under TS template literals (`` `${Extract<N, string>}/${Extract<V, string>}` ``). Number literals don't compose cleanly into route keys (`sync/board/moveCard/1` looks wrong on the wire and would lose the "v" prefix everyone expects). Filename + wire format + map keys all agree on `v{N}`.
54
-
55
- ---
56
-
57
- ## 2. File-naming convention
58
-
59
- A sync route version lives in one file per side:
60
-
61
- ```
62
- src/{page}/_sync/{name}_server_v{N}.ts <- required when v{N} should run server logic
63
- src/{page}/_sync/{name}_client_v{N}.ts <- optional per-recipient overlay
64
- ```
65
-
66
- Concrete examples:
67
-
68
- ```
69
- src/board/_sync/moveCard_server_v1.ts <- moveCard, v1
70
- src/board/_sync/moveCard_server_v2.ts <- moveCard, v2 (lives alongside v1)
71
- src/board/_sync/moveCard_client_v2.ts <- v2 added a per-recipient filter; v1 still has no client file
72
- src/chat/_sync/sendMessage_server_v1.ts <- chat/sendMessage, v1 only
73
- src/test/nestedTest/_sync/room_server_v1.ts <- nested page, route = 'test/nestedTest/room'
74
- ```
75
-
76
- The dev loader (`@luckystack/devkit`'s scanner, see `/docs/ARCHITECTURE_ROUTING.md`) walks `src/` and registers every file matching `_(server|client)_v\d+\.ts$` inside an `_sync` folder. The runtime map key is `${page}/${name}/v${N}_(server|client)` so the two sides for the same `(name, version)` are paired by the route layer.
77
-
78
- In `handleSyncRequest`, the wire key gets composed from the parsed route name + the literal version:
79
-
80
- ```ts
81
- // fullName carried on the wire: 'sync/<page>/<name>/v<N>'
82
- const fullName = `sync/${sanitizedName}/${version}`;
83
- ```
84
-
85
- Both versions of `moveCard` coexist on the same server with separate map entries: `board/moveCard/v1_server` and `board/moveCard/v2_server`. Picking the right one is purely a function of what `version` the originator sent.
86
-
87
- ---
88
-
89
- ## 3. Adding `v2` without breaking `v1`
90
-
91
- The framework explicitly supports running multiple versions of the same route side-by-side. The migration flow:
92
-
93
- ### Step 1 — Author `v2` files alongside `v1`
94
-
95
- ```
96
- src/board/_sync/moveCard_server_v1.ts <- unchanged, keep shipping
97
- src/board/_sync/moveCard_server_v2.ts <- new shape, new contract
98
- ```
99
-
100
- Both files export their own `main`, their own `SyncParams`, and (in `_server`) their own `auth`. The runtime treats them as fully independent handlers — there is no shared state, no fallthrough, no `version: 'latest'` sentinel.
101
-
102
- ### Step 2 — Regenerate the type map
103
-
104
- ```
105
- npm run ai:index
106
- ```
107
-
108
- The generated `SyncTypeMap` now carries a `{ v1, v2 }` key set for `board/moveCard`. Both `syncRequest({ name: 'board/moveCard', version: 'v1', ... })` and `version: 'v2'` typecheck; each gets its version-specific `data` / response inference.
109
-
110
- ### Step 3 — Migrate consumers at their own pace
111
-
112
- ```ts
113
- // Old caller — still compiles, still works against the original _server_v1.ts.
114
- await syncRequest({
115
- name: 'board/moveCard',
116
- version: 'v1',
117
- data: { cardId, toLane },
118
- receiver: roomCode,
119
- });
120
-
121
- // New caller — typechecks against the new shape.
122
- await syncRequest({
123
- name: 'board/moveCard',
124
- version: 'v2',
125
- data: { cardId, toLane, expectedRevision },
126
- receiver: roomCode,
127
- });
128
- ```
129
-
130
- Subscriber side is the same — two `upsertSyncEventCallback` registrations for `(name, v1)` and `(name, v2)` if you need both, or just the new one if the page is fully migrated.
131
-
132
- ### Step 4 — When **every** consumer is on `v2`, delete `v1`
133
-
134
- Only after you have ground-truth evidence that no client emits or subscribes to `v1` should the `_server_v1.ts` / `_client_v1.ts` files be removed. Removing earlier breaks any straggler client (stale browser tab, mobile app waiting on app-store review, third-party integration) immediately with `sync.notFound`.
135
-
136
- The framework has no built-in usage telemetry for this. Project-level options:
137
-
138
- - Log `routeName` in the `preSyncFanout` hook with the version segment parsed, ship to your analytics, watch the `v1` line trend to zero.
139
- - Add a `preSyncAuthorize` hook that warns once per unique session token when `v1` traffic arrives.
140
- - Gate the deletion behind a release that's been live long enough to flush any stale clients (typically 1–2 release cycles for web SPAs, several weeks for native apps).
141
-
142
- ---
143
-
144
- ## 4. Why AI must NEVER hot-replace a version in place
145
-
146
- In-place edits to `_server_v1.ts` / `_client_v1.ts` are reserved for **bug fixes that do not change the contract**. The contract is the shape of `clientInput` (request), the shape of `serverOutput` and `clientOutput` (responses), and any emitted stream payload shape (`serverStream` / `clientStream`).
147
-
148
- A change is **breaking** if any of the following move:
149
-
150
- - `clientInput` gains a required field, removes a field, or narrows a field's type.
151
- - `serverOutput` removes a field, renames a field, narrows a literal, or changes the `status` union.
152
- - `clientOutput` does any of the above.
153
- - `serverStream` / `clientStream` payload shape changes — recipients consuming the stream channel will silently break.
154
- - `auth` becomes stricter (`login: false` -> `true`, new `additional` predicates rejecting previously-allowed users).
155
-
156
- For any of these, **add a `v{N+1}` file**. Leave `v1` alone. The cost of a duplicate file is trivial; the cost of breaking deployed clients during a rolling release is not.
157
-
158
- Bug fixes that **are** allowed in-place on an existing version:
159
-
160
- - Database query optimization that returns the same shape.
161
- - Adding a new optional field to `serverOutput` (additive, not breaking — existing consumers ignore it).
162
- - Tightening server-side validation in a way that previously-accepted-but-illegitimate inputs are now rejected (security fix).
163
- - Internal refactors of the handler body that preserve the inferred return type.
164
-
165
- If in doubt: bump the version. The framework was designed around the assumption that versions are cheap.
166
-
167
- ---
168
-
169
- ## 5. Shape evolution rules per version
170
-
171
- Within a single version, every shape is independent and unaffected by other versions. Across versions, you have full freedom — but recognize that **shapes are inferred, not declared**, so a change to the file's return type is automatically a change to the generated map type. There is no separate version manifest.
172
-
173
- | Shape | Source | Where the shape change shows up |
174
- |---|---|---|
175
- | `clientInput` | `_server`'s `SyncParams.clientInput` (or the equivalent interface) | `data` argument on `syncRequest({ name, version, data })` |
176
- | `serverOutput` | `_server`'s `main(...)` return value (minus `status`) | `result` on the originator's response envelope, `serverOutput` on recipient callbacks |
177
- | `clientOutput` | `_client`'s `main(...)` return value (minus `status`), or `{}` if no `_client` file | `clientOutput` on recipient callbacks |
178
- | `serverStream` | Inferred from `stream` / `broadcastStream` / `streamTo` call sites in `_server`'s `main` | `onStream` callback typing, `upsertSyncEventStreamCallback` union |
179
- | `clientStream` | Inferred from `stream` call sites in `_client`'s `main` | `upsertSyncEventStreamCallback` union |
180
-
181
- A stage that never streams (no call sites in `main`) collapses its stream type to `never`. The consumer-side callback (`onStream` or `upsertSyncEventStreamCallback`) then refuses to compile if you try to subscribe — preventing dead listeners.
182
-
183
- Practical consequence: adding `broadcastStream({ chunk })` to `_server_v1.ts` after `v1` consumers already exist *widens* their `serverStream` to a non-`never` shape — this is **non-breaking** for existing `upsertSyncEventCallback` subscribers (they don't care about stream payloads), but it *does* enable new `upsertSyncEventStreamCallback` registrations. Removing the only stream call site in `_server` later collapses `serverStream` back to `never` and *will* break any registered stream subscriber at compile time on next type-map regeneration. Bump the version when in doubt.
184
-
185
- ---
186
-
187
- ## 6. Interaction with Zod input schemas (`apiInputSchemas.generated.ts`)
188
-
189
- `@luckystack/devkit` extracts `clientInput` from each `_server_v{N}.ts` via the type-map emitter and generates a Zod schema **per route per version**. The runtime calls `validateInputByType({ typeText, value, rootKey, filePath })` against the route's specific schema during `handleSyncRequest`.
190
-
191
- Implication:
192
-
193
- - `v1` and `v2` have **independent** Zod schemas. Tightening validation in `v2` does not affect `v1` traffic.
194
- - Renaming a field in `v2`'s `clientInput` means `v2`'s generated schema rejects the old field name; `v1`'s schema is unaffected.
195
- - The schema file (`src/_sockets/apiInputSchemas.generated.ts`) regenerates on type-map runs. Don't hand-edit it — the regenerator overwrites.
196
-
197
- ---
198
-
199
- ## 7. Wire-format and runtime key reference
200
-
201
- | Layer | Format | Example |
202
- |---|---|---|
203
- | Filename | `{name}_(server\|client)_v{N}.ts` | `moveCard_server_v2.ts` |
204
- | Route in `syncRequest` | `'<page>/<name>'` + `version: 'v{N}'` | `name: 'board/moveCard', version: 'v2'` |
205
- | Wire `name` (Socket.io emit) | `'sync/<page>/<name>/v{N}'` | `'sync/board/moveCard/v2'` |
206
- | Wire `cb` (callback handle) | `'<page>/<name>/v{N}'` | `'board/moveCard/v2'` |
207
- | Runtime map key (server side) | `'<page>/<name>/v{N}_(server\|client)'` | `'board/moveCard/v2_server'` |
208
- | Recipient subscription key | `'sync/<page>/<name>/v{N}'` (internal) | `'sync/board/moveCard/v2'` |
209
- | Root-level sync | `'system/<name>'` -> `'sync/system/<name>/v{N}'` | `'system/heartbeat'` -> `'sync/system/heartbeat/v1'` |
210
-
211
- The five fragments above all carry the same `v{N}` segment. If you ever see a route key without it, that's a bug — the version is part of every layer of the routing.
212
-
213
- ---
214
-
215
- ## 8. Worked example — shipping a breaking change
216
-
217
- Initial state:
218
-
219
- ```ts
220
- // src/board/_sync/moveCard_server_v1.ts
221
- export const auth = { login: true };
222
- export interface SyncParams {
223
- clientInput: { cardId: string; toLane: string };
224
- user: SessionLayout;
225
- functions: Functions;
226
- roomCode: string;
227
- }
228
- export const main = async ({ clientInput, functions }: SyncParams) => {
229
- await functions.db.card.update({ where: { id: clientInput.cardId }, data: { laneId: clientInput.toLane } });
230
- return { status: 'success', cardId: clientInput.cardId };
231
- };
232
- ```
233
-
234
- We want to add optimistic-locking with `expectedRevision`. That's a **new required field on `clientInput`** -> breaking change -> new version.
235
-
236
- Step 1: copy + edit:
237
-
238
- ```ts
239
- // src/board/_sync/moveCard_server_v2.ts
240
- export const auth = { login: true };
241
- export interface SyncParams {
242
- clientInput: { cardId: string; toLane: string; expectedRevision: number };
243
- user: SessionLayout;
244
- functions: Functions;
245
- roomCode: string;
246
- }
247
- export const main = async ({ clientInput, functions }: SyncParams) => {
248
- const card = await functions.db.card.findUnique({ where: { id: clientInput.cardId } });
249
- if (!card) return { status: 'error', errorCode: 'board.cardNotFound' };
250
- if (card.revision !== clientInput.expectedRevision) {
251
- return { status: 'error', errorCode: 'board.staleRevision' };
252
- }
253
- await functions.db.card.update({
254
- where: { id: clientInput.cardId },
255
- data: { laneId: clientInput.toLane, revision: card.revision + 1 },
256
- });
257
- return { status: 'success', cardId: clientInput.cardId, revision: card.revision + 1 };
258
- };
259
- ```
260
-
261
- Step 2: regenerate the type map. New caller can typecheck against `v2`; old caller stays on `v1`.
262
-
263
- Step 3: migrate caller code branch-by-branch:
264
-
265
- ```ts
266
- // New code path uses v2:
267
- const response = await syncRequest({
268
- name: 'board/moveCard',
269
- version: 'v2',
270
- data: { cardId, toLane, expectedRevision: localCard.revision },
271
- receiver: roomCode,
272
- });
273
-
274
- if (response.status === 'error' && response.errorCode === 'board.staleRevision') {
275
- // Resolve drift via a refetch flow.
276
- }
277
- ```
278
-
279
- Step 4: after every code path is migrated and the analytics show zero `v1` traffic, delete `moveCard_server_v1.ts`. Regenerate the type map again — `version: 'v1'` becomes a compile error everywhere it lingers, and you fix or remove those call sites.
280
-
281
- ---
282
-
283
- ## 9. Anti-patterns
284
-
285
- ### Anti-pattern: mutating `v1`'s contract in place
286
-
287
- ```ts
288
- // BAD — clientInput gains a required field on v1.
289
- // Every existing client sending {cardId, toLane} now fails with sync.invalidInputType.
290
- export interface SyncParams {
291
- clientInput: { cardId: string; toLane: string; expectedRevision: number };
292
- ...
293
- }
294
- ```
295
-
296
- Fix: add `v2` instead.
297
-
298
- ### Anti-pattern: faking versioning by branching inside `main`
299
-
300
- ```ts
301
- // BAD — the route reports a single shape to the type map but branches at runtime.
302
- export const main = async ({ clientInput }: SyncParams) => {
303
- if ('expectedRevision' in clientInput) { /* new behavior */ }
304
- else { /* legacy behavior */ }
305
- ...
306
- };
307
- ```
308
-
309
- Fix: separate files, separate versions. The point of versioning is *static* enforcement so consumers can't mix the two shapes.
310
-
311
- ### Anti-pattern: bumping version for non-breaking changes
312
-
313
- ```ts
314
- // BAD — added a logger, bumped to v2 anyway.
315
- ```
316
-
317
- Fix: in-place edit `v1`. The contract did not change; consumers do not need to migrate.
318
-
319
- ### Anti-pattern: skipping versions (`v1` -> `v3`)
320
-
321
- ```ts
322
- // BAD — file is named _server_v3.ts but there is no v2.
323
- ```
324
-
325
- Allowed by the framework (it just maps `v3` to its own entry), but confusing for humans. Use sequential numbers (`v1`, `v2`, `v3`) unless you have a reason not to.
326
-
327
- ### Anti-pattern: casting at the call site to "fix" a missing version literal
328
-
329
- ```ts
330
- // BAD — defeats the entire version inference chain.
331
- await syncRequest({ name: 'board/moveCard' as any, version: someString as any, ... });
332
- ```
333
-
334
- This violates repository contract rule 21 (no `as any` / `as unknown` on typed transports). If you don't know the version statically, the design needs to change — versions are an authoring-time decision, not a runtime one.
335
-
336
- ---
337
-
338
- ## 10. Quick reference
339
-
340
- | Action | Version policy |
341
- |---|---|
342
- | Fixing a bug that doesn't change shapes | Edit existing version in place. |
343
- | Adding an optional field to `serverOutput` | Edit existing version in place (additive). |
344
- | Adding a required field to `clientInput` | New version (`v{N+1}`). |
345
- | Removing a field from `serverOutput` / `clientOutput` | New version. |
346
- | Renaming a field anywhere in the contract | New version. |
347
- | Tightening `auth.login` from `false` to `true` | New version. |
348
- | Adding new stream emitter (when previously stream was `never`) | New version recommended (subscriber type widens — non-breaking now, but breaks if you ever remove it again). |
349
- | Deleting an unused version | After confirming zero traffic, remove both `_server_v{N}.ts` and `_client_v{N}.ts`, then regenerate the type map. |
350
-
351
- ---
352
-
353
- ## 11. Related
354
-
355
- - File-based routing (sync sections): [`/docs/ARCHITECTURE_ROUTING.md`](../../../docs/ARCHITECTURE_ROUTING.md#sync-routing)
356
- - Originator call signature: [`./sync-request.md`](./sync-request.md)
357
- - Server / client handler authoring: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
358
- - Streaming shape inference: [`./streaming.md`](./streaming.md) §7
359
- - Recipient subscription: [`./callback-registration.md`](./callback-registration.md)
360
- - Error contract (including `sync.invalidInputType` from regenerated Zod schemas): [`./error-states.md`](./error-states.md)
361
- - Type-map regeneration: `npm run ai:index` (`@luckystack/devkit`)
362
- - Sync type contract: rule 21 in repository-root [`CLAUDE.md`](../../../CLAUDE.md)
5
+ String-literal versions align route naming with TypeScript inference, so casts at the call site conceal a generator mismatch rather than solving it. Regenerate artifacts and fix the declaration or generator first. Keep both versions until the old consumers are retired; deleting the old handler early breaks clients still in flight. See repository `AGENTS.md` and `./server-vs-client-handlers.md`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@luckystack/sync",
3
- "version": "0.10.1",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -49,7 +49,7 @@
49
49
  "files": [
50
50
  "dist",
51
51
  "README.md",
52
- "CLAUDE.md",
52
+ "AGENTS.md",
53
53
  "docs",
54
54
  "LICENSE",
55
55
  "CHANGELOG.md"
@@ -60,8 +60,8 @@
60
60
  "test": "vitest run"
61
61
  },
62
62
  "dependencies": {
63
- "@luckystack/core": "^0.10.1",
64
- "@luckystack/error-tracking": "^0.10.1"
63
+ "@luckystack/core": "^0.11.0",
64
+ "@luckystack/error-tracking": "^0.11.0"
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@prisma/client": "^6.19.0",
package/CLAUDE.md DELETED
@@ -1,128 +0,0 @@
1
- # @luckystack/sync
2
-
3
- > AI summary + function INDEX. For deep specs see `docs/` next to this file.
4
-
5
- ## What this package does
6
-
7
- Real-time sync transport for LuckyStack. Provides type-safe, room-based fanout over Socket.io with configurable socket or routed HTTP/SSE invocation. Each sync event is a file-based route with a mandatory `_server_v{N}.ts` (runs once, validates, produces `serverOutput`) and an optional `_client_v{N}.ts` (runs once per recipient socket for per-target filtering or auth). Streaming primitives (`stream`, `broadcastStream`, `streamTo`) support live LLM tokens, collab-editor diffs, and per-recipient progress. An offline queue with configurable drop policy keeps optimistic sends from being lost when the socket reconnects.
8
-
9
- ## When to USE this package
10
-
11
- - Real-time room-based fanout (collab editors, chat, multiplayer state)
12
- - Streaming AI/LLM tokens with throttled chunking
13
- - Per-recipient customization (filter / translate / brand) without a second round-trip
14
- - Optimistic offline sends that should replay when the socket reconnects
15
- - Server-validated mutations that must be broadcast to every peer in a room
16
-
17
- ## When to NOT suggest this (yet)
18
-
19
- - Request/response with no fanout — use `@luckystack/api` (`apiRequest`)
20
- - Pure presence/online-status broadcasts — use `@luckystack/presence`
21
- - Cross-instance fanout without a Redis adapter — wire the adapter first (see `/docs/ARCHITECTURE_SOCKET.md`)
22
- - One-off background jobs unrelated to a client room
23
- - HTTP-only environments without a long-lived connection AND no SSE — the package needs at minimum the HTTP/SSE fallback
24
-
25
- ## Function Index
26
-
27
- ### Server entry (`@luckystack/sync`)
28
-
29
- | Export | One-liner | Deep doc |
30
- |---|---|---|
31
- | `handleSyncRequest({ msg, socket, token })` | Socket.io sync entry — auth, rate-limit, validate, run `_server`, fanout, run `_client` per recipient, emit results, dispatch hooks. | → `docs/server-vs-client-handlers.md` |
32
- | `handleHttpSyncRequest(req, res)` | HTTP/SSE fallback for sync requests when websockets are blocked. | → `docs/sync-request.md` |
33
- | `createStreamThrottle({ flushEveryMs?, flushAtChars?, field? })` | Coalesce tiny stream pieces (LLM tokens) into bigger chunks before emit. | → `docs/streaming.md` |
34
- | Type: `HttpSyncStreamEvent` | SSE event shape emitted by the HTTP fallback. | → `docs/sync-request.md` |
35
- | `SYNC_ERROR_CODES` (readonly array) + type `SyncErrorCode` | Canonical list of every `sync.*` error code the framework can emit (both transports + the server's HTTP sync route). Also exported from `/client`. Write a locale-parity test against it instead of retyping a doc table. | → `docs/error-states.md` |
36
- | Type: `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle handle + options. | → `docs/streaming.md` |
37
-
38
- ### Client entry (`@luckystack/sync/client`)
39
-
40
- | Export | One-liner | Deep doc |
41
- |---|---|---|
42
- | `syncRequest({ name, version, data?, receiver, ignoreSelf?, onStream?, offlineDropPolicy? })` | Fire a typed sync event using `transport.invocation`; routed mode uses HTTP/SSE while recipient callbacks stay on the one socket. | → `docs/sync-request.md` |
43
- | `useSyncEvents()` | React hook returning `{ upsertSyncEventCallback, upsertSyncEventStreamCallback }` scoped to the component lifetime. | → `docs/callback-registration.md` |
44
- | `useSyncEventTrigger()` | React hook returning `{ triggerSyncEvent, triggerSyncStreamEvent }` for manually invoking registered callbacks (testing / local echo). | → `docs/callback-registration.md` |
45
- | `initSyncRequest({ setSocketStatus, sessionRef })` | One-time wiring of socket lifecycle handlers (connect/disconnect/reconnectAttempt/userAfk/userBack/connectError) into the socket-status provider. | → `docs/callback-registration.md` |
46
- | Type: `SyncRequestStreamEvent<T>` | Payload shape passed to `onStream` on the originator side. | → `docs/streaming.md` |
47
- | Type: `SyncRouteStreamEvent<T>` | Payload shape passed to `upsertSyncEventStreamCallback` on recipients. | → `docs/streaming.md` |
48
- | `SYNC_ERROR_CODES` + type `SyncErrorCode` | Same canonical `sync.*` code list as the server barrel, for client-side locale parity tests and exhaustive `switch`es. | → `docs/error-states.md` |
49
-
50
- ### Streaming primitives (received inside `_server_v{N}.ts` params)
51
-
52
- | Primitive | Audience | Deep doc |
53
- |---|---|---|
54
- | `stream(payload)` | Originator socket only (cheapest). | → `docs/streaming.md` |
55
- | `broadcastStream(payload)` | Every socket in `roomCode`, across all instances (`io.to(room).emit` via the Redis adapter). | → `docs/streaming.md` |
56
- | `streamTo(tokens, payload)` | Selective fanout to specific session tokens. | → `docs/streaming.md` |
57
- | `stream(payload)` in `_client_v{N}.ts` | Per-recipient, runs after `_server` finishes. | → `docs/streaming.md` |
58
-
59
- ### Hooks dispatched by the server handler
60
-
61
- | Hook | When | Deep doc |
62
- |---|---|---|
63
- | `preSocketMessage` | At the very top of the socket message handler (before auth/route resolution). Stop to reject the message. Transport-level seam mirroring `preHttpRequest`; `channel: 'sync'`. | → `docs/server-vs-client-handlers.md` |
64
- | `preSyncAuthorize` | After basic `AuthProps` check, before rate-limit + input validation. Stop to reject. | → `docs/server-vs-client-handlers.md` |
65
- | `postSyncAuthorize` | Observational — after the request passes auth + custom policy, before rate-limit + validation. | → `docs/server-vs-client-handlers.md` |
66
- | `preSyncValidate` | Before runtime input validation. Stop to reject (mirrors `preApiValidate`). | → `docs/server-vs-client-handlers.md` |
67
- | `postSyncValidate` | After validation, carrying `{ validation }` (mirrors `postApiValidate`). | → `docs/server-vs-client-handlers.md` |
68
- | `preSyncExecute` | Before the `_server` handler runs. Stop to short-circuit (mirrors `preApiExecute`). | → `docs/server-vs-client-handlers.md` |
69
- | `postSyncExecute` | After `_server` resolves OR throws, carrying `{ result, error, durationMs }`. Fires on the FAILURE path too (mirrors `postApiExecute`). | → `docs/server-vs-client-handlers.md` |
70
- | `preSyncFanout` | After `_server` runs, before any recipient receives the payload. Stop to abort fanout. | → `docs/room-fanout.md` |
71
- | `preSyncRecipient` | Per recipient (both transports, remote recipients included), before that ONE socket receives the payload. Carries `{ routeName, receiver, recipientSocketId, recipientToken, recipientUserId, serverOutput }`. A stop signal SKIPS just that recipient (the loop continues; it is not counted as delivered) — it does NOT abort the whole fanout; a stop WITH `overrideOutput` delivers the override instead. `recipientToken` is the recipient's session token (`null` for an anonymous socket); `recipientUserId` is ALWAYS null — there is no resolver option, read the session by `recipientToken` yourself when needed. | → `docs/room-fanout.md` |
72
- | `postSyncFanout` | After all recipients have been emitted to. Receives `recipientCount`. | → `docs/room-fanout.md` |
73
- | `preSyncStream` | Per stream chunk, before it is emitted (`stream` / `broadcastStream` / `streamTo`). Carries `{ routeName, chunk, recipient }` (`recipient` = `'originator'`, the room, or a token). Observational. | → `docs/streaming.md` |
74
- | `postSyncStream` | Per stream chunk, after emit. Adds a 1-based per-stream `chunkIndex` to the `preSyncStream` payload. Observational. | → `docs/streaming.md` |
75
- | `rateLimitExceeded` | When the per-route or per-IP bucket rejects a sync. | → `docs/error-states.md` |
76
-
77
- ## Config keys
78
-
79
- ### `registerProjectConfig({ transport, sync, offlineQueue })`
80
-
81
- - `transport.invocation` — `'socket'` (backwards-compatible default) or `'routed-http'`. Routed mode invokes the owning service through the router; Socket.io remains connected for fanout callbacks, rooms and presence.
82
-
83
- - `sync.streamThrottle.flushAtChars` — char threshold before a buffered throttle flushes. Default `32`.
84
- - `sync.streamThrottle.flushEveryMs` — timer-based flush interval; `false` disables the timer. Default `50`.
85
- - `sync.streamThrottle.field` — payload key carrying the buffered text. Default `'chunk'`.
86
- - `sync.fanoutYieldEvery` — yield to the event loop every N recipients during a giant fanout. Clamped to `>= 1` (a configured `0` would make the modulo `NaN` and never yield).
87
- - `sync.fanoutYieldMs` — duration of the yield `setTimeout`.
88
- - `sync.requestTimeoutMs` — client-side ack-timeout for `syncRequest` (CORE-06). After this elapses with no acknowledgement the promise settles with `{ status:'error', errorCode:'sync.requestTimeout', httpStatus:504 }` instead of hanging. `false` disables. Default `30000`.
89
- - `sync.allowClientReceiverAll` — when `false`, a client requesting the broadcast receiver `'all'` is rejected (`sync.receiverNotAllowed`, 403) on both transports (SYNC-07). Listed in `docs/error-states.md` §3. **Default `false` (0.2.0 secure-default flip — was `true`); opt back into cluster-wide broadcast explicitly or approve via `preSyncAuthorize`.**
90
- - `sync.requireRoomMembership` — when `true`, a client may only target a room it is a member of; otherwise rejected (`sync.notRoomMember`, 403; listed in `docs/error-states.md` §3). Enforced on BOTH transports but from DIFFERENT sources, deliberately: the SOCKET path tests PHYSICAL membership (`socket.rooms` of the originating socket), the HTTP/SSE path tests LOGICAL membership (the session's persisted `roomCodes` + its own token-room) because it has no originator socket. Consequence: on `routed-http` a successful `syncRequest` does not prove you are in the room yourself or that you will receive the fan-out (dev-mode warning when this happens) — see `/docs/ARCHITECTURE_SYNC.md` → "Room membership is transport-specific". An anonymous HTTP caller (no session) has undeterminable membership and is rejected (fail-closed). **Default `true` (0.2.0 secure-default flip — was `false`); set `false` for the legacy any-room behavior.**
91
- - Empty target room → `sync.noReceiversFound` (404) on BOTH transports since 0.10.0 (the HTTP path used to answer `success` with zero recipients). Recipients are resolved BEFORE `_server` runs on both transports, so this error — and a `fetchSockets()` adapter timeout (`sync.serverExecutionFailed`) — means nothing was persisted; a retry is safe. See `docs/room-fanout.md` §1.
92
- - `sync.flushPressure.maxBufferedBytes` — default drain threshold for the `flushPressure` backpressure helper when no per-call `thresholdBytes` is given (SYNC-15). Default `5_242_880` (5 MiB).
93
- - `sync.flushPressure.highWaterMarkChunks` — upper bound (in packets) on the derived flush-pressure threshold. Default `1000`. (`lowWaterMarkChunks` is surfaced in core but full chunk-watermark hysteresis is a residual — see fix report.)
94
-
95
- ### Per-route `_server` exports
96
-
97
- - `export const rateLimit: number | false` — per-route rate limit (mirrors `@luckystack/api`). Overrides `rateLimiting.defaultApiLimit` for this sync route's per-requester bucket; `false` disables it (the global per-IP bucket still applies); omit to fall back to `defaultApiLimit`. Honored by both transports.
98
- - `offlineQueue.maxSize` — cap on the client-side offline queue.
99
- - `offlineQueue.dropPolicy` — `'reject'` (default — overflow returns `offline.queueFull`), `'drop-oldest'`, or `'drop-newest'`. Per-request override via `syncRequest({ offlineDropPolicy })`. SYNC-09: when a request that was already QUEUED is later evicted (drop-oldest by a newer enqueue, or age expiry) its awaiting promise now settles with `{ status:'error', errorCode:'offline.dropped' }` (was: hung forever).
100
-
101
- ### Logging toggles
102
-
103
- - `logging.devLogs`, `logging.devNotifications`, `logging.socketStatus`, `logging.stream` — drive the dev-only log lines in this package.
104
-
105
- ### Env vars
106
-
107
- None directly. Inherits socket transport config from `@luckystack/server` and Redis adapter config from `@luckystack/core`.
108
-
109
- ## Peer dependencies
110
-
111
- - **Required**: `@luckystack/core`, `@luckystack/error-tracking`. **`@luckystack/login` is NOT a dependency** (0.2.0 decoupling) — sessions resolve through core's `readSession` / session-provider registry (`handleSyncRequest` imports `readSession` from `@luckystack/core`), with login as the default *provider* (optional package), not a hard runtime dep.
112
- - **Required peers**:
113
- - `socket.io@^4.8.0` (server entry)
114
- - `socket.io-client@^4.8.0` (client entry)
115
- - **Optional peers**:
116
- - `@prisma/client@^6.19.0` (only for the default Prisma-backed session/user provider)
117
- - `react@^19.2.0` (only the `/client` subpath)
118
- - Redis adapter (required for cross-instance fanout) wired by `@luckystack/server`; see `/docs/ARCHITECTURE_SOCKET.md`.
119
-
120
- ## Related
121
-
122
- - Architecture deep-dive: `/docs/ARCHITECTURE_SYNC.md`
123
- - Socket setup + Redis adapter: `/docs/ARCHITECTURE_SOCKET.md`
124
- - Multi-instance model + pitfalls (regular `syncRequest` fan-out reaches across instances via `io.in(room).fetchSockets()` + `RemoteSocket.emit()`; streaming via `broadcastStream`/`streamTo`): `/docs/ARCHITECTURE_MULTI_INSTANCE.md`
125
- - File-based `_sync/` routing: `/docs/ARCHITECTURE_ROUTING.md`
126
- - Streaming primitives (`stream` / `streamTo` / `broadcastStream` / `_client`) + throttle: `/docs/ARCHITECTURE_SYNC.md#streaming`
127
- - README (consumer quickstart): `./README.md`
128
- - Sibling packages: `@luckystack/api`, `@luckystack/presence`, `@luckystack/server`
@@ -1,5 +0,0 @@
1
- declare const SYNC_ERROR_CODES: readonly ["sync.invalidName", "sync.invalidVersion", "sync.missingReceiver", "sync.ioUnavailable", "sync.failedRequest", "sync.invalidServerResponse", "sync.invalidRequestFormat", "sync.requestTimeout", "sync.invalidRequest", "sync.invalidCallback", "sync.notFound", "sync.rateLimitExceeded", "sync.invalidInputType", "sync.serverExecutionFailed", "sync.receiverNotAllowed", "sync.notRoomMember", "sync.noReceiversFound", "sync.clientExecutionFailed", "sync.invalidClientResponse", "sync.clientRejected", "sync.disabled", "sync.methodNotAllowed", "sync.devToolsUnavailable"];
2
- /** Union of every framework-emitted `sync.*` error code. */
3
- type SyncErrorCode = (typeof SYNC_ERROR_CODES)[number];
4
-
5
- export { SYNC_ERROR_CODES as S, type SyncErrorCode as a };