@luckystack/sync 0.1.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.
- package/CHANGELOG.md +14 -0
- package/CLAUDE.md +104 -0
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/client.d.ts +126 -0
- package/dist/client.js +537 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +88 -0
- package/dist/index.js +1203 -0
- package/dist/index.js.map +1 -0
- package/docs/callback-registration.md +257 -0
- package/docs/error-states.md +252 -0
- package/docs/ignore-self.md +162 -0
- package/docs/room-fanout.md +233 -0
- package/docs/server-vs-client-handlers.md +321 -0
- package/docs/streaming.md +349 -0
- package/docs/sync-request.md +284 -0
- package/docs/version-policy.md +362 -0
- package/package.json +75 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# sync-request
|
|
2
|
+
|
|
3
|
+
> Type-safe fire-and-broadcast call from the browser into a Socket.io room. Resolves with the server result envelope after the originator's ack lands; recipients receive the merged `{ serverOutput, clientOutput }` payload via `upsertSyncEventCallback`. The HTTP/SSE fallback (`handleHttpSyncRequest`) keeps the same pipeline alive when websockets are blocked.
|
|
4
|
+
|
|
5
|
+
Documents the **caller-facing transport API**: how `syncRequest` is invoked, the response envelope, and the rare HTTP-fallback path. For handler authoring see [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md); for callback subscription see [`./callback-registration.md`](./callback-registration.md).
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 1. Function signature
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { syncRequest } from '@luckystack/sync/client';
|
|
13
|
+
|
|
14
|
+
await syncRequest({
|
|
15
|
+
name, // typed string literal — see `SyncFullName`
|
|
16
|
+
version, // typed string literal — see `VersionsForFullName<name>`
|
|
17
|
+
data, // typed via generated `SyncTypeMap` — required iff non-empty
|
|
18
|
+
receiver, // room code, or 'all' (NOT recommended in production)
|
|
19
|
+
ignoreSelf, // optional, default false
|
|
20
|
+
onStream, // optional originator-side stream callback
|
|
21
|
+
offlineDropPolicy // optional per-request offline-queue policy
|
|
22
|
+
});
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The signature is a type-driven discriminated union — under the hood `SyncParamsForFullName<F, V>` branches on `DataRequired<ClientInput>`:
|
|
26
|
+
|
|
27
|
+
- If the route's `clientInput` type allows `{}`, `data` is optional.
|
|
28
|
+
- If the route's `clientInput` carries any required key (or is a union without `{}`), `data` is required at compile time.
|
|
29
|
+
|
|
30
|
+
This is enforced through generated `SyncTypeMap` entries — no runtime check fills it in.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## 2. Argument breakdown
|
|
35
|
+
|
|
36
|
+
### `name: F extends SyncFullName`
|
|
37
|
+
|
|
38
|
+
`SyncFullName` is the union of every discovered sync route, computed from `SyncTypeMap`:
|
|
39
|
+
|
|
40
|
+
- `src/{page}/_sync/{name}_server_v{N}.ts` -> `'{page}/{name}'`
|
|
41
|
+
- `src/_sync/{name}_server_v{N}.ts` (root-level) -> `'system/{name}'`
|
|
42
|
+
|
|
43
|
+
Always pass the literal string. Any narrowing layer (`as any`, `as SyncFullName`, manual cast helpers) breaks inference and **violates rule 16** in the root `CLAUDE.md` (no `unknown`/`any` casts on typed transports).
|
|
44
|
+
|
|
45
|
+
### `version: V extends VersionsForFullName<F>`
|
|
46
|
+
|
|
47
|
+
String-literal version key (`'v1'`, `'v2'`, ...). One version per source file. See [`./version-policy.md`](./version-policy.md) for the deprecation flow when you need to ship a `v2` while `v1` consumers still exist.
|
|
48
|
+
|
|
49
|
+
### `data: ClientInputForFullName<F, V>`
|
|
50
|
+
|
|
51
|
+
Whatever `clientInput` your `_server_v{N}.ts` declares. Goes through Zod validation server-side via `validateInputByType` against the schema generated by `@luckystack/devkit`.
|
|
52
|
+
|
|
53
|
+
Validation lifecycle (server side):
|
|
54
|
+
|
|
55
|
+
1. Empty / non-object payload -> `sync.invalidRequest`.
|
|
56
|
+
2. Schema generated but payload fails -> `sync.invalidInputType` with `errorParams: [{ key: 'message', value: <readable reason> }]`.
|
|
57
|
+
3. Route has no `inputType` (Zod schema not generated yet) -> validation skipped, dev-only warn-log fires on first miss.
|
|
58
|
+
|
|
59
|
+
### `receiver: string`
|
|
60
|
+
|
|
61
|
+
Identifies the fanout target.
|
|
62
|
+
|
|
63
|
+
- A **room code** (e.g. `'Ag2cg4'`, `'board-123'`, the session token of a specific user). Sockets join rooms via `joinRoom` / `leaveRoom` from `@luckystack/core` (client).
|
|
64
|
+
- The literal `'all'` -> every connected socket on the current instance. Avoid in production: every `getRuntimeSyncMaps()` lookup runs once but the per-recipient loop runs O(N). The framework yields to the event loop every `sync.fanoutYieldEvery` recipients to avoid starving other requests, but burning N sockets per write is still a smell.
|
|
65
|
+
- Empty / non-string -> `sync.missingReceiver`.
|
|
66
|
+
|
|
67
|
+
Every socket auto-joins a room named after its own session token at connect time. That is how `streamTo(token, ...)` reaches a specific user across all of their devices.
|
|
68
|
+
|
|
69
|
+
### `ignoreSelf?: boolean` (default `false`)
|
|
70
|
+
|
|
71
|
+
Skip the originator's own sockets during fanout. See [`./ignore-self.md`](./ignore-self.md) — short version: turn it on when the sender already applied the optimistic change locally and only needs other clients to be told.
|
|
72
|
+
|
|
73
|
+
### `onStream?: SyncRequestStreamCallback`
|
|
74
|
+
|
|
75
|
+
Originator-side stream listener. Subscribes to the `buildSyncProgressEventName(responseIndex)` channel for the lifetime of this single request, then unsubscribes the moment the final ack lands. Receives **only originator-targeted** chunks emitted via `stream(payload)` from the `_server` handler (not `broadcastStream` / `streamTo` — those flow through the normal `upsertSyncEventStreamCallback` registry).
|
|
76
|
+
|
|
77
|
+
Typed via `SyncRequestStreamEvent<ServerStream>` — if the route never streams to the originator, the callback type collapses to `never` and the TS compiler will reject the call site.
|
|
78
|
+
|
|
79
|
+
### `offlineDropPolicy?: 'reject' | 'drop-oldest' | 'drop-newest'`
|
|
80
|
+
|
|
81
|
+
Per-request override of `projectConfig.offlineQueue.dropPolicy`. When the socket is offline (disconnected or browser is offline), `syncRequest` does **not** reject — it queues the request in `enqueueSyncRequest` and replays on reconnect. If the queue is full:
|
|
82
|
+
|
|
83
|
+
- `'reject'` (default) — the request resolves with `errorCode: 'offline.queueFull'`.
|
|
84
|
+
- `'drop-oldest'` — the oldest queued request is evicted; this one joins the queue.
|
|
85
|
+
- `'drop-newest'` — this request is dropped at the door; the queue stays intact.
|
|
86
|
+
|
|
87
|
+
Per-request override exists so a noisy ephemeral event (`'editor cursor move'`) can pick `'drop-oldest'` while the global default stays `'reject'` for safer mutations.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 3. Response envelope
|
|
92
|
+
|
|
93
|
+
`syncRequest` always resolves (never rejects). The promise type is `SyncRequestResponseForFullName<F, V>`:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
type SyncRequestResponseForFullName<F, V> =
|
|
97
|
+
| { status: 'error'; message: string; errorCode: string; errorParams?: SyncErrorParam[]; httpStatus?: number }
|
|
98
|
+
| { status: 'success'; message: string; result: ServerOutputForFullName<F, V> };
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
### Success branch
|
|
102
|
+
|
|
103
|
+
```ts
|
|
104
|
+
{
|
|
105
|
+
status: 'success',
|
|
106
|
+
message: string, // server-provided or `sync ${name} success`
|
|
107
|
+
result: ServerOutput // exactly what _server_v{N}.ts returned, minus its `status` field
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`result` is typed via `ServerOutputForFullName<F, V>` -> the same shape `_server_v{N}.ts`'s `main(...)` declared. **Never** narrow this with `as any` / `as unknown`; if inference fails the generated type map is wrong (regenerate via `npm run ai:index`).
|
|
112
|
+
|
|
113
|
+
### Error branch
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
{
|
|
117
|
+
status: 'error',
|
|
118
|
+
message: string, // localized via normalizeErrorResponse
|
|
119
|
+
errorCode: string, // see error-states.md for the full catalog
|
|
120
|
+
errorParams?: [{ key, value }], // i18n interpolation slots
|
|
121
|
+
httpStatus?: number // 401/403/429/500 typically
|
|
122
|
+
}
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Localization runs through `normalizeErrorResponse({ response, preferredLocale, userLanguage })` server-side, then again client-side through `normalizeErrorResponseCore`. Order of preference for the locale: `x-language` header -> `accept-language` header -> `user.language` from session.
|
|
126
|
+
|
|
127
|
+
Full catalog of `errorCode` values in [`./error-states.md`](./error-states.md).
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## 4. Lifecycle (originator side)
|
|
132
|
+
|
|
133
|
+
```
|
|
134
|
+
syncRequest({ name, version, data, receiver, ... })
|
|
135
|
+
|
|
|
136
|
+
v
|
|
137
|
+
1. Validate arguments locally:
|
|
138
|
+
- name -> sync.invalidName / routing.invalidServiceRouteName
|
|
139
|
+
- version -> sync.invalidVersion
|
|
140
|
+
- receiver -> sync.missingReceiver
|
|
141
|
+
2. waitForSocket() — ensures the socket has booted
|
|
142
|
+
- times out -> sync.ioUnavailable
|
|
143
|
+
3. canSendNow(socket):
|
|
144
|
+
- false (offline / disconnected) -> enqueueSyncRequest({...})
|
|
145
|
+
- queue full + dropPolicy='reject' -> offline.queueFull
|
|
146
|
+
- otherwise: queued, replays on `connect`
|
|
147
|
+
4. responseIndex = incrementResponseIndex()
|
|
148
|
+
5. Subscribe to buildSyncProgressEventName(responseIndex) if onStream
|
|
149
|
+
6. socket.emit('sync', { name, data, cb, receiver, responseIndex, ignoreSelf })
|
|
150
|
+
7. socket.once(buildSyncResponseEventName(responseIndex)):
|
|
151
|
+
- status: 'error' -> normalizeSyncError -> resolve(errorEnvelope)
|
|
152
|
+
- status: 'success' -> resolve({ status: 'success', message, result })
|
|
153
|
+
- other -> sync.invalidServerResponse
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The progress listener cleans itself up in the response handler. Even if the request errors out, `onStream` is unsubscribed before the promise resolves.
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 5. Concrete examples
|
|
161
|
+
|
|
162
|
+
### A. Mutation broadcast (the 90% case)
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const response = await syncRequest({
|
|
166
|
+
name: 'board/moveCard',
|
|
167
|
+
version: 'v1',
|
|
168
|
+
data: { cardId, toLane },
|
|
169
|
+
receiver: roomCode,
|
|
170
|
+
ignoreSelf: true, // we already moved it optimistically
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
if (response.status !== 'success') {
|
|
174
|
+
notify.error({ key: response.errorCode });
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
// `response.result` is typed as ServerOutput for board/moveCard v1
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### B. Optimistic send with offline tolerance
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
// Cursor moves are spammy and disposable — drop oldest if offline queue fills up.
|
|
184
|
+
await syncRequest({
|
|
185
|
+
name: 'editor/cursorMove',
|
|
186
|
+
version: 'v1',
|
|
187
|
+
data: { x, y },
|
|
188
|
+
receiver: documentId,
|
|
189
|
+
offlineDropPolicy: 'drop-oldest',
|
|
190
|
+
});
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### C. Stream consumer (originator)
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
let buffer = '';
|
|
197
|
+
const response = await syncRequest({
|
|
198
|
+
name: 'chat/sendMessage',
|
|
199
|
+
version: 'v1',
|
|
200
|
+
data: { prompt },
|
|
201
|
+
receiver: chatRoomId,
|
|
202
|
+
onStream: ({ chunk }) => {
|
|
203
|
+
buffer += chunk;
|
|
204
|
+
setPreview(buffer);
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
if (response.status === 'success') {
|
|
209
|
+
commitMessage(response.result.messageId, buffer);
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
For broadcast streams (where everyone in the room — including the sender — sees the chunks) prefer `upsertSyncEventStreamCallback` instead of `onStream`. See [`./streaming.md`](./streaming.md).
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## 6. HTTP / SSE fallback (`handleHttpSyncRequest`)
|
|
218
|
+
|
|
219
|
+
When the Socket.io transport is unavailable (corporate firewalls, mobile carriers blocking websockets), `@luckystack/server` exposes a POST endpoint that calls `handleHttpSyncRequest` directly. The pipeline is identical to the socket variant except for:
|
|
220
|
+
|
|
221
|
+
| Stage | Socket transport | HTTP transport |
|
|
222
|
+
|---|---|---|
|
|
223
|
+
| Originator chunks (`stream(payload)`) | `socket.emit(progressEventName, payload)` | `stream?.(payload)` injected callback → SSE writer |
|
|
224
|
+
| Recipients (`broadcastStream`, `streamTo`) | Socket.io fanout | Socket.io fanout (recipients still on sockets) |
|
|
225
|
+
| Sentry span | `'sync.request'` | `'sync.request.http'` |
|
|
226
|
+
| Final ack | `socket.emit(buildSyncResponseEventName(responseIndex), envelope)` | HTTP response body (`HttpSyncResponse`) |
|
|
227
|
+
|
|
228
|
+
Signature:
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
async function handleHttpSyncRequest({
|
|
232
|
+
name: string; // 'sync/<page>/<name>/v<N>' or '<page>/<name>/v<N>'
|
|
233
|
+
cb?: string; // callback handle used in recipient emits
|
|
234
|
+
data: Record<string, unknown>;
|
|
235
|
+
receiver: string;
|
|
236
|
+
ignoreSelf?: boolean;
|
|
237
|
+
token: string | null;
|
|
238
|
+
requesterIp?: string; // for IP rate-limit bucket
|
|
239
|
+
xLanguageHeader?: string | string[];
|
|
240
|
+
acceptLanguageHeader?: string | string[];
|
|
241
|
+
stream?: (payload: HttpSyncStreamEvent) => void;
|
|
242
|
+
}): Promise<HttpSyncResponse>
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`HttpSyncStreamEvent` is an alias of `SyncStreamPayload` (`Record<string, unknown>`). The server side typically wires `stream` to a Server-Sent-Events writer that flushes each chunk as `event: stream\ndata: <json>\n\n`.
|
|
246
|
+
|
|
247
|
+
The client side ships with no built-in SSE adapter — `syncRequest` always uses Socket.io. Adding an HTTP-fallback client is a project-level decision; the server side already supports it.
|
|
248
|
+
|
|
249
|
+
---
|
|
250
|
+
|
|
251
|
+
## 7. Interaction with the offline queue
|
|
252
|
+
|
|
253
|
+
Queue identity:
|
|
254
|
+
|
|
255
|
+
- Each queued request gets a `queueId = ${Date.now()}-${Math.random()}` on first queue (not on retries).
|
|
256
|
+
- The queue key is the full sync route name (`sync/<page>/<name>/v<N>`) for ordering and (optionally) coalescing in `enqueueSyncRequest`.
|
|
257
|
+
- The `run` closure captures the original arguments — when the socket reconnects, the queued call replays through the same `runRequest(socket)` body that originally enqueued it.
|
|
258
|
+
|
|
259
|
+
Replay order is FIFO unless `dropPolicy` mutates the queue. A successful replay resolves the original `syncRequest` promise; the caller's `await` simply takes longer.
|
|
260
|
+
|
|
261
|
+
Failures during replay surface the same way as if the call had run online — the promise resolves with `status: 'error'`, the caller branches on `errorCode`.
|
|
262
|
+
|
|
263
|
+
---
|
|
264
|
+
|
|
265
|
+
## 8. Types reference
|
|
266
|
+
|
|
267
|
+
| Type | Purpose | Exported from |
|
|
268
|
+
|---|---|---|
|
|
269
|
+
| `SyncRequestStreamEvent<T>` | Payload shape passed to `onStream` (originator side) | `@luckystack/sync/client` |
|
|
270
|
+
| `SyncRouteStreamEvent<T>` | Payload shape passed to `upsertSyncEventStreamCallback` (any recipient) | `@luckystack/sync/client` |
|
|
271
|
+
| `HttpSyncStreamEvent` | SSE event shape emitted by the HTTP fallback | `@luckystack/sync` |
|
|
272
|
+
| `StreamThrottle` / `CreateStreamThrottleOptions` | Throttle handle + options | `@luckystack/sync` |
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## 9. Related
|
|
277
|
+
|
|
278
|
+
- Handler authoring: [`./server-vs-client-handlers.md`](./server-vs-client-handlers.md)
|
|
279
|
+
- Stream primitives: [`./streaming.md`](./streaming.md)
|
|
280
|
+
- Subscribing to results: [`./callback-registration.md`](./callback-registration.md)
|
|
281
|
+
- Error catalog: [`./error-states.md`](./error-states.md)
|
|
282
|
+
- Versioning: [`./version-policy.md`](./version-policy.md)
|
|
283
|
+
- Offline queue config: `@luckystack/core` `OfflineQueueConfig` (`projectConfig.offlineQueue`)
|
|
284
|
+
- Architecture deep-dive: [`/docs/ARCHITECTURE_SYNC.md`](../../../docs/ARCHITECTURE_SYNC.md)
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
# version-policy
|
|
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.
|
|
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 rule 16 (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 16 in repo root [`.claude/CLAUDE.md`](../../../.claude/CLAUDE.md)
|