@spfn/core 0.3.0-beta.3 → 0.3.0-beta.31

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.
Files changed (104) hide show
  1. package/README.md +304 -12
  2. package/dist/app-config/index.d.ts +39 -1
  3. package/dist/app-config/index.js.map +1 -1
  4. package/dist/authz/index.js +47 -391
  5. package/dist/authz/index.js.map +1 -1
  6. package/dist/{boss-D16fO2oG.d.ts → boss-BiDCbbAQ.d.ts} +60 -7
  7. package/dist/codegen/index.d.ts +157 -16
  8. package/dist/codegen/index.js +772 -166
  9. package/dist/codegen/index.js.map +1 -1
  10. package/dist/config/index.js +4 -4
  11. package/dist/config/index.js.map +1 -1
  12. package/dist/contract/index.d.ts +39 -16
  13. package/dist/contract/index.js +147 -23
  14. package/dist/contract/index.js.map +1 -1
  15. package/dist/db/index.d.ts +230 -35
  16. package/dist/db/index.js +316 -160
  17. package/dist/db/index.js.map +1 -1
  18. package/dist/env/index.d.ts +6 -442
  19. package/dist/env/index.js +13 -16
  20. package/dist/env/index.js.map +1 -1
  21. package/dist/env/loader.js +24 -1
  22. package/dist/env/loader.js.map +1 -1
  23. package/dist/error-CIZlYJlL.d.ts +157 -0
  24. package/dist/errors/index.d.ts +18 -1
  25. package/dist/errors/index.js +47 -391
  26. package/dist/errors/index.js.map +1 -1
  27. package/dist/event/index.d.ts +5 -5
  28. package/dist/event/index.js +202 -3
  29. package/dist/event/index.js.map +1 -1
  30. package/dist/event/sse/client.d.ts +18 -4
  31. package/dist/event/sse/client.js +94 -18
  32. package/dist/event/sse/client.js.map +1 -1
  33. package/dist/event/sse/index.d.ts +18 -5
  34. package/dist/event/sse/index.js +452 -114
  35. package/dist/event/sse/index.js.map +1 -1
  36. package/dist/event/ws/client.d.ts +3 -2
  37. package/dist/event/ws/index.d.ts +4 -3
  38. package/dist/job/index.d.ts +48 -3
  39. package/dist/job/index.js +131 -52
  40. package/dist/job/index.js.map +1 -1
  41. package/dist/logger/index.js +0 -12
  42. package/dist/logger/index.js.map +1 -1
  43. package/dist/middleware/index.js +52 -397
  44. package/dist/middleware/index.js.map +1 -1
  45. package/dist/nextjs/index.d.ts +75 -14
  46. package/dist/nextjs/index.js +200 -84
  47. package/dist/nextjs/index.js.map +1 -1
  48. package/dist/nextjs/server.d.ts +46 -8
  49. package/dist/nextjs/server.js +15 -1
  50. package/dist/nextjs/server.js.map +1 -1
  51. package/dist/ops/index.d.ts +61 -42
  52. package/dist/ops/index.js +648 -97
  53. package/dist/ops/index.js.map +1 -1
  54. package/dist/registry-C2KQCh0m.d.ts +473 -0
  55. package/dist/route/index.d.ts +5 -2
  56. package/dist/route/index.js +107 -28
  57. package/dist/route/index.js.map +1 -1
  58. package/dist/route/types.d.ts +7 -1
  59. package/dist/{router-Cy7rAfmj.d.ts → route-builder-DRW-MSCJ.d.ts} +51 -126
  60. package/dist/router-C57y-vz1.d.ts +184 -0
  61. package/dist/router-CX5uVjmU.d.ts +406 -0
  62. package/dist/server/index.d.ts +130 -7
  63. package/dist/server/index.js +1174 -164
  64. package/dist/server/index.js.map +1 -1
  65. package/dist/state/client.d.ts +206 -0
  66. package/dist/state/client.js +678 -0
  67. package/dist/state/client.js.map +1 -0
  68. package/dist/state/index.d.ts +443 -0
  69. package/dist/state/index.js +644 -0
  70. package/dist/state/index.js.map +1 -0
  71. package/dist/state/react.d.ts +49 -0
  72. package/dist/state/react.js +50 -0
  73. package/dist/state/react.js.map +1 -0
  74. package/dist/surface/index.d.ts +115 -0
  75. package/dist/surface/index.js +1013 -0
  76. package/dist/surface/index.js.map +1 -0
  77. package/dist/token-manager-Ckjt2-qq.d.ts +122 -0
  78. package/dist/{types-CfYVhIQ9.d.ts → types-5Rl11_Nb.d.ts} +43 -4
  79. package/dist/types-CKH0D3Dy.d.ts +441 -0
  80. package/dist/{types-ZQODsBft.d.ts → types-CurMab2Q.d.ts} +52 -2
  81. package/dist/{types-2AbaW4Ie.d.ts → types-DncqXfWN.d.ts} +4 -3
  82. package/docs/file-upload.md +195 -333
  83. package/package.json +41 -11
  84. package/src/cache/README.md +330 -0
  85. package/src/codegen/README.md +721 -0
  86. package/src/config/README.md +333 -0
  87. package/src/contract/README.md +442 -0
  88. package/src/db/README.md +589 -0
  89. package/src/db/manager/README.md +500 -0
  90. package/src/db/schema/README.md +354 -0
  91. package/src/db/transaction/README.md +845 -0
  92. package/src/env/README.md +787 -0
  93. package/src/errors/README.md +445 -0
  94. package/src/event/README.md +994 -0
  95. package/src/job/README.md +613 -0
  96. package/src/logger/README.md +321 -0
  97. package/src/middleware/README.md +634 -0
  98. package/src/nextjs/README.md +756 -0
  99. package/src/route/README.md +763 -0
  100. package/src/security/README.md +100 -0
  101. package/src/server/README.md +704 -0
  102. package/src/state/README.md +806 -0
  103. package/dist/token-manager-BT5EnUAR.d.ts +0 -278
  104. package/dist/types-9oszaJqp.d.ts +0 -250
@@ -0,0 +1,806 @@
1
+ # @spfn/core/state — server-owned state, declared once
2
+
3
+ A signed-in shell shows a handful of per-person facts: has this person a workspace, agreed to
4
+ the current terms, which role here, is the runtime ready. Each used to be wired by hand three
5
+ times — a read route for the first paint, a domain event the browser re-interprets into a
6
+ value, and a client store that merges the two — and the three drifted.
7
+
8
+ `defineState` declares such a fact once. `defineStateRouter` derives from that one
9
+ declaration the read route, the write route, its entry in a snapshot route and the SSE event.
10
+ The server is the single source of truth: every value a client receives is what `read()`
11
+ returned, read on the server; the browser never computes one.
12
+
13
+ The browser side — `@spfn/core/state/client` and the React hook `@spfn/core/state/react` —
14
+ is described under [Browser](#browser).
15
+
16
+ ## Import paths
17
+
18
+ ```typescript
19
+ // server
20
+ import { defineState, defineStateRouter } from '@spfn/core/state';
21
+ import type { InferStateValue, InferStateKey, InferStateInput } from '@spfn/core/state';
22
+ import { Type } from '@sinclair/typebox'; // for .key() / .value() / .data() / .write() schemas
23
+
24
+ // browser — the router by type only
25
+ import { createStateClient } from '@spfn/core/state/client';
26
+ import { useConnectionStatus, useServerState } from '@spfn/core/state/react';
27
+ import type { states } from '@/server/states';
28
+ ```
29
+
30
+ ---
31
+
32
+ ## Public API (complete)
33
+
34
+ From `@spfn/core/state`:
35
+
36
+ - Builder: `defineState(id)` → `StateBuilder`
37
+ - Router: `defineStateRouter(states, { path?, subject?, use?, skip?, contract? })` → `StateRouter`
38
+ - Types: `StateBuilder`, `StateRouter`, `StateRouterOptions`, `SnapshotRouteOptions`, `StateRoutes`, `StateEvents`,
39
+ `StatesSnapshot`, `StatesManifest`, `SubjectResolver`, `InferStateValue`, `InferStateKey`, `InferStateInput`,
40
+ `StateShape`, `StateValue`, `StateKey`, `StateResult`, `StateSnapshot`, `StateFrame`,
41
+ `ReadContext`, `PermissionContext`, `WritePermissionContext`, `WriteContext`, `ChangedPayload`, `GrantTarget`,
42
+ `RevokeTarget`, `StateContract`, `StateWriteOptions`, `RoutableState`
43
+
44
+ `StateBuilder` methods — immutable, every call returns a new builder, order free, no terminal
45
+ method:
46
+
47
+ | Method | Effect | Refused |
48
+ | --- | --- | --- |
49
+ | `.key(schema, { shared? })` | Key fields: one value per key. `shared: true` — the value does not depend on the subject | a non-object schema; a field that is not a required `Type.String()`; a field named `events` or `token`; a second call |
50
+ | `.value(schema)` | The value. Required | a second call |
51
+ | `.data(schema)` | Data attached to the value. Optional | a second call |
52
+ | `.principal<P>()` | Type-only: who the callbacks' `principal` is — what the app's auth middleware sets as `c.get('auth')`. The write route then refuses a request whose `auth` is `undefined` or `null` (401) | a second call |
53
+ | `.use(mws)` / `.skip(names)` | Middleware for the two per-state routes, `get<Id>State` / `put<Id>State`; calls add up. **Not authorization** — the snapshot and the SSE stream cannot run it; authorize with `.canRead()` (see [Permissions](#permissions)) | at `defineStateRouter`, when the state declares no `.canRead()` |
54
+ | `.contract({ since, auth?, requiresSession?, deprecatedIn?, removedIn? })` | Publishes the read route, the write route and the event | a second call |
55
+ | `.canRead(fn)` | `({ subject, principal, key? }) => boolean \| Promise<boolean>`. Required on a keyed state | a second call |
56
+ | `.canWrite(fn)` | Same shape; `principal` without `undefined` (below). Absent → `canRead` decides | a second call |
57
+ | `.read(fn)` | The only source of a value. Required. Returns `{ value }`, or `{ value, data }` with `.data` — checked against the declaration on every path | a second call |
58
+ | `.write(input, fn, { since?, deprecatedIn?, removedIn? })` | Derives the write route. `fn` gets the read context plus `input` and returns nothing | a second call |
59
+ | `.changedBy(events)` | Domain events whose payload is read like the change signal's (see [The change signal](#the-change-signal)) | a second call; at `defineStateRouter`, an event whose payload schema lacks a required field |
60
+
61
+ `StateBuilder` members: `.changed` (the change signal), `.grant({ subject, key })` (`key` on a
62
+ keyed state only, and required there), `.revoke({ subject, key? })`, `.id`.
63
+
64
+ `StateRouter`: `.router` (a `Router` from `defineRouter`), `.events` (`Record` of the SSE
65
+ events keyed by wire name), `.states` (the declarations as given).
66
+
67
+ `defineStateRouter` options:
68
+
69
+ | Option | Effect |
70
+ | --- | --- |
71
+ | `path` | Where the routes are mounted, default `/state` |
72
+ | `subject` | Who an HTTP request is for, default `c => c.get('auth')?.userId ?? null` |
73
+ | `use` | Middleware for the snapshot route `GET <path>` only. **Not authorization**; refused when a state declares no `.canRead()`. An empty `use` is not a middleware and is not refused |
74
+ | `skip` | Server-level named middlewares the snapshot route skips; `'*'` skips every one. Refused like `use` |
75
+ | `contract` | `{ since, auth?, requiresSession?, deprecatedIn?, removedIn? }` — publishes the snapshot route; its response schema is derived |
76
+
77
+ `use`, `skip` and `contract` reach the snapshot route only. A per-state route keeps exactly its
78
+ own state's `.use()` / `.skip()` / `.contract()`; nothing is merged either way. A server that
79
+ gates every request behind a named middleware (say `'requirements'`: "finish your onboarding")
80
+ lets the onboarding states `.skip(['requirements'])` and the router `skip: ['requirements']`, so
81
+ the shell's first snapshot is served to exactly the people it routes. The router does not infer
82
+ the snapshot's skips from its states'.
83
+
84
+ The types of `.read()`, `.write()`, `.canRead()` and `.canWrite()` contexts and of `R` are
85
+ computed from the schemas declared *before* the call — runtime order is free, but declare
86
+ `.key()`, `.value()`, `.data()` and `.principal()` first to have the functions checked against them.
87
+
88
+ `principal` in each callback, with `.principal<P>()` declared:
89
+
90
+ | Context | `principal` | Why |
91
+ | --- | --- | --- |
92
+ | `ReadContext` (`.read`) | `P \| undefined` | the change signal's re-read has no request, so none |
93
+ | `PermissionContext` (`.canRead`) | `P \| undefined` | SSE connect passes what `resolvePrincipal` returned, possibly `undefined` |
94
+ | `WritePermissionContext` (`.canWrite`) | `P` | asked on `PUT <path>/<id>` only, which refuses a request whose `auth` is `undefined` or `null` |
95
+ | `WriteContext` (`.write`) | `P` | the same route |
96
+
97
+ Without `.principal()` every one is `unknown`. `defineState<AuthContext>('consent')` is not the
98
+ form: an explicit type argument would default the id to `string` and lose the route-name
99
+ literals. Nor is a router option: the router cannot retype callbacks declared before it.
100
+
101
+ ```typescript
102
+ export const consent = defineState('consent')
103
+ .principal<AuthContext>()
104
+ .value(Consent)
105
+ .canWrite(({ principal }) => principal.user.verified) // AuthContext
106
+ .read(({ subject }) => consentService.current(subject))
107
+ .write(Input, ({ principal, input }) => consentService.record(principal.user.email, input));
108
+ ```
109
+
110
+ ---
111
+
112
+ ## Quick start
113
+
114
+ ```typescript
115
+ // src/server/state.ts
116
+ import { defineState, defineStateRouter } from '@spfn/core/state';
117
+ import { Type } from '@sinclair/typebox';
118
+
119
+ export const consent = defineState('consent') // keyless: one value per subject
120
+ .value(Type.Union([Type.Literal('pending'), Type.Literal('agreed')]))
121
+ .data(Type.Object({ marketing: Type.Boolean() }))
122
+ .contract({ since: '1.3.0', auth: 'clientProofV1', requiresSession: true })
123
+ .read(({ subject }) => consentService.current(subject))
124
+ .write(Type.Object({ termsVersion: Type.String(), marketing: Type.Boolean() }),
125
+ ({ subject, input }) => consentService.record(subject, input));
126
+
127
+ export const runtime = defineState('runtime') // keyed, shared by every reader of the key
128
+ .key(Type.Object({ workspaceId: Type.String() }), { shared: true })
129
+ .value(Type.Union([Type.Literal('provisioning'), Type.Literal('ready')]))
130
+ .canRead(({ subject, key }) => memberService.isMember(subject, key.workspaceId))
131
+ .canWrite(({ subject, key }) => memberService.isAdmin(subject, key.workspaceId))
132
+ .read(({ key }) => runtimeService.status(key.workspaceId))
133
+ .write(Type.Object({ action: Type.Literal('restart') }), ({ key }) => runtimeService.restart(key.workspaceId));
134
+
135
+ export const member = defineState('member') // keyed, one value per (subject, key)
136
+ .key(Type.Object({ workspaceId: Type.String() }))
137
+ .value(Type.Union([Type.Literal('owner'), Type.Literal('admin'), Type.Literal('member')]))
138
+ .canRead(({ subject, key }) => memberService.isMember(subject, key.workspaceId))
139
+ .read(({ subject, key }) => memberService.roleOf(subject, key.workspaceId));
140
+
141
+ export const states = defineStateRouter({ consent, runtime, member });
142
+ ```
143
+
144
+ ```typescript
145
+ // server.config.ts
146
+ export default defineServerConfig()
147
+ .routes(appRouter.packages([states.router]))
148
+ .events(defineEventRouter({ ...appEvents, ...states.events }), { auth: { enabled: true, resolvePrincipal } })
149
+ .build();
150
+ ```
151
+
152
+ `.packages([states.router])` is the documented mount: the routes reach the generated route map
153
+ like `authRouter`'s, and an app route with the same name is refused by the route-map
154
+ generator's existing duplicate check. Spreading `states.router.routes` into the app router also
155
+ works. `read()` and `write()` are service calls; database access stays in repositories.
156
+
157
+ After a commit that changed a value, the app emits the change signal:
158
+
159
+ ```typescript
160
+ await consent.changed.emit({ subject: userId });
161
+ await member.changed.emit({ workspaceId, subject: userId });
162
+ await runtime.changed.emit({ workspaceId });
163
+ ```
164
+
165
+ ---
166
+
167
+ ## Three kinds of subject
168
+
169
+ | Declaration | `read()` receives | One value per | Frame reaches |
170
+ | --- | --- | --- | --- |
171
+ | no `.key()` | `{ subject, principal }` | subject | that subject's connections |
172
+ | `.key(K)` | `{ subject, principal, key }` | subject and key | that subject's connections that declared the key |
173
+ | `.key(K, { shared: true })` | `{ principal, key }` — no `subject` | key | every connection that declared the key and passed `canRead` |
174
+
175
+ A keyed state must declare `.canRead()`: the key comes from the request, so without it any
176
+ subject could read any key. `defineStateRouter` refuses one that does not.
177
+
178
+ Key fields are required strings. They travel as query parameters (always strings) and the
179
+ SSE frame filter compares them with the connect query as strings, so a `Type.Number()` key
180
+ would never match — `.key()` refuses it rather than coercing on one side. `events` and
181
+ `token` are refused as field names: the SSE stream reads them from the same connect query.
182
+
183
+ ---
184
+
185
+ ## What one declaration derives
186
+
187
+ With the default `path` `/state`:
188
+
189
+ | Derived | Shape |
190
+ | --- | --- |
191
+ | `getConsentState` | `GET /state/consent` → `{ at, value, data }`. Keyed: key fields in the query, validated by the route (missing → 400) |
192
+ | `putConsentState` | `PUT /state/consent`, only when `.write()` exists. Body = the input schema, keyed: key fields in the query. → `{ at, value, data }` |
193
+ | `getStates` | `GET /state` → `{ at, states: { consent: { value, data }, … } }` in declaration order — one for all states of the router |
194
+ | `states.events['state.consent.changed']` | the SSE event, payload `{ at, value, data, key?, subject? }` — `key` on a keyed state, `subject` unless shared. It declares `.canSubscribe` and `.filter` from `canRead` and the key, and `.initial`: a connection it allows receives the state's current frame — one `read()` for its subject and the key in its connect query — right after `connected` |
195
+ | `consent.changed` | the change signal, payload keyless `{ subject }`, keyed `{ ...keyFields, subject }`, shared `{ ...keyFields }` |
196
+
197
+ `data` is present exactly when `.data()` is declared, in every response and frame.
198
+
199
+ The derived router also carries the manifest as a non-enumerable `Router._stateManifest` — the
200
+ object `getStates` answers as `manifest`, carried through `.use()` / `.packages()` /
201
+ `.contractVersion()` — which `@spfn/core:states` reads to write the declared ids
202
+ ([Declared ids at runtime](#declared-ids-at-runtime)).
203
+
204
+ Route names are `get<Id>State` / `put<Id>State` with the id's first letter upper-cased, so an
205
+ id must match `^[a-z][a-zA-Z0-9]*$` — `defineState` refuses another.
206
+
207
+ `defineStateRouter` refuses, naming the state: no `.value()` or `.read()`; a keyed state
208
+ without `.canRead()`; a state with `.use()` or `.skip()` but no `.canRead()`; a router `use`
209
+ or `skip` while any state declares no `.canRead()` (the first such state is named); a
210
+ `.changedBy()` event whose payload schema lacks a field the signal needs; two states with one
211
+ id; a `.write(…, { since })` on a state without `.contract()`; a state already grouped by
212
+ another router (each router subscribes the change signal, so every change would be read and
213
+ sent twice). A builder without `.value()` or
214
+ `.read()` is also a type error at the call.
215
+
216
+ ---
217
+
218
+ ## Handler order
219
+
220
+ The subject comes from `options.subject(c)`, default `c.get('auth')?.userId ?? null` — the
221
+ SSE `getSubject` default. No subject → `UnauthorizedError` (401). The principal on HTTP is
222
+ `c.get('auth')` as the app's middleware set it. A state that declared `.principal()` refuses a
223
+ write whose request's `auth` is `undefined` or `null` with `UnauthorizedError` (401), after the subject and before
224
+ `canWrite`; the read and snapshot routes never refuse on a missing principal.
225
+
226
+ - **Read** `GET <path>/<id>`: subject → key (query, validated) → `canRead` (false → 403) →
227
+ `at` → `read()` → `{ at, value, data }`.
228
+ - **Write** `PUT <path>/<id>`: subject → principal (whose `auth` is `undefined` or `null` → 401) → key →
229
+ `canWrite ?? canRead` (false → 403, `write` not called) → `write({ …, input })` → `at` →
230
+ `read()` → one SSE frame → `{ at, value, data }`.
231
+ When the handler runs inside a transaction (`.use([Transactional()])`), the frame is
232
+ published after that transaction commits (see [Frames and transactions](#frames-and-transactions)).
233
+ A `write` that throws propagates; nothing is read and no frame is sent. The input is a
234
+ command, never the value: what the route answers is re-read.
235
+ - **Snapshot** `GET <path>`: subject → one `at` → for each state, in declaration order: a keyed
236
+ state whose key fields are not all in the query is omitted; `canRead` false → omitted;
237
+ otherwise `read()`. A keyed state left out never stops a later keyless one. A `read()` that
238
+ throws fails the whole request — an error is not a permission. The answer is
239
+ `{ at, states, manifest }`: `manifest` is `{ [id]: { key: string[]; write: boolean } }` for
240
+ every state in declaration order — the key fields (`[]` when keyless) and whether it has a
241
+ write — built once when the router is built. It is what the [browser client](#browser)
242
+ knows of the router at runtime.
243
+
244
+ Every `read()` result is checked against `{ value, data }` as declared — `data` exactly when
245
+ `.data()` is, no other field — with a schema built once per state when the router is built.
246
+ A result that does not match fails the read, write and snapshot routes with an `Error` (500)
247
+ naming the state and the first validation message, never the value; on the change signal it
248
+ is logged at warn and sends nothing; `grant` rejects with the same error and sends nothing. On
249
+ the stream's connect it is logged at warn naming the state's event (never the error), the state
250
+ is left out of `connected.initial` and sends no initial frame. The client's snapshot read then
251
+ fails too — the snapshot route reads every state — so the entry keeps its last value, `onError`
252
+ is called, and the state recovers with its next frame or the next connect.
253
+
254
+ A write whose `read()` then fails — it threw or returned an undeclared result — answers 500
255
+ and sends no frame. Without a transaction the write has already committed: there is no value
256
+ to report, and the next signal or read brings the client up to date. Under
257
+ `.use([Transactional()])` the failed read rolls the write back with it, so nothing changed.
258
+
259
+ The derived routes are ordinary `RouteDef`s registered by `registerRoutes`, so `.use()`
260
+ middleware runs inside the route's own chain, after the app's global middleware — it sees the
261
+ `auth` the app set. A state's `.use()` / `.skip()` / `.contract()` reach its two per-state
262
+ routes only; the router's `use` / `skip` / `contract` reach the snapshot route only. The SSE
263
+ stream runs neither. That is why `.use()` / `.skip()` — the state's and the router's — require
264
+ `.canRead()`: a guard in middleware would hold on one route and leak through the others and the
265
+ stream. A state that needs middleware only for, say, rate limiting declares
266
+ `.canRead(() => true)` to say so.
267
+
268
+ ---
269
+
270
+ ## The change signal
271
+
272
+ The app emits `state.changed` **after the commit** that changed the value — from
273
+ `onAfterCommit` (`@spfn/core/db`), not inside the transaction. An emit made inside a
274
+ transaction anyway reads there and sends its frame at the commit (see
275
+ [Frames and transactions](#frames-and-transactions)). The module never reads on its own: no
276
+ polling, no database trigger. On each signal it reads and sends one frame:
277
+
278
+ | State | Read | Frame |
279
+ | --- | --- | --- |
280
+ | keyless | `read({ subject })` | `{ at, value, data, subject }` |
281
+ | keyed | `read({ subject, key })` | `{ at, value, data, key, subject }` |
282
+ | keyed shared | `read({ key })` once, however many connections hold the key | `{ at, value, data, key }` |
283
+
284
+ `emit` validates its payload and refuses a missing field — the module reads exactly what the
285
+ payload names. On the signal path `principal` is `undefined`. A `read()` that throws is logged
286
+ at warn with the state id and sends nothing; the next signal works as usual.
287
+
288
+ The signal is in-process: the pod that committed reads and emits, and the SSE event (wired to
289
+ the cache like any event in `.events()`) carries the frame to every pod. Do not attach a cache
290
+ to `state.changed` itself — every pod would read and send the frame again.
291
+
292
+ `changed.emit` on a builder no router grouped throws, naming the state. Every builder call
293
+ returns a new builder and the router subscribes the signal of the instance it was given, so
294
+ export that final instance — an intermediate `base` in `base.write(…)` is never routed.
295
+
296
+ **`.changedBy([event, …])`.** A domain event the app already emits can stand in for the
297
+ signal: the router subscribes each listed `EventDef` and reads its payload like
298
+ `changed.emit`'s — keyless `subject`; keyed the key fields and `subject`; shared the key
299
+ fields. `defineStateRouter` refuses an event whose declared payload schema lacks one of those
300
+ as a required property, naming the event and the field. A payload that fails the same check at
301
+ runtime is logged at warn and skipped (the app emitted it; there is nobody to throw to). The
302
+ module attaches nothing to the event — no `canSubscribe`, no `filter`; it only subscribes.
303
+
304
+ - An event listed by two states re-reads both and sends two frames.
305
+ - An event that is also in the app's SSE router reaches the browser twice by design: as itself,
306
+ and through the state as `state.<id>.changed`.
307
+ - One pod re-reads: the pod that emitted the event. A listed event bound to the cache transport
308
+ (every event in `.events()` is) reaches every pod, so the router subscribes it with
309
+ `{ localOnly: true }`; the derived `state.<id>.changed` frame then fans out to every pod
310
+ through the cache like any event. One emit, one read, one frame.
311
+ - On the emitting pod `await domainEvent.emit()` also awaits the state's `read()` and its frame
312
+ publish, so a slow `read()` delays the caller of `emit`.
313
+ - The payload is checked against the signal's fields only; a field the state does not name is
314
+ ignored, even when the key schema declares `additionalProperties: false`.
315
+
316
+ `read()`'s result is copied into the frame, so neither a handler mutating what it returned nor
317
+ a subscriber mutating the payload changes the other.
318
+
319
+ ### Frames and transactions
320
+
321
+ Every frame — of the write route, of the change signal and of `grant` — is sent by one rule:
322
+
323
+ | Where the read ran | Frame |
324
+ | --- | --- |
325
+ | outside a transaction | emitted at once and awaited; an emit that throws fails the write or `grant`; on the change signal it is logged at warn and `changed.emit` resolves |
326
+ | inside a transaction | queued with `onAfterCommit` and published after the root transaction commits; never when the transaction that read it, or any transaction around it, rolls back |
327
+
328
+ - The frame carries the value read inside the transaction, with that read's `at`; nothing is
329
+ read again after the commit. The response is the same snapshot.
330
+ - So a `changed.emit` or a `grant` made inside a transaction is deferred to its commit, like a
331
+ write.
332
+ - A `Transactional()` nested in an outer `runInTransaction` is a savepoint: its frame is
333
+ handed to the outer transaction and published at the outer commit, or dropped if the
334
+ handler throws.
335
+ - A deferred emit runs outside the transaction context; an error it throws is logged by the
336
+ transaction runner and reaches nobody.
337
+
338
+ ---
339
+
340
+ ## Permissions
341
+
342
+ | Predicate | Enforced at | On refusal |
343
+ | --- | --- | --- |
344
+ | `canRead` | `GET <path>/<id>` | 403 |
345
+ | `canRead` | `GET <path>` | that state omitted |
346
+ | `canRead` | SSE connect (`canSubscribe`), with the key from the connect query | that event in `connected.deniedEvents` |
347
+ | `canWrite ?? canRead` | `PUT <path>/<id>` | 403; with `.principal()` declared, a request whose `auth` is `undefined` or `null` is 401 before it is asked |
348
+
349
+ On SSE the connection declares its key in the connect query —
350
+ `/events/stream?events=state.runtime.changed&workspaceId=w1&token=…`. The module sets each
351
+ derived event's `.canSubscribe()` and `.filter()` (`@spfn/core/event`):
352
+
353
+ - `canSubscribe`: keyed — any key field missing from the query denies without calling
354
+ `canRead`; otherwise `canRead({ subject, principal, key })`. Keyless — `canRead` when
355
+ declared, else allowed (a subject exists; the handler asks only then). `principal` is what
356
+ the SSE auth config's `resolvePrincipal` returned.
357
+ - `filter`: every key field of the frame equals the query's, and a frame carrying `subject`
358
+ reaches only that subject.
359
+
360
+ `canRead` runs once per connection. A membership that ends while the connection is open is
361
+ the app's `revoke` call — nothing re-evaluates it.
362
+
363
+ **State events require an authenticated stream** (`auth: { enabled: true }` in `.events()`).
364
+ Without auth a connection has no subject, the handler never asks `canSubscribe`, and so
365
+ `canRead` would be skipped; the derived `filter` therefore refuses every connection without a
366
+ subject, and no state frame is delivered on such a stream.
367
+
368
+ ## `grant` / `revoke`
369
+
370
+ ```typescript
371
+ await runtime.revoke({ subject: userId }); // membership ended
372
+ await runtime.grant({ subject: userId, key: { workspaceId } }); // membership began
373
+ ```
374
+
375
+ `grant` opens exactly the granted key and does not consult `canRead` — it is the app's
376
+ statement that the subject may now read it. On a keyed state it calls the derived event's
377
+ `grant(subject, { query: key })`, so only the subject's connections whose connect query
378
+ declared that key are activated; a tab of the same subject on another key — one `canRead`
379
+ refused — stays shut. Then it reads the current value for that subject and key and sends it as
380
+ one frame, so the client has the value at once. `key` is required on a keyed state (a type
381
+ error, and a refusal naming the state at runtime); a keyless state takes `{ subject }`.
382
+
383
+ The key is checked against `.key()`: an empty or partial key (or a non-string field) rejects
384
+ naming the state and the field, since it would match every connection of the subject. A field
385
+ `.key()` does not declare is dropped, never matched on. `revoke({ subject, key })` checks the
386
+ same way.
387
+
388
+ `revoke({ subject, key })` deactivates the event on the subject's connections that declared
389
+ `key`; `revoke({ subject })` without a key reaches every connection of the subject, whatever
390
+ key it declared. Each receives `subscription.revoked`.
391
+
392
+ Both reach every pod through the cache transport, which applies the same key rule. A `read()`
393
+ that throws, or returns an undeclared result, in `grant` rejects the call and sends nothing
394
+ (the grant itself is already applied). Across pods the grant's control message and its frame
395
+ travel on separate channels, so a tab on another pod may still be denied when the frame arrives
396
+ and drop it; the next signal brings it up to date.
397
+
398
+ ---
399
+
400
+ ## `at`
401
+
402
+ `at` is `new Date().toISOString()` taken **before** `read()` starts. Every response and frame
403
+ carries the `at` of the read that produced it, and a snapshot carries one `at` for all its
404
+ states. A client applies a value only when its `at` is not older than the one it holds.
405
+
406
+ `at` is wall-clock: across pods it is as good as their clocks, typically within a few
407
+ milliseconds of each other. Two values with the same `at` are resolved as "same value, no
408
+ change" by the client (implemented in the client half). There is no version counter.
409
+
410
+ ## Contract
411
+
412
+ `.contract({ since, auth?, requiresSession?, deprecatedIn?, removedIn? })` publishes, in
413
+ `contracts/current.json`:
414
+
415
+ - the read operation, response `Type.Object({ at: Type.String(), value, data })` (`data` when declared),
416
+ key fields as the request query;
417
+ - the write operation, request body = the input schema, the same response, and its own
418
+ `since` / `deprecatedIn` / `removedIn` from `.write(…, options)` falling back field by field
419
+ to the state's;
420
+ - the SSE event `state.<id>.changed` with `since` / `deprecatedIn` / `removedIn`.
421
+
422
+ A state without `.contract()` appears nowhere, like a route without one. The snapshot route is
423
+ contracted by the router's own `contract` option, never by a state's: response
424
+ `{ at, states: { [id]?: { value, data? } }, manifest: { [id]: { key: string[], write: boolean } } }`,
425
+ each `states` entry optional and each `manifest` entry required, in declaration order — built
426
+ once when the router is built. Without that option it appears nowhere. A `value` literal removed later is `response.type-changed` on the read
427
+ operation and `event.payload.type-changed` on the event, as any route and event.
428
+
429
+ ---
430
+
431
+ ## Browser
432
+
433
+ `@spfn/core/state/client` is the browser side of a state router: it calls the derived read,
434
+ write and snapshot routes, keeps one SSE stream open for every state, and decides per
435
+ `(id, key)` which of a snapshot, a frame, a read or a write answer is current.
436
+ `@spfn/core/state/react` puts one state into a component. The router is known **by type
437
+ only** — `import type { states }` — so no server code reaches the browser bundle;
438
+ `@spfn/core/state/client` imports nothing from `@spfn/core/state` at runtime.
439
+
440
+ A page load costs one snapshot read, and the server makes it: the server component's render,
441
+ handed to the client as `initial`. The browser makes no snapshot `GET` of its own — the stream
442
+ serves each allowed state's current frame on connect ([The stream](#the-stream)).
443
+
444
+ ```typescript
445
+ // src/client/state.ts
446
+ import { createStateClient } from '@spfn/core/state/client';
447
+ import { createAuthSSEClient } from '@spfn/core/event/sse/client';
448
+ import type { states } from '@/server/states'; // type only
449
+
450
+ export const state = createStateClient<typeof states>({
451
+ sse: createAuthSSEClient(), // the state client's own
452
+ api: { baseUrl: '/api/rpc' }, // passed to createApi
453
+ });
454
+ ```
455
+
456
+ ```tsx
457
+ 'use client';
458
+ import { useServerState } from '@spfn/core/state/react';
459
+ import { state } from '@/client/state';
460
+
461
+ function Shell({ workspaceId }: { workspaceId: string })
462
+ {
463
+ const consent = useServerState(state, 'consent'); // keyless
464
+ const runtime = useServerState(state, 'runtime', { workspaceId }); // keyed
465
+
466
+ if (consent.status === 'loading' || runtime.status === 'loading')
467
+ {
468
+ return <Spinner />;
469
+ }
470
+
471
+ return consent.value === 'pending'
472
+ ? <Terms onAgree={marketing => consent.write({ termsVersion: '2026-10', marketing })} />
473
+ : <Workspace runtime={runtime.value} />;
474
+ }
475
+ ```
476
+
477
+ ### The manifest
478
+
479
+ A type cannot be read at runtime, and the client needs three facts the router type carries:
480
+ every state id (the stream requests every state event), each keyed state's key fields (to tell
481
+ which key a snapshot entry belongs to) and which states have a write. The snapshot route
482
+ answers them as `manifest`, so the app declares the router once, on the server.
483
+
484
+ The first `subscribe` — or `get`, `write`, `snapshot` — therefore reads the snapshot **first**:
485
+ the client installs its manifest, then applies its states, then opens the stream with
486
+ `events=` from the manifest, in its order. Until then a `subscribe` waits (it never throws),
487
+ and `current()` is `undefined`; a `subscribe` while that snapshot is in flight is served by it,
488
+ not by a second read. Once the manifest is known, a `subscribe` whose id it does not name, or
489
+ whose key fields differ from the state's, is refused: the error, naming the state and both sets
490
+ of fields, goes to `onError`, and the subscription is dropped without reopening the stream. A
491
+ `write` on a state the manifest marks `write: false` rejects naming the state, before any
492
+ request. A first snapshot that fails goes to `onError` (or rejects the call that needed it).
493
+ While subscriptions wait on it, it is read again every `retryDelay` (default 3000 ms, the SSE
494
+ client's `reconnectDelay`), each failure to `onError`, until it succeeds, `close()` runs,
495
+ nothing is subscribed, or `maxRetries` retries failed too (default `0`: no limit) — then the
496
+ status is `'failed'` and no retry is set ([Connection status](#connection-status)); a `get`,
497
+ `write` or `snapshot` that reads it first serves the waiting subscriptions too — whichever
498
+ call brings the manifest opens the stream. `close()` while it is
499
+ in flight discards its answer, and a `get` or `write` waiting on it rejects: the client was
500
+ closed.
501
+
502
+ The manifest may also come from `initial` — the snapshot the server rendered with (below). Such a
503
+ client knows it from construction: nothing above waits, a refused `subscribe` goes to `onError` at
504
+ once, and the first `subscribe` opens the stream without reading the snapshot first.
505
+
506
+ ### Declared ids at runtime
507
+
508
+ The manifest says what the **server** serves; nothing the browser executes says what the **app**
509
+ declared — `typeof states` is erased at build, and importing the router would pull every state's
510
+ `read()`, with its repositories and database, into the layout. The `@spfn/core:states` generator
511
+ writes those ids and their manifest into a file with no import:
512
+
513
+ ```typescript
514
+ // .spfnrc.ts
515
+ import { defineConfig, defineGenerator } from '@spfn/core/codegen';
516
+ import type { StatesGeneratorConfig } from '@spfn/core/codegen';
517
+
518
+ export default defineConfig({
519
+ generators: [
520
+ defineGenerator<StatesGeneratorConfig>({
521
+ name: '@spfn/core:states',
522
+ routerPath: './src/server/router.ts', // the router route-map reads
523
+ outputPath: './src/generated/states.ts', // the default
524
+ }),
525
+ ],
526
+ });
527
+ ```
528
+
529
+ An app lists its `@spfn/core:route-map` generator in the same `generators` array.
530
+
531
+ ```typescript
532
+ // src/generated/states.ts — DO NOT EDIT
533
+ export const stateIds = ['consent', 'member', 'workspaces'] as const;
534
+ export type StateId = (typeof stateIds)[number];
535
+ export const stateManifest: Readonly<Record<StateId, { readonly key: readonly string[]; readonly write: boolean }>> = {
536
+ consent: { key: [], write: true },
537
+ member: { key: ['workspaceId'], write: false },
538
+ workspaces: { key: [], write: false },
539
+ };
540
+ ```
541
+
542
+ An app root compares it with the snapshot it read before rendering, so a deploy skew between the
543
+ Next.js server and the API is reported there instead of as `state.<id>` being `undefined` in a screen:
544
+
545
+ ```tsx
546
+ const snapshot = await api.getStates.call({ query });
547
+ const skewed = stateIds.filter(id => JSON.stringify(snapshot.manifest[id]) !== JSON.stringify(stateManifest[id]));
548
+
549
+ if (skewed.length > 0)
550
+ {
551
+ return <DeploySkew states={skewed} />;
552
+ }
553
+ ```
554
+
555
+ That goes from declared to served. The reverse — an id the server serves that this build lacks —
556
+ indexes `stateManifest` with a `string`, which does not compile under `strict`.
557
+ `Array.prototype.includes` is not a type guard, so declare one:
558
+
559
+ ```typescript
560
+ const isStateId = (id: string): id is StateId => (stateIds as readonly string[]).includes(id);
561
+
562
+ const unknownToThisBuild = Object.keys(snapshot.manifest).filter(id => !isStateId(id));
563
+ ```
564
+
565
+ The generator refuses a router that mounts no state router, and two state routers declaring one
566
+ id; see [`../codegen/README.md`](../codegen/README.md) → "Built-in: `@spfn/core:states`".
567
+
568
+ ### `createStateClient<typeof states>({ sse, api?, onError?, retryDelay?, maxRetries?, initial? })`
569
+
570
+ `initial: { snapshot, query? }` is the snapshot a server render read — `api.getStates`'s answer —
571
+ and the query it was read with (default `{}`). The client installs its manifest and applies each
572
+ state it holds at construction, keyed states under the query's key, so `current()` answers at once
573
+ and the first `subscribe` opens the stream without reading the snapshot first. Nothing is marked
574
+ `'revoked'` from it: an omission in the render's snapshot is not a denial by the stream. The
575
+ snapshot type is the router's own — another router's does not compile — and comes from the router
576
+ type parameter: without `<typeof states>` the client is untyped, as before. Pass what
577
+ `api.getStates` answered, not a hand-built snapshot: one whose manifest names a state the server
578
+ does not serve keeps the stream from opening, as the SSE handler refuses that event. A server component reads
579
+ the snapshot and passes it, with its query, as a prop to the client component that builds the
580
+ client:
581
+
582
+ ```tsx
583
+ // app/[workspaceId]/page.tsx — a server component
584
+ const query = { workspaceId };
585
+ const snapshot = await api.getStates.call({ query });
586
+
587
+ return <Shell initial={{ snapshot, query }} workspaceId={workspaceId} />;
588
+ ```
589
+
590
+ ```tsx
591
+ 'use client';
592
+ import type { StateClientInitial } from '@spfn/core/state/client';
593
+
594
+ export function Shell({ initial, workspaceId }: { initial: StateClientInitial<typeof states>; workspaceId: string })
595
+ {
596
+ const [state] = useState(() => createStateClient<typeof states>({ sse: createAuthSSEClient(), initial }));
597
+ useEffect(() => () => state.close(), [state]);
598
+ // useServerState(state, 'runtime', { workspaceId }) is 'ready' on the first render
599
+ }
600
+ ```
601
+
602
+ | Member | Behaviour |
603
+ | --- | --- |
604
+ | `get(id, key?)` | `GET` `get<Id>State`, key fields in the query. The answer is applied under the ordering rule and returned |
605
+ | `snapshot(query?)` | `GET getStates`, by default with the stream's query. Each state in the answer is applied, and each subscribed state it omits is marked `'revoked'` (below); the snapshot is returned. A rejected fetch rejects and changes nothing |
606
+ | `subscribe(id, key?, listener)` | Registers the listener for `(id, key)` and returns its unsubscribe (idempotent). The listener is called when the entry's `status`, `value` or `data` changes — never for an arrival that differs only by a newer `at` (Ordering, below). The client's first subscriber reads the snapshot, then opens the stream (below) |
607
+ | `write(id, input, key?)` | `put<Id>State` with the input as the body, key fields in the query. The answer is applied at once — before the frame the write also sends — and returned. Typed only for a state with a write, and requires the key on a keyed one |
608
+ | `current(id, key?)` | Synchronous: `{ at, value, data?, status }`, `{ status: 'revoked' }` for a state revoked before any value arrived, or `undefined`. The same frozen object until a change is applied |
609
+ | `connection()` | Synchronous: the connection status ([Connection status](#connection-status)), a frozen object, the same reference until it changes |
610
+ | `subscribeConnection(listener)` | Registers `listener(status)`, called with the new status on every change, after `connection()` returns it; returns its unsubscribe (idempotent). The same function subscribed twice is two subscriptions |
611
+ | `retry()` | When `'failed'` or `'reconnecting'`: reads the first snapshot now (its retry count reset), or, once the manifest is known, reopens the stream now. A no-op when `'connected'`, while the first snapshot is in flight, and with nothing subscribed — after `close()` too, until a new `subscribe` |
612
+ | `close()` | Closes the stream, drops every listener — connection listeners too — and stops listening to `online`; the status is back to `'reconnecting'` 0. Idempotent. A `subscribe` after it starts the client again, without `online`: `retry()` restarts it as before |
613
+
614
+ `onError` receives a failure of something the client ran on its own — the first snapshot, the
615
+ snapshot after the stream opened, the read after a grant — and a refused `subscribe` (default
616
+ `console.error`). Calls the app makes reject.
617
+
618
+ A listener that throws is logged at warn with the state id and does not stop the others; so is
619
+ a connection listener.
620
+
621
+ Give the state client an `SSEClient` of its own: an `SSEClient` holds one subscription, and
622
+ the state client's supersedes any other made on it.
623
+
624
+ ### Ordering
625
+
626
+ Per `(id, key)`, every arrival — a snapshot entry, a frame, a read or a write answer — goes
627
+ through one rule. `at` is the time the server's read started, so a larger `at` is a later value.
628
+ The client keeps, per `(id, key)`, an ordering stamp — the newest `at` seen — apart from the
629
+ entry, and compares each arrival against it.
630
+
631
+ | Arrival | Result |
632
+ | --- | --- |
633
+ | no current entry, or `at` newer than the stamp | the ordering stamp advances; applied and notified only when `status`, `value` or `data` differ (deep-equal) |
634
+ | `at` equal to the stamp | one read stamps one `at`, so this is the same value: applied and notified only when `value` or `data` differ (deep-equal), or its status differs from the entry's |
635
+ | `at` older than the stamp | dropped |
636
+ | a snapshot older than one state's stamp | that state keeps its entry; the others take the snapshot |
637
+ | a write answer | applied like a frame — its own frame, arriving later with the same `at` and value, changes nothing |
638
+ | a frame for a key not subscribed here | dropped (the server already filters by the connect query; this is the second guard) |
639
+
640
+ Every stream open — the first, a reconnect, a reopen on a key change — brings each state's
641
+ current value with a newer `at`: the stream's initial frame, or the snapshot re-read when the
642
+ stream did not serve it. A listener hears it only when `status`, `value` or `data` changed. The
643
+ `at` of `current()` is the `at` of the read that last changed the value; a later read that repeats
644
+ it advances only the stamp, so an older arrival after it is still dropped.
645
+
646
+ The ordering rule decides the value; the status is decided apart (next section): an arrival
647
+ on a revoked `(id, key)` supplies its value and leaves it `'revoked'`.
648
+
649
+ An applied change replaces the entry with a new frozen object; nothing else does. That is
650
+ what `useSyncExternalStore` needs: `getSnapshot` returns the same reference while nothing
651
+ changed.
652
+
653
+ ### The stream
654
+
655
+ - **One stream per client**, opened when the first subscriber arrives and closed when the last
656
+ leaves. Subscriptions made in the same task (components mounting together, a StrictMode
657
+ unmount-remount) are batched into one change.
658
+ - **Snapshot first**: the first stream opens after the first snapshot, whose manifest it needs —
659
+ at once on a client built with `initial`, which already holds it.
660
+ - **Every event requested**: `events=` lists `state.<id>.changed` for every state of the
661
+ manifest, in its order, never only the subscribed ones. The server's `grant` activates only an event the
662
+ connection requested, so a state subscribed later would otherwise be ungrantable.
663
+ - **Keys in the query**: the connect query is the union of the subscribed keys' fields (SSE
664
+ `subscribe({ query })`). A new key value, or the last subscriber of one leaving, closes the
665
+ stream and opens it with the new query.
666
+ - **Current frames on every open** — the first, a reconnect, a reopen on a key change. A frame
667
+ sent while no stream was open is lost, whether its key changed or not. The server covers that
668
+ gap: right after the `connected` frame it writes each allowed state's current frame — one
669
+ `read()` for the subject and the carried key, its `at` the read's start — before any live
670
+ frame, and `connected.initial` names the states it served. They are ordinary frames under the
671
+ ordering rule, so whatever changed between the render and the open arrives as a newer `at`.
672
+ The client reads the snapshot only when a state it requested is neither in `initial` nor in
673
+ `deniedEvents` — a state whose read threw on the server — or when `initial` is absent (a
674
+ server before it); a state in `deniedEvents` it watches on the carried key is `'revoked'`.
675
+ (It all runs on the stream's `connected`, not at `onReconnect`: a read before the new stream
676
+ is live would leave the same gap.)
677
+ - **One value per key field per connection.** A connect query carries one `workspaceId`; when
678
+ two subscribed keys disagree on a field, the most recently subscribed key is carried. A key
679
+ the stream does not carry is read once (`get`) when the stream opens and receives no frames
680
+ until it is carried again. A screen that must follow two workspaces live uses a second
681
+ state client.
682
+
683
+ ### Connection status
684
+
685
+ The client decides the retries — the SSE client's reconnects of the stream, its own of the
686
+ first snapshot — so it says where they stand. `connection()` is one of:
687
+
688
+ | Status | Holds |
689
+ | --- | --- |
690
+ | `{ status: 'connected' }` | a stream is open (the SSE `onOpen`) |
691
+ | `{ status: 'reconnecting', attempt }` | no stream open — not yet, or nothing subscribed — or the SSE client retrying a dropped one: `attempt` is its count (`onReconnect`), reset when a stream opens; `0` before the first stream opens, while the first snapshot is read or retried, and after `retry()` |
692
+ | `{ status: 'failed' }` | the retries are exhausted: the SSE client's `maxReconnectAttempts` (its `onClose`, the attempts spent), or `maxRetries` for the first snapshot. Nothing more is tried until a restart (below) or, with the manifest unknown, a `get`, `write` or `snapshot` that brings it |
693
+
694
+ - **Start value** `{ status: 'reconnecting', attempt: 0 }`, a client built with `initial` too:
695
+ its entries are ready, its stream is not.
696
+ - A stream error alone changes nothing — a reconnect or the end of the attempts follows. A
697
+ stream the client closes itself — a key change, `close()` — is no failure; a reopen on a
698
+ key change while connected stays `'connected'`.
699
+ - **Restarts.** `retry()` and the browser's `online` event — listened to from construction
700
+ where `addEventListener` exists — restart when `'failed'` or `'reconnecting'`; a `subscribe`
701
+ restarts when `'failed'`, reopening a failed stream even when the query is unchanged. An
702
+ unsubscribe that leaves subscribers never does: the status stays `'failed'` (the last one:
703
+ below). With the manifest unknown, any call that brings it — `get`, `write`, `snapshot` —
704
+ opens the stream; with the manifest known, those calls reopen nothing while `'failed'`. The
705
+ first snapshot is read now with its count reset (one in flight is not doubled), or the stream
706
+ is reopened now as a new SSE subscription, whose attempts start from 0 — a pending reconnect
707
+ timer is superseded. The status is `'reconnecting'` 0 until it opens.
708
+ - **The last unsubscribe** closes the stream: `'reconnecting'` 0, the start value, and the
709
+ first snapshot's retry count starts over for the next wait.
710
+ - **`close()`** sets `'reconnecting'` 0, drops the connection listeners and removes the
711
+ `online` listener; its own teardown is not `'failed'`. A `subscribe` after it starts the
712
+ client again, and `retry()` restarts it; `online` is not listened to again.
713
+
714
+ ```tsx
715
+ const connection = useConnectionStatus(state);
716
+
717
+ if (connection.status === 'failed')
718
+ {
719
+ return <Banner action={() => state.retry()}>The server has a problem.</Banner>;
720
+ }
721
+ if (connection.status === 'reconnecting' && connection.attempt > 0)
722
+ {
723
+ return <Banner>The server is not answering, retrying…</Banner>;
724
+ }
725
+ ```
726
+
727
+ `maxRetries` bounds the first snapshot only; bound the stream's reconnects with the SSE
728
+ client's `maxReconnectAttempts` (`createAuthSSEClient({ maxReconnectAttempts: 5 })`). Both
729
+ default to `0`, retrying forever: the status never turns `'failed'`.
730
+
731
+ ### `'revoked'` — the server does not serve this to you
732
+
733
+ One status says the server stopped serving a `(id, key)` to this subject, however the client
734
+ learned it. The mark is kept per `(id, key)` apart from the value, so it holds even when no
735
+ value has arrived yet.
736
+
737
+ | Arrival | Client |
738
+ | --- | --- |
739
+ | `subscription.revoked` for `state.<id>.changed` | the entry of `id` for **the key the connection carries** (keyless: its one entry) gets `status: 'revoked'`, value kept, listeners notified. The server scopes a revoke by the connect query, so a shadowed key of the same state is untouched |
740
+ | `connected.deniedEvents` names a subscribed state — keyless, or keyed on the key the connection carries | `'revoked'`, value kept, listeners notified: the connect check (`canRead` false, or the key absent) denied it. A shadowed key of the same state is untouched |
741
+ | a snapshot that omits a subscribed state — keyless, or keyed with its key in the query | `'revoked'`, value kept if one exists, listeners notified. This is also a state denied at connect (`canRead` false, absent from the snapshot), and a reconnect after access was lost while the stream was down, when no revoke could arrive. A keyed state whose key the query did not carry is not marked |
742
+ | a frame, a read or a snapshot started before the revoke | supplies its value under the ordering rule; the status stays `'revoked'` |
743
+ | `subscription.granted` for it | clears the mark for the carried key and reads it once (`get`); the server also sends a frame, before or after the control frame. Both arrive `'ready'`, and either order ends at the latest value |
744
+ | a frame — the initial one included — or the snapshot of a connection opened after the revoke, holding the state | clears the mark: that connection's own connect check served it |
745
+ | the read of a shadowed key when a connection opened after the revoke, answered | clears the mark the same way: the read route served it |
746
+ | an answer of such a read older than the stamp — a frame or a `write()` answer of the new connection landed first | its value is dropped under the ordering rule, while the mark is still lifted: the lift goes by the connection, not by `at` |
747
+
748
+ Every automatic read is tagged with the connection it was started for, which is how a
749
+ snapshot in flight across a revoke is told from one read after it.
750
+
751
+ ### `useServerState(client, id, key?)`
752
+
753
+ ```typescript
754
+ // → { status: 'loading' | 'ready' | 'revoked'; at?: string; value?: V; data?: D; write?: (input) => Promise<StateFrame> }
755
+ ```
756
+
757
+ - Built on `useSyncExternalStore`: `subscribe` is `client.subscribe(id, key, cb)`,
758
+ `getSnapshot` is `client.current(id, key)`, and so is the server snapshot. A server render
759
+ opens nothing; it shows the value when the client was built with `initial`, `'loading'`
760
+ otherwise. Hydration matches when the server and the browser build their clients from the
761
+ same `initial`.
762
+ - The key is compared by its fields (stable-stringified), so `{ workspaceId }` written inline
763
+ does not resubscribe on every render; another `workspaceId` unsubscribes and subscribes. A
764
+ key never seen is `'loading'` until it has an entry; a key seen before shows its cached
765
+ entry as `'ready'` until the reopen's frame or snapshot lands (entries are not evicted).
766
+ - `write` is typed only for a state with a write, bound to the hook's key. At runtime it is on
767
+ every result — the type decides, so no render waits on the manifest — and on a state
768
+ without a write it rejects naming the state.
769
+ - `react` (`^18 || ^19`) is an **optional peer** of `@spfn/core`: only `@spfn/core/state/react`
770
+ imports it. `@spfn/core/state/client` is framework-neutral.
771
+
772
+ A page's first paint still comes from a server component reading the snapshot with the app's
773
+ `api.` client; handed to the client as `initial`, the same read serves the hook's first render
774
+ and the client's first subscribe, the stream's initial frames bring anything newer, and the
775
+ hook takes over in the browser.
776
+
777
+ ### `useConnectionStatus(client)`
778
+
779
+ `connection()` in a component, on `useSyncExternalStore`: `subscribe` is
780
+ `client.subscribeConnection(cb)`, `getSnapshot` is `client.connection()`, both stable per
781
+ client, so a render does not resubscribe. The server snapshot is `{ status: 'reconnecting',
782
+ attempt: 0 }` — what every client starts with, so hydration matches.
783
+
784
+ ### Types
785
+
786
+ From `@spfn/core/state/client`: `createStateClient`, `StateClient<TStates>`,
787
+ `StateClientConfig<TStates>` (`sse`, `api?`, `onError?`, `retryDelay?`, `maxRetries?`, `initial?`),
788
+ `ConnectionStatus`, `ConnectionListener`,
789
+ `StateClientInitial<TStates>` (`{ snapshot, query? }`), `StateFrame<V, D>`, `StateEntry<V, D>`
790
+ (a frame plus `status`, or `{ status: 'revoked' }` alone), `StateListener`, `StateId`, `StateKeyArgs`, `WritableStateId`,
791
+ `StateCatalog`, `StateFrameOf`, `InferStateData`, and — re-exported type-only from the server
792
+ module — `InferStateValue`, `InferStateKey`, `InferStateInput`. From `@spfn/core/state/react`:
793
+ `useServerState`, `ServerState<TStates, Id>`, `useConnectionStatus`.
794
+
795
+ ---
796
+
797
+ ## Out of scope
798
+
799
+ - Browser-only state (an open panel, a draft), session and network state.
800
+ - Optimistic updates: the client applies what the server read, never a guess.
801
+ - In the browser client: optimistic updates, offline queueing of writes, more than one SSE
802
+ connection per client, WebSocket, and Next.js server components (a server component reads
803
+ the route with the app's `api.` client as any other initial data).
804
+ - De-duplicating `read()` across subscribers of a per-subject keyed state (each subject has
805
+ its own value; a shared key is read once).
806
+ - A route-level "refuse until state X is Y" gate.