@spfn/core 0.3.0-beta.25 → 0.3.0-beta.27

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.
@@ -10,17 +10,21 @@ declaration the read route, the write route, its entry in a snapshot route and t
10
10
  The server is the single source of truth: every value a client receives is what `read()`
11
11
  returned, read on the server; the browser never computes one.
12
12
 
13
- This is the server half. The browser client and the React hook (`@spfn/core/state/client`,
14
- `@spfn/core/state/react`) are a separate release.
13
+ The browser side — `@spfn/core/state/client` and the React hook `@spfn/core/state/react` —
14
+ is described under [Browser](#browser).
15
15
 
16
16
  ## Import paths
17
17
 
18
- One entry point, server only.
19
-
20
18
  ```typescript
19
+ // server
21
20
  import { defineState, defineStateRouter } from '@spfn/core/state';
22
21
  import type { InferStateValue, InferStateKey, InferStateInput } from '@spfn/core/state';
23
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 { useServerState } from '@spfn/core/state/react';
27
+ import type { states } from '@/server/states';
24
28
  ```
25
29
 
26
30
  ---
@@ -30,11 +34,11 @@ import { Type } from '@sinclair/typebox'; // for .key() / .value() / .data() /
30
34
  From `@spfn/core/state`:
31
35
 
32
36
  - Builder: `defineState(id)` → `StateBuilder`
33
- - Router: `defineStateRouter(states, { path?, subject? })` → `StateRouter`
34
- - Types: `StateBuilder`, `StateRouter`, `StateRouterOptions`, `StateRoutes`, `StateEvents`,
35
- `StatesSnapshot`, `SubjectResolver`, `InferStateValue`, `InferStateKey`, `InferStateInput`,
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`,
36
40
  `StateShape`, `StateValue`, `StateKey`, `StateResult`, `StateSnapshot`, `StateFrame`,
37
- `ReadContext`, `PermissionContext`, `WriteContext`, `ChangedPayload`, `GrantTarget`,
41
+ `ReadContext`, `PermissionContext`, `WritePermissionContext`, `WriteContext`, `ChangedPayload`, `GrantTarget`,
38
42
  `RevokeTarget`, `StateContract`, `StateWriteOptions`, `RoutableState`
39
43
 
40
44
  `StateBuilder` methods — immutable, every call returns a new builder, order free, no terminal
@@ -45,10 +49,11 @@ method:
45
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 |
46
50
  | `.value(schema)` | The value. Required | a second call |
47
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 |
48
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()` |
49
54
  | `.contract({ since, auth?, requiresSession?, deprecatedIn?, removedIn? })` | Publishes the read route, the write route and the event | a second call |
50
55
  | `.canRead(fn)` | `({ subject, principal, key? }) => boolean \| Promise<boolean>`. Required on a keyed state | a second call |
51
- | `.canWrite(fn)` | Same shape. Absent → `canRead` decides | a second call |
56
+ | `.canWrite(fn)` | Same shape; `principal` without `undefined` (below). Absent → `canRead` decides | a second call |
52
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 |
53
58
  | `.write(input, fn, { since?, deprecatedIn?, removedIn? })` | Derives the write route. `fn` gets the read context plus `input` and returns nothing | a second call |
54
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 |
@@ -59,9 +64,48 @@ keyed state only, and required there), `.revoke({ subject, key? })`, `.id`.
59
64
  `StateRouter`: `.router` (a `Router` from `defineRouter`), `.events` (`Record` of the SSE
60
65
  events keyed by wire name), `.states` (the declarations as given).
61
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
+
62
84
  The types of `.read()`, `.write()`, `.canRead()` and `.canWrite()` contexts and of `R` are
63
85
  computed from the schemas declared *before* the call — runtime order is free, but declare
64
- `.key()`, `.value()` and `.data()` first to have the functions checked against them.
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
+ ```
65
109
 
66
110
  ---
67
111
 
@@ -156,7 +200,8 @@ Route names are `get<Id>State` / `put<Id>State` with the id's first letter upper
156
200
  id must match `^[a-z][a-zA-Z0-9]*$` — `defineState` refuses another.
157
201
 
158
202
  `defineStateRouter` refuses, naming the state: no `.value()` or `.read()`; a keyed state
159
- without `.canRead()`; a state with `.use()` or `.skip()` but no `.canRead()`; a
203
+ without `.canRead()`; a state with `.use()` or `.skip()` but no `.canRead()`; a router `use`
204
+ or `skip` while any state declares no `.canRead()` (the first such state is named); a
160
205
  `.changedBy()` event whose payload schema lacks a field the signal needs; two states with one
161
206
  id; a `.write(…, { since })` on a state without `.contract()`; a state already grouped by
162
207
  another router (each router subscribes the change signal, so every change would be read and
@@ -169,18 +214,27 @@ sent twice). A builder without `.value()` or
169
214
 
170
215
  The subject comes from `options.subject(c)`, default `c.get('auth')?.userId ?? null` — the
171
216
  SSE `getSubject` default. No subject → `UnauthorizedError` (401). The principal on HTTP is
172
- `c.get('auth')` as the app's middleware set it.
217
+ `c.get('auth')` as the app's middleware set it. A state that declared `.principal()` refuses a
218
+ write whose request's `auth` is `undefined` or `null` with `UnauthorizedError` (401), after the subject and before
219
+ `canWrite`; the read and snapshot routes never refuse on a missing principal.
173
220
 
174
221
  - **Read** `GET <path>/<id>`: subject → key (query, validated) → `canRead` (false → 403) →
175
222
  `at` → `read()` → `{ at, value, data }`.
176
- - **Write** `PUT <path>/<id>`: subject → key → `canWrite ?? canRead` (false → 403, `write` not
177
- called) → `write({ …, input })` → `at` → `read()` → one SSE frame → `{ at, value, data }`.
223
+ - **Write** `PUT <path>/<id>`: subject → principal (whose `auth` is `undefined` or `null` → 401) → key →
224
+ `canWrite ?? canRead` (false → 403, `write` not called) → `write({ …, input })` → `at` →
225
+ `read()` → one SSE frame → `{ at, value, data }`.
226
+ When the handler runs inside a transaction (`.use([Transactional()])`), the frame is
227
+ published after that transaction commits (see [Frames and transactions](#frames-and-transactions)).
178
228
  A `write` that throws propagates; nothing is read and no frame is sent. The input is a
179
229
  command, never the value: what the route answers is re-read.
180
230
  - **Snapshot** `GET <path>`: subject → one `at` → for each state, in declaration order: a keyed
181
231
  state whose key fields are not all in the query is omitted; `canRead` false → omitted;
182
232
  otherwise `read()`. A keyed state left out never stops a later keyless one. A `read()` that
183
- throws fails the whole request — an error is not a permission.
233
+ throws fails the whole request — an error is not a permission. The answer is
234
+ `{ at, states, manifest }`: `manifest` is `{ [id]: { key: string[]; write: boolean } }` for
235
+ every state in declaration order — the key fields (`[]` when keyless) and whether it has a
236
+ write — built once when the router is built. It is what the [browser client](#browser)
237
+ knows of the router at runtime.
184
238
 
185
239
  Every `read()` result is checked against `{ value, data }` as declared — `data` exactly when
186
240
  `.data()` is, no other field — with a schema built once per state when the router is built.
@@ -188,15 +242,17 @@ A result that does not match fails the read, write and snapshot routes with an `
188
242
  naming the state and the first validation message, never the value; on the change signal it
189
243
  is logged at warn and sends nothing; `grant` rejects with the same error and sends nothing.
190
244
 
191
- A write that committed and then failed to read — `read()` threw or returned an undeclared
192
- result — answers 500 and sends no frame, although the write ran. There is no value to report;
193
- the next signal or read brings the client up to date.
245
+ A write whose `read()` then fails — it threw or returned an undeclared result — answers 500
246
+ and sends no frame. Without a transaction the write has already committed: there is no value
247
+ to report, and the next signal or read brings the client up to date. Under
248
+ `.use([Transactional()])` the failed read rolls the write back with it, so nothing changed.
194
249
 
195
250
  The derived routes are ordinary `RouteDef`s registered by `registerRoutes`, so `.use()`
196
251
  middleware runs inside the route's own chain, after the app's global middleware — it sees the
197
- `auth` the app set. It runs on the two per-state routes only: the snapshot route and the SSE
198
- stream carry no state's `.use()`. That is why `.use()` / `.skip()` require `.canRead()` — a
199
- guard in middleware would hold on `GET <path>/<id>` and leak through `GET <path>` and the
252
+ `auth` the app set. A state's `.use()` / `.skip()` / `.contract()` reach its two per-state
253
+ routes only; the router's `use` / `skip` / `contract` reach the snapshot route only. The SSE
254
+ stream runs neither. That is why `.use()` / `.skip()` — the state's and the router's — require
255
+ `.canRead()`: a guard in middleware would hold on one route and leak through the others and the
200
256
  stream. A state that needs middleware only for, say, rate limiting declares
201
257
  `.canRead(() => true)` to say so.
202
258
 
@@ -205,8 +261,10 @@ stream. A state that needs middleware only for, say, rate limiting declares
205
261
  ## The change signal
206
262
 
207
263
  The app emits `state.changed` **after the commit** that changed the value — from
208
- `onAfterCommit` (`@spfn/core/db`), not inside the transaction. The module never reads on its
209
- own: no polling, no database trigger. On each signal it reads and sends one frame:
264
+ `onAfterCommit` (`@spfn/core/db`), not inside the transaction. An emit made inside a
265
+ transaction anyway reads there and sends its frame at the commit (see
266
+ [Frames and transactions](#frames-and-transactions)). The module never reads on its own: no
267
+ polling, no database trigger. On each signal it reads and sends one frame:
210
268
 
211
269
  | State | Read | Frame |
212
270
  | --- | --- | --- |
@@ -249,6 +307,27 @@ module attaches nothing to the event — no `canSubscribe`, no `filter`; it only
249
307
  `read()`'s result is copied into the frame, so neither a handler mutating what it returned nor
250
308
  a subscriber mutating the payload changes the other.
251
309
 
310
+ ### Frames and transactions
311
+
312
+ Every frame — of the write route, of the change signal and of `grant` — is sent by one rule:
313
+
314
+ | Where the read ran | Frame |
315
+ | --- | --- |
316
+ | 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 |
317
+ | inside a transaction | queued with `onAfterCommit` and published after the root transaction commits; never when the root rolls back |
318
+
319
+ - The frame carries the value read inside the transaction, with that read's `at`; nothing is
320
+ read again after the commit. The response is the same snapshot.
321
+ - So a `changed.emit` or a `grant` made inside a transaction is deferred to its commit, like a
322
+ write.
323
+ - The queue belongs to the root transaction: a `Transactional()` nested in an outer
324
+ `runInTransaction` publishes at the outer commit.
325
+ - A nested transaction (a savepoint) that rolls back while the root commits still publishes
326
+ the frame read inside it, because after-commit callbacks belong to the root; emit the signal
327
+ from the outer transaction, or after it, when an inner one may roll back.
328
+ - A deferred emit runs outside the transaction context; an error it throws is logged by the
329
+ transaction runner and reaches nobody.
330
+
252
331
  ---
253
332
 
254
333
  ## Permissions
@@ -258,7 +337,7 @@ a subscriber mutating the payload changes the other.
258
337
  | `canRead` | `GET <path>/<id>` | 403 |
259
338
  | `canRead` | `GET <path>` | that state omitted |
260
339
  | `canRead` | SSE connect (`canSubscribe`), with the key from the connect query | that event in `connected.deniedEvents` |
261
- | `canWrite ?? canRead` | `PUT <path>/<id>` | 403 |
340
+ | `canWrite ?? canRead` | `PUT <path>/<id>` | 403; with `.principal()` declared, a request whose `auth` is `undefined` or `null` is 401 before it is asked |
262
341
 
263
342
  On SSE the connection declares its key in the connect query —
264
343
  `/events/stream?events=state.runtime.changed&workspaceId=w1&token=…`. The module sets each
@@ -334,17 +413,201 @@ change" by the client (implemented in the client half). There is no version coun
334
413
  - the SSE event `state.<id>.changed` with `since` / `deprecatedIn` / `removedIn`.
335
414
 
336
415
  A state without `.contract()` appears nowhere, like a route without one. The snapshot route is
337
- not contracted. A `value` literal removed later is `response.type-changed` on the read
416
+ contracted by the router's own `contract` option, never by a state's: response
417
+ `{ at, states: { [id]?: { value, data? } }, manifest: { [id]: { key: string[], write: boolean } } }`,
418
+ each `states` entry optional and each `manifest` entry required, in declaration order — built
419
+ once when the router is built. Without that option it appears nowhere. A `value` literal removed later is `response.type-changed` on the read
338
420
  operation and `event.payload.type-changed` on the event, as any route and event.
339
421
 
340
422
  ---
341
423
 
424
+ ## Browser
425
+
426
+ `@spfn/core/state/client` is the browser side of a state router: it calls the derived read,
427
+ write and snapshot routes, keeps one SSE stream open for every state, and decides per
428
+ `(id, key)` which of a snapshot, a frame, a read or a write answer is current.
429
+ `@spfn/core/state/react` puts one state into a component. The router is known **by type
430
+ only** — `import type { states }` — so no server code reaches the browser bundle;
431
+ `@spfn/core/state/client` imports nothing from `@spfn/core/state` at runtime.
432
+
433
+ ```typescript
434
+ // src/client/state.ts
435
+ import { createStateClient } from '@spfn/core/state/client';
436
+ import { createAuthSSEClient } from '@spfn/core/event/sse/client';
437
+ import type { states } from '@/server/states'; // type only
438
+
439
+ export const state = createStateClient<typeof states>({
440
+ sse: createAuthSSEClient(), // the state client's own
441
+ api: { baseUrl: '/api/rpc' }, // passed to createApi
442
+ });
443
+ ```
444
+
445
+ ```tsx
446
+ 'use client';
447
+ import { useServerState } from '@spfn/core/state/react';
448
+ import { state } from '@/client/state';
449
+
450
+ function Shell({ workspaceId }: { workspaceId: string })
451
+ {
452
+ const consent = useServerState(state, 'consent'); // keyless
453
+ const runtime = useServerState(state, 'runtime', { workspaceId }); // keyed
454
+
455
+ if (consent.status === 'loading' || runtime.status === 'loading')
456
+ {
457
+ return <Spinner />;
458
+ }
459
+
460
+ return consent.value === 'pending'
461
+ ? <Terms onAgree={marketing => consent.write({ termsVersion: '2026-10', marketing })} />
462
+ : <Workspace runtime={runtime.value} />;
463
+ }
464
+ ```
465
+
466
+ ### The manifest
467
+
468
+ A type cannot be read at runtime, and the client needs three facts the router type carries:
469
+ every state id (the stream requests every state event), each keyed state's key fields (to tell
470
+ which key a snapshot entry belongs to) and which states have a write. The snapshot route
471
+ answers them as `manifest`, so the app declares the router once, on the server.
472
+
473
+ The first `subscribe` — or `get`, `write`, `snapshot` — therefore reads the snapshot **first**:
474
+ the client installs its manifest, then applies its states, then opens the stream with
475
+ `events=` from the manifest, in its order. Until then a `subscribe` waits (it never throws),
476
+ and `current()` is `undefined`; a `subscribe` while that snapshot is in flight is served by it,
477
+ not by a second read. Once the manifest is known, a `subscribe` whose id it does not name, or
478
+ whose key fields differ from the state's, is refused: the error, naming the state and both sets
479
+ of fields, goes to `onError`, and the subscription is dropped without reopening the stream. A
480
+ `write` on a state the manifest marks `write: false` rejects naming the state, before any
481
+ request. A first snapshot that fails goes to `onError` (or rejects the call that needed it).
482
+ While subscriptions wait on it, it is read again every `retryDelay` (default 3000 ms, the SSE
483
+ client's `reconnectDelay`), each failure to `onError`, until it succeeds, `close()` runs, or
484
+ nothing is subscribed; a `get`, `write` or `snapshot` that reads it first serves the waiting
485
+ subscriptions too — whichever call brings the manifest opens the stream. `close()` while it is
486
+ in flight discards its answer, and a `get` or `write` waiting on it rejects: the client was
487
+ closed.
488
+
489
+ ### `createStateClient<typeof states>({ sse, api?, onError?, retryDelay? })`
490
+
491
+ | Member | Behaviour |
492
+ | --- | --- |
493
+ | `get(id, key?)` | `GET` `get<Id>State`, key fields in the query. The answer is applied under the ordering rule and returned |
494
+ | `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 |
495
+ | `subscribe(id, key?, listener)` | Registers the listener for `(id, key)` and returns its unsubscribe (idempotent). The client's first subscriber reads the snapshot, then opens the stream (below) |
496
+ | `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 |
497
+ | `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 |
498
+ | `close()` | Closes the stream, drops every listener. Idempotent |
499
+
500
+ `onError` receives a failure of something the client ran on its own — the first snapshot, the
501
+ snapshot after the stream opened, the read after a grant — and a refused `subscribe` (default
502
+ `console.error`). Calls the app makes reject.
503
+
504
+ A listener that throws is logged at warn with the state id and does not stop the others.
505
+
506
+ Give the state client an `SSEClient` of its own: an `SSEClient` holds one subscription, and
507
+ the state client's supersedes any other made on it.
508
+
509
+ ### Ordering
510
+
511
+ Per `(id, key)`, every arrival — a snapshot entry, a frame, a read or a write answer — goes
512
+ through one rule. `at` is the time the server's read started, so a larger `at` is a later value.
513
+
514
+ | Arrival | Result |
515
+ | --- | --- |
516
+ | no current entry, or `at` newer than the current | applied, listeners notified |
517
+ | `at` equal to the current | one read stamps one `at`, so this is the same value: applied and notified only when `value` or `data` differ (deep-equal) or the entry is `'revoked'` |
518
+ | `at` older than the current | dropped |
519
+ | a snapshot older than one state's entry | that state keeps its entry; the others take the snapshot |
520
+ | a write answer | applied like a frame — its own frame, arriving later with the same `at` and value, changes nothing |
521
+ | a frame for a key not subscribed here | dropped (the server already filters by the connect query; this is the second guard) |
522
+
523
+ The ordering rule decides the value; the status is decided apart (next section): an arrival
524
+ on a revoked `(id, key)` supplies its value and leaves it `'revoked'`.
525
+
526
+ An applied change replaces the entry with a new frozen object; nothing else does. That is
527
+ what `useSyncExternalStore` needs: `getSnapshot` returns the same reference while nothing
528
+ changed.
529
+
530
+ ### The stream
531
+
532
+ - **One stream per client**, opened when the first subscriber arrives and closed when the last
533
+ leaves. Subscriptions made in the same task (components mounting together, a StrictMode
534
+ unmount-remount) are batched into one change.
535
+ - **Snapshot first**: the first stream opens after the first snapshot, whose manifest it needs.
536
+ - **Every event requested**: `events=` lists `state.<id>.changed` for every state of the
537
+ manifest, in its order, never only the subscribed ones. The server's `grant` activates only an event the
538
+ connection requested, so a state subscribed later would otherwise be ungrantable.
539
+ - **Keys in the query**: the connect query is the union of the subscribed keys' fields (SSE
540
+ `subscribe({ query })`). A new key value, or the last subscriber of one leaving, closes the
541
+ stream and opens it with the new query.
542
+ - **Snapshot on every open** — the first, a reconnect, a reopen on a key change. A frame sent
543
+ while no stream was open is lost, whether its key changed or not; the snapshot read after
544
+ the open covers that gap. (It runs on the stream's `open`, not at `onReconnect`: a snapshot
545
+ read before the new stream is live would leave the same gap.)
546
+ - **One value per key field per connection.** A connect query carries one `workspaceId`; when
547
+ two subscribed keys disagree on a field, the most recently subscribed key is carried. A key
548
+ the stream does not carry is read once (`get`) when the stream opens and receives no frames
549
+ until it is carried again. A screen that must follow two workspaces live uses a second
550
+ state client.
551
+
552
+ ### `'revoked'` — the server does not serve this to you
553
+
554
+ One status says the server stopped serving a `(id, key)` to this subject, however the client
555
+ learned it. The mark is kept per `(id, key)` apart from the value, so it holds even when no
556
+ value has arrived yet.
557
+
558
+ | Arrival | Client |
559
+ | --- | --- |
560
+ | `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 |
561
+ | 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 |
562
+ | a frame, a read or a snapshot started before the revoke | supplies its value under the ordering rule; the status stays `'revoked'` |
563
+ | `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 |
564
+ | the snapshot of a connection opened after the revoke, holding the state | clears the mark: that connection's own connect check served it |
565
+ | 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 |
566
+ | an answer of such a read older than the entry — 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` |
567
+
568
+ Every automatic read is tagged with the connection it was started for, which is how a
569
+ snapshot in flight across a revoke is told from one read after it.
570
+
571
+ ### `useServerState(client, id, key?)`
572
+
573
+ ```typescript
574
+ // → { status: 'loading' | 'ready' | 'revoked'; at?: string; value?: V; data?: D; write?: (input) => Promise<StateFrame> }
575
+ ```
576
+
577
+ - Built on `useSyncExternalStore`: `subscribe` is `client.subscribe(id, key, cb)`,
578
+ `getSnapshot` is `client.current(id, key)`, and the server snapshot is `undefined` — a
579
+ server render is `'loading'` and opens nothing.
580
+ - The key is compared by its fields (stable-stringified), so `{ workspaceId }` written inline
581
+ does not resubscribe on every render; another `workspaceId` unsubscribes and subscribes. A
582
+ key never seen is `'loading'` until it has an entry; a key seen before shows its cached
583
+ entry as `'ready'` until the reopen snapshot lands (entries are not evicted).
584
+ - `write` is typed only for a state with a write, bound to the hook's key. At runtime it is on
585
+ every result — the type decides, so no render waits on the manifest — and on a state
586
+ without a write it rejects naming the state.
587
+ - `react` (`^18 || ^19`) is an **optional peer** of `@spfn/core`: only `@spfn/core/state/react`
588
+ imports it. `@spfn/core/state/client` is framework-neutral.
589
+
590
+ A page's first paint still comes from a server component reading the state's route with the
591
+ app's `api.` client; the hook takes over in the browser.
592
+
593
+ ### Types
594
+
595
+ From `@spfn/core/state/client`: `createStateClient`, `StateClient<TStates>`,
596
+ `StateClientConfig`, `StateFrame<V, D>`, `StateEntry<V, D>` (a frame plus `status`, or
597
+ `{ status: 'revoked' }` alone), `StateListener`, `StateId`, `StateKeyArgs`, `WritableStateId`,
598
+ `StateCatalog`, `StateFrameOf`, `InferStateData`, and — re-exported type-only from the server
599
+ module — `InferStateValue`, `InferStateKey`, `InferStateInput`. From `@spfn/core/state/react`:
600
+ `useServerState`, `ServerState<TStates, Id>`.
601
+
602
+ ---
603
+
342
604
  ## Out of scope
343
605
 
344
606
  - Browser-only state (an open panel, a draft), session and network state.
345
607
  - Optimistic updates: the client applies what the server read, never a guess.
346
- - The browser client and the React hook — `@spfn/core/state/client`, `@spfn/core/state/react`,
347
- a separate release.
608
+ - In the browser client: optimistic updates, offline queueing of writes, more than one SSE
609
+ connection per client, WebSocket, and Next.js server components (a server component reads
610
+ the route with the app's `api.` client as any other initial data).
348
611
  - De-duplicating `read()` across subscribers of a per-subject keyed state (each subject has
349
612
  its own value; a shared key is read once).
350
613
  - A route-level "refuse until state X is Y" gate.