@spfn/core 0.3.0-beta.26 → 0.3.0-beta.28

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.
@@ -23,7 +23,7 @@ import { Type } from '@sinclair/typebox'; // for .key() / .value() / .data() /
23
23
 
24
24
  // browser — the router by type only
25
25
  import { createStateClient } from '@spfn/core/state/client';
26
- import { useServerState } from '@spfn/core/state/react';
26
+ import { useConnectionStatus, useServerState } from '@spfn/core/state/react';
27
27
  import type { states } from '@/server/states';
28
28
  ```
29
29
 
@@ -34,11 +34,11 @@ import type { states } from '@/server/states';
34
34
  From `@spfn/core/state`:
35
35
 
36
36
  - Builder: `defineState(id)` → `StateBuilder`
37
- - Router: `defineStateRouter(states, { path?, subject? })` → `StateRouter`
38
- - Types: `StateBuilder`, `StateRouter`, `StateRouterOptions`, `StateRoutes`, `StateEvents`,
37
+ - Router: `defineStateRouter(states, { path?, subject?, use?, skip?, contract? })` → `StateRouter`
38
+ - Types: `StateBuilder`, `StateRouter`, `StateRouterOptions`, `SnapshotRouteOptions`, `StateRoutes`, `StateEvents`,
39
39
  `StatesSnapshot`, `StatesManifest`, `SubjectResolver`, `InferStateValue`, `InferStateKey`, `InferStateInput`,
40
40
  `StateShape`, `StateValue`, `StateKey`, `StateResult`, `StateSnapshot`, `StateFrame`,
41
- `ReadContext`, `PermissionContext`, `WriteContext`, `ChangedPayload`, `GrantTarget`,
41
+ `ReadContext`, `PermissionContext`, `WritePermissionContext`, `WriteContext`, `ChangedPayload`, `GrantTarget`,
42
42
  `RevokeTarget`, `StateContract`, `StateWriteOptions`, `RoutableState`
43
43
 
44
44
  `StateBuilder` methods — immutable, every call returns a new builder, order free, no terminal
@@ -49,10 +49,11 @@ method:
49
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
50
  | `.value(schema)` | The value. Required | a second call |
51
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 |
52
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()` |
53
54
  | `.contract({ since, auth?, requiresSession?, deprecatedIn?, removedIn? })` | Publishes the read route, the write route and the event | a second call |
54
55
  | `.canRead(fn)` | `({ subject, principal, key? }) => boolean \| Promise<boolean>`. Required on a keyed state | a second call |
55
- | `.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 |
56
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 |
57
58
  | `.write(input, fn, { since?, deprecatedIn?, removedIn? })` | Derives the write route. `fn` gets the read context plus `input` and returns nothing | a second call |
58
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 |
@@ -63,9 +64,48 @@ keyed state only, and required there), `.revoke({ subject, key? })`, `.id`.
63
64
  `StateRouter`: `.router` (a `Router` from `defineRouter`), `.events` (`Record` of the SSE
64
65
  events keyed by wire name), `.states` (the declarations as given).
65
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
+
66
84
  The types of `.read()`, `.write()`, `.canRead()` and `.canWrite()` contexts and of `R` are
67
85
  computed from the schemas declared *before* the call — runtime order is free, but declare
68
- `.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
+ ```
69
109
 
70
110
  ---
71
111
 
@@ -151,7 +191,7 @@ With the default `path` `/state`:
151
191
  | `getConsentState` | `GET /state/consent` → `{ at, value, data }`. Keyed: key fields in the query, validated by the route (missing → 400) |
152
192
  | `putConsentState` | `PUT /state/consent`, only when `.write()` exists. Body = the input schema, keyed: key fields in the query. → `{ at, value, data }` |
153
193
  | `getStates` | `GET /state` → `{ at, states: { consent: { value, data }, … } }` in declaration order — one for all states of the router |
154
- | `states.events['state.consent.changed']` | the SSE event, payload `{ at, value, data, key?, subject? }` — `key` on a keyed state, `subject` unless shared |
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` |
155
195
  | `consent.changed` | the change signal, payload keyless `{ subject }`, keyed `{ ...keyFields, subject }`, shared `{ ...keyFields }` |
156
196
 
157
197
  `data` is present exactly when `.data()` is declared, in every response and frame.
@@ -160,7 +200,8 @@ Route names are `get<Id>State` / `put<Id>State` with the id's first letter upper
160
200
  id must match `^[a-z][a-zA-Z0-9]*$` — `defineState` refuses another.
161
201
 
162
202
  `defineStateRouter` refuses, naming the state: no `.value()` or `.read()`; a keyed state
163
- 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
164
205
  `.changedBy()` event whose payload schema lacks a field the signal needs; two states with one
165
206
  id; a `.write(…, { since })` on a state without `.contract()`; a state already grouped by
166
207
  another router (each router subscribes the change signal, so every change would be read and
@@ -173,12 +214,17 @@ sent twice). A builder without `.value()` or
173
214
 
174
215
  The subject comes from `options.subject(c)`, default `c.get('auth')?.userId ?? null` — the
175
216
  SSE `getSubject` default. No subject → `UnauthorizedError` (401). The principal on HTTP is
176
- `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.
177
220
 
178
221
  - **Read** `GET <path>/<id>`: subject → key (query, validated) → `canRead` (false → 403) →
179
222
  `at` → `read()` → `{ at, value, data }`.
180
- - **Write** `PUT <path>/<id>`: subject → key → `canWrite ?? canRead` (false → 403, `write` not
181
- 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)).
182
228
  A `write` that throws propagates; nothing is read and no frame is sent. The input is a
183
229
  command, never the value: what the route answers is re-read.
184
230
  - **Snapshot** `GET <path>`: subject → one `at` → for each state, in declaration order: a keyed
@@ -194,17 +240,23 @@ Every `read()` result is checked against `{ value, data }` as declared — `data
194
240
  `.data()` is, no other field — with a schema built once per state when the router is built.
195
241
  A result that does not match fails the read, write and snapshot routes with an `Error` (500)
196
242
  naming the state and the first validation message, never the value; on the change signal it
197
- is logged at warn and sends nothing; `grant` rejects with the same error and sends nothing.
243
+ is logged at warn and sends nothing; `grant` rejects with the same error and sends nothing. On
244
+ the stream's connect it is logged at warn naming the state's event (never the error), the state
245
+ is left out of `connected.initial` and sends no initial frame. The client's snapshot read then
246
+ fails too — the snapshot route reads every state — so the entry keeps its last value, `onError`
247
+ is called, and the state recovers with its next frame or the next connect.
198
248
 
199
- A write that committed and then failed to read — `read()` threw or returned an undeclared
200
- result — answers 500 and sends no frame, although the write ran. There is no value to report;
201
- the next signal or read brings the client up to date.
249
+ A write whose `read()` then fails — it threw or returned an undeclared result — answers 500
250
+ and sends no frame. Without a transaction the write has already committed: there is no value
251
+ to report, and the next signal or read brings the client up to date. Under
252
+ `.use([Transactional()])` the failed read rolls the write back with it, so nothing changed.
202
253
 
203
254
  The derived routes are ordinary `RouteDef`s registered by `registerRoutes`, so `.use()`
204
255
  middleware runs inside the route's own chain, after the app's global middleware — it sees the
205
- `auth` the app set. It runs on the two per-state routes only: the snapshot route and the SSE
206
- stream carry no state's `.use()`. That is why `.use()` / `.skip()` require `.canRead()` — a
207
- guard in middleware would hold on `GET <path>/<id>` and leak through `GET <path>` and the
256
+ `auth` the app set. A state's `.use()` / `.skip()` / `.contract()` reach its two per-state
257
+ routes only; the router's `use` / `skip` / `contract` reach the snapshot route only. The SSE
258
+ stream runs neither. That is why `.use()` / `.skip()` — the state's and the router's — require
259
+ `.canRead()`: a guard in middleware would hold on one route and leak through the others and the
208
260
  stream. A state that needs middleware only for, say, rate limiting declares
209
261
  `.canRead(() => true)` to say so.
210
262
 
@@ -213,8 +265,10 @@ stream. A state that needs middleware only for, say, rate limiting declares
213
265
  ## The change signal
214
266
 
215
267
  The app emits `state.changed` **after the commit** that changed the value — from
216
- `onAfterCommit` (`@spfn/core/db`), not inside the transaction. The module never reads on its
217
- own: no polling, no database trigger. On each signal it reads and sends one frame:
268
+ `onAfterCommit` (`@spfn/core/db`), not inside the transaction. An emit made inside a
269
+ transaction anyway reads there and sends its frame at the commit (see
270
+ [Frames and transactions](#frames-and-transactions)). The module never reads on its own: no
271
+ polling, no database trigger. On each signal it reads and sends one frame:
218
272
 
219
273
  | State | Read | Frame |
220
274
  | --- | --- | --- |
@@ -257,6 +311,25 @@ module attaches nothing to the event — no `canSubscribe`, no `filter`; it only
257
311
  `read()`'s result is copied into the frame, so neither a handler mutating what it returned nor
258
312
  a subscriber mutating the payload changes the other.
259
313
 
314
+ ### Frames and transactions
315
+
316
+ Every frame — of the write route, of the change signal and of `grant` — is sent by one rule:
317
+
318
+ | Where the read ran | Frame |
319
+ | --- | --- |
320
+ | 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 |
321
+ | 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 |
322
+
323
+ - The frame carries the value read inside the transaction, with that read's `at`; nothing is
324
+ read again after the commit. The response is the same snapshot.
325
+ - So a `changed.emit` or a `grant` made inside a transaction is deferred to its commit, like a
326
+ write.
327
+ - A `Transactional()` nested in an outer `runInTransaction` is a savepoint: its frame is
328
+ handed to the outer transaction and published at the outer commit, or dropped if the
329
+ handler throws.
330
+ - A deferred emit runs outside the transaction context; an error it throws is logged by the
331
+ transaction runner and reaches nobody.
332
+
260
333
  ---
261
334
 
262
335
  ## Permissions
@@ -266,7 +339,7 @@ a subscriber mutating the payload changes the other.
266
339
  | `canRead` | `GET <path>/<id>` | 403 |
267
340
  | `canRead` | `GET <path>` | that state omitted |
268
341
  | `canRead` | SSE connect (`canSubscribe`), with the key from the connect query | that event in `connected.deniedEvents` |
269
- | `canWrite ?? canRead` | `PUT <path>/<id>` | 403 |
342
+ | `canWrite ?? canRead` | `PUT <path>/<id>` | 403; with `.principal()` declared, a request whose `auth` is `undefined` or `null` is 401 before it is asked |
270
343
 
271
344
  On SSE the connection declares its key in the connect query —
272
345
  `/events/stream?events=state.runtime.changed&workspaceId=w1&token=…`. The module sets each
@@ -342,7 +415,10 @@ change" by the client (implemented in the client half). There is no version coun
342
415
  - the SSE event `state.<id>.changed` with `since` / `deprecatedIn` / `removedIn`.
343
416
 
344
417
  A state without `.contract()` appears nowhere, like a route without one. The snapshot route is
345
- not contracted, its `manifest` included. A `value` literal removed later is `response.type-changed` on the read
418
+ contracted by the router's own `contract` option, never by a state's: response
419
+ `{ at, states: { [id]?: { value, data? } }, manifest: { [id]: { key: string[], write: boolean } } }`,
420
+ each `states` entry optional and each `manifest` entry required, in declaration order — built
421
+ once when the router is built. Without that option it appears nowhere. A `value` literal removed later is `response.type-changed` on the read
346
422
  operation and `event.payload.type-changed` on the event, as any route and event.
347
423
 
348
424
  ---
@@ -356,6 +432,10 @@ write and snapshot routes, keeps one SSE stream open for every state, and decide
356
432
  only** — `import type { states }` — so no server code reaches the browser bundle;
357
433
  `@spfn/core/state/client` imports nothing from `@spfn/core/state` at runtime.
358
434
 
435
+ A page load costs one snapshot read, and the server makes it: the server component's render,
436
+ handed to the client as `initial`. The browser makes no snapshot `GET` of its own — the stream
437
+ serves each allowed state's current frame on connect ([The stream](#the-stream)).
438
+
359
439
  ```typescript
360
440
  // src/client/state.ts
361
441
  import { createStateClient } from '@spfn/core/state/client';
@@ -406,28 +486,70 @@ of fields, goes to `onError`, and the subscription is dropped without reopening
406
486
  `write` on a state the manifest marks `write: false` rejects naming the state, before any
407
487
  request. A first snapshot that fails goes to `onError` (or rejects the call that needed it).
408
488
  While subscriptions wait on it, it is read again every `retryDelay` (default 3000 ms, the SSE
409
- client's `reconnectDelay`), each failure to `onError`, until it succeeds, `close()` runs, or
410
- nothing is subscribed; a `get`, `write` or `snapshot` that reads it first serves the waiting
411
- subscriptions too — whichever call brings the manifest opens the stream. `close()` while it is
489
+ client's `reconnectDelay`), each failure to `onError`, until it succeeds, `close()` runs,
490
+ nothing is subscribed, or `maxRetries` retries failed too (default `0`: no limit) — then the
491
+ status is `'failed'` and no retry is set ([Connection status](#connection-status)); a `get`,
492
+ `write` or `snapshot` that reads it first serves the waiting subscriptions too — whichever
493
+ call brings the manifest opens the stream. `close()` while it is
412
494
  in flight discards its answer, and a `get` or `write` waiting on it rejects: the client was
413
495
  closed.
414
496
 
415
- ### `createStateClient<typeof states>({ sse, api?, onError?, retryDelay? })`
497
+ The manifest may also come from `initial` — the snapshot the server rendered with (below). Such a
498
+ client knows it from construction: nothing above waits, a refused `subscribe` goes to `onError` at
499
+ once, and the first `subscribe` opens the stream without reading the snapshot first.
500
+
501
+ ### `createStateClient<typeof states>({ sse, api?, onError?, retryDelay?, maxRetries?, initial? })`
502
+
503
+ `initial: { snapshot, query? }` is the snapshot a server render read — `api.getStates`'s answer —
504
+ and the query it was read with (default `{}`). The client installs its manifest and applies each
505
+ state it holds at construction, keyed states under the query's key, so `current()` answers at once
506
+ and the first `subscribe` opens the stream without reading the snapshot first. Nothing is marked
507
+ `'revoked'` from it: an omission in the render's snapshot is not a denial by the stream. The
508
+ snapshot type is the router's own — another router's does not compile — and comes from the router
509
+ type parameter: without `<typeof states>` the client is untyped, as before. Pass what
510
+ `api.getStates` answered, not a hand-built snapshot: one whose manifest names a state the server
511
+ does not serve keeps the stream from opening, as the SSE handler refuses that event. A server component reads
512
+ the snapshot and passes it, with its query, as a prop to the client component that builds the
513
+ client:
514
+
515
+ ```tsx
516
+ // app/[workspaceId]/page.tsx — a server component
517
+ const query = { workspaceId };
518
+ const snapshot = await api.getStates.call({ query });
519
+
520
+ return <Shell initial={{ snapshot, query }} workspaceId={workspaceId} />;
521
+ ```
522
+
523
+ ```tsx
524
+ 'use client';
525
+ import type { StateClientInitial } from '@spfn/core/state/client';
526
+
527
+ export function Shell({ initial, workspaceId }: { initial: StateClientInitial<typeof states>; workspaceId: string })
528
+ {
529
+ const [state] = useState(() => createStateClient<typeof states>({ sse: createAuthSSEClient(), initial }));
530
+ useEffect(() => () => state.close(), [state]);
531
+ // useServerState(state, 'runtime', { workspaceId }) is 'ready' on the first render
532
+ }
533
+ ```
416
534
 
417
535
  | Member | Behaviour |
418
536
  | --- | --- |
419
537
  | `get(id, key?)` | `GET` `get<Id>State`, key fields in the query. The answer is applied under the ordering rule and returned |
420
538
  | `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 |
421
- | `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) |
539
+ | `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) |
422
540
  | `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 |
423
541
  | `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 |
424
- | `close()` | Closes the stream, drops every listener. Idempotent |
542
+ | `connection()` | Synchronous: the connection status ([Connection status](#connection-status)), a frozen object, the same reference until it changes |
543
+ | `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 |
544
+ | `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` |
545
+ | `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 |
425
546
 
426
547
  `onError` receives a failure of something the client ran on its own — the first snapshot, the
427
548
  snapshot after the stream opened, the read after a grant — and a refused `subscribe` (default
428
549
  `console.error`). Calls the app makes reject.
429
550
 
430
- A listener that throws is logged at warn with the state id and does not stop the others.
551
+ A listener that throws is logged at warn with the state id and does not stop the others; so is
552
+ a connection listener.
431
553
 
432
554
  Give the state client an `SSEClient` of its own: an `SSEClient` holds one subscription, and
433
555
  the state client's supersedes any other made on it.
@@ -436,16 +558,24 @@ the state client's supersedes any other made on it.
436
558
 
437
559
  Per `(id, key)`, every arrival — a snapshot entry, a frame, a read or a write answer — goes
438
560
  through one rule. `at` is the time the server's read started, so a larger `at` is a later value.
561
+ The client keeps, per `(id, key)`, an ordering stamp — the newest `at` seen — apart from the
562
+ entry, and compares each arrival against it.
439
563
 
440
564
  | Arrival | Result |
441
565
  | --- | --- |
442
- | no current entry, or `at` newer than the current | applied, listeners notified |
443
- | `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'` |
444
- | `at` older than the current | dropped |
445
- | a snapshot older than one state's entry | that state keeps its entry; the others take the snapshot |
566
+ | 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) |
567
+ | `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 |
568
+ | `at` older than the stamp | dropped |
569
+ | a snapshot older than one state's stamp | that state keeps its entry; the others take the snapshot |
446
570
  | a write answer | applied like a frame — its own frame, arriving later with the same `at` and value, changes nothing |
447
571
  | a frame for a key not subscribed here | dropped (the server already filters by the connect query; this is the second guard) |
448
572
 
573
+ Every stream open — the first, a reconnect, a reopen on a key change — brings each state's
574
+ current value with a newer `at`: the stream's initial frame, or the snapshot re-read when the
575
+ stream did not serve it. A listener hears it only when `status`, `value` or `data` changed. The
576
+ `at` of `current()` is the `at` of the read that last changed the value; a later read that repeats
577
+ it advances only the stamp, so an older arrival after it is still dropped.
578
+
449
579
  The ordering rule decides the value; the status is decided apart (next section): an arrival
450
580
  on a revoked `(id, key)` supplies its value and leaves it `'revoked'`.
451
581
 
@@ -458,23 +588,79 @@ changed.
458
588
  - **One stream per client**, opened when the first subscriber arrives and closed when the last
459
589
  leaves. Subscriptions made in the same task (components mounting together, a StrictMode
460
590
  unmount-remount) are batched into one change.
461
- - **Snapshot first**: the first stream opens after the first snapshot, whose manifest it needs.
591
+ - **Snapshot first**: the first stream opens after the first snapshot, whose manifest it needs —
592
+ at once on a client built with `initial`, which already holds it.
462
593
  - **Every event requested**: `events=` lists `state.<id>.changed` for every state of the
463
594
  manifest, in its order, never only the subscribed ones. The server's `grant` activates only an event the
464
595
  connection requested, so a state subscribed later would otherwise be ungrantable.
465
596
  - **Keys in the query**: the connect query is the union of the subscribed keys' fields (SSE
466
597
  `subscribe({ query })`). A new key value, or the last subscriber of one leaving, closes the
467
598
  stream and opens it with the new query.
468
- - **Snapshot on every open** — the first, a reconnect, a reopen on a key change. A frame sent
469
- while no stream was open is lost, whether its key changed or not; the snapshot read after
470
- the open covers that gap. (It runs on the stream's `open`, not at `onReconnect`: a snapshot
471
- read before the new stream is live would leave the same gap.)
599
+ - **Current frames on every open** — the first, a reconnect, a reopen on a key change. A frame
600
+ sent while no stream was open is lost, whether its key changed or not. The server covers that
601
+ gap: right after the `connected` frame it writes each allowed state's current frame — one
602
+ `read()` for the subject and the carried key, its `at` the read's start — before any live
603
+ frame, and `connected.initial` names the states it served. They are ordinary frames under the
604
+ ordering rule, so whatever changed between the render and the open arrives as a newer `at`.
605
+ The client reads the snapshot only when a state it requested is neither in `initial` nor in
606
+ `deniedEvents` — a state whose read threw on the server — or when `initial` is absent (a
607
+ server before it); a state in `deniedEvents` it watches on the carried key is `'revoked'`.
608
+ (It all runs on the stream's `connected`, not at `onReconnect`: a read before the new stream
609
+ is live would leave the same gap.)
472
610
  - **One value per key field per connection.** A connect query carries one `workspaceId`; when
473
611
  two subscribed keys disagree on a field, the most recently subscribed key is carried. A key
474
612
  the stream does not carry is read once (`get`) when the stream opens and receives no frames
475
613
  until it is carried again. A screen that must follow two workspaces live uses a second
476
614
  state client.
477
615
 
616
+ ### Connection status
617
+
618
+ The client decides the retries — the SSE client's reconnects of the stream, its own of the
619
+ first snapshot — so it says where they stand. `connection()` is one of:
620
+
621
+ | Status | Holds |
622
+ | --- | --- |
623
+ | `{ status: 'connected' }` | a stream is open (the SSE `onOpen`) |
624
+ | `{ 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()` |
625
+ | `{ 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 |
626
+
627
+ - **Start value** `{ status: 'reconnecting', attempt: 0 }`, a client built with `initial` too:
628
+ its entries are ready, its stream is not.
629
+ - A stream error alone changes nothing — a reconnect or the end of the attempts follows. A
630
+ stream the client closes itself — a key change, `close()` — is no failure; a reopen on a
631
+ key change while connected stays `'connected'`.
632
+ - **Restarts.** `retry()` and the browser's `online` event — listened to from construction
633
+ where `addEventListener` exists — restart when `'failed'` or `'reconnecting'`; a `subscribe`
634
+ restarts when `'failed'`, reopening a failed stream even when the query is unchanged. An
635
+ unsubscribe that leaves subscribers never does: the status stays `'failed'` (the last one:
636
+ below). With the manifest unknown, any call that brings it — `get`, `write`, `snapshot` —
637
+ opens the stream; with the manifest known, those calls reopen nothing while `'failed'`. The
638
+ first snapshot is read now with its count reset (one in flight is not doubled), or the stream
639
+ is reopened now as a new SSE subscription, whose attempts start from 0 — a pending reconnect
640
+ timer is superseded. The status is `'reconnecting'` 0 until it opens.
641
+ - **The last unsubscribe** closes the stream: `'reconnecting'` 0, the start value, and the
642
+ first snapshot's retry count starts over for the next wait.
643
+ - **`close()`** sets `'reconnecting'` 0, drops the connection listeners and removes the
644
+ `online` listener; its own teardown is not `'failed'`. A `subscribe` after it starts the
645
+ client again, and `retry()` restarts it; `online` is not listened to again.
646
+
647
+ ```tsx
648
+ const connection = useConnectionStatus(state);
649
+
650
+ if (connection.status === 'failed')
651
+ {
652
+ return <Banner action={() => state.retry()}>The server has a problem.</Banner>;
653
+ }
654
+ if (connection.status === 'reconnecting' && connection.attempt > 0)
655
+ {
656
+ return <Banner>The server is not answering, retrying…</Banner>;
657
+ }
658
+ ```
659
+
660
+ `maxRetries` bounds the first snapshot only; bound the stream's reconnects with the SSE
661
+ client's `maxReconnectAttempts` (`createAuthSSEClient({ maxReconnectAttempts: 5 })`). Both
662
+ default to `0`, retrying forever: the status never turns `'failed'`.
663
+
478
664
  ### `'revoked'` — the server does not serve this to you
479
665
 
480
666
  One status says the server stopped serving a `(id, key)` to this subject, however the client
@@ -484,12 +670,13 @@ value has arrived yet.
484
670
  | Arrival | Client |
485
671
  | --- | --- |
486
672
  | `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 |
673
+ | `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 |
487
674
  | 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 |
488
675
  | a frame, a read or a snapshot started before the revoke | supplies its value under the ordering rule; the status stays `'revoked'` |
489
676
  | `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 |
490
- | the snapshot of a connection opened after the revoke, holding the state | clears the mark: that connection's own connect check served it |
677
+ | 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 |
491
678
  | 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 |
492
- | 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` |
679
+ | 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` |
493
680
 
494
681
  Every automatic read is tagged with the connection it was started for, which is how a
495
682
  snapshot in flight across a revoke is told from one read after it.
@@ -501,29 +688,42 @@ snapshot in flight across a revoke is told from one read after it.
501
688
  ```
502
689
 
503
690
  - Built on `useSyncExternalStore`: `subscribe` is `client.subscribe(id, key, cb)`,
504
- `getSnapshot` is `client.current(id, key)`, and the server snapshot is `undefined` — a
505
- server render is `'loading'` and opens nothing.
691
+ `getSnapshot` is `client.current(id, key)`, and so is the server snapshot. A server render
692
+ opens nothing; it shows the value when the client was built with `initial`, `'loading'`
693
+ otherwise. Hydration matches when the server and the browser build their clients from the
694
+ same `initial`.
506
695
  - The key is compared by its fields (stable-stringified), so `{ workspaceId }` written inline
507
696
  does not resubscribe on every render; another `workspaceId` unsubscribes and subscribes. A
508
697
  key never seen is `'loading'` until it has an entry; a key seen before shows its cached
509
- entry as `'ready'` until the reopen snapshot lands (entries are not evicted).
698
+ entry as `'ready'` until the reopen's frame or snapshot lands (entries are not evicted).
510
699
  - `write` is typed only for a state with a write, bound to the hook's key. At runtime it is on
511
700
  every result — the type decides, so no render waits on the manifest — and on a state
512
701
  without a write it rejects naming the state.
513
702
  - `react` (`^18 || ^19`) is an **optional peer** of `@spfn/core`: only `@spfn/core/state/react`
514
703
  imports it. `@spfn/core/state/client` is framework-neutral.
515
704
 
516
- A page's first paint still comes from a server component reading the state's route with the
517
- app's `api.` client; the hook takes over in the browser.
705
+ A page's first paint still comes from a server component reading the snapshot with the app's
706
+ `api.` client; handed to the client as `initial`, the same read serves the hook's first render
707
+ and the client's first subscribe, the stream's initial frames bring anything newer, and the
708
+ hook takes over in the browser.
709
+
710
+ ### `useConnectionStatus(client)`
711
+
712
+ `connection()` in a component, on `useSyncExternalStore`: `subscribe` is
713
+ `client.subscribeConnection(cb)`, `getSnapshot` is `client.connection()`, both stable per
714
+ client, so a render does not resubscribe. The server snapshot is `{ status: 'reconnecting',
715
+ attempt: 0 }` — what every client starts with, so hydration matches.
518
716
 
519
717
  ### Types
520
718
 
521
719
  From `@spfn/core/state/client`: `createStateClient`, `StateClient<TStates>`,
522
- `StateClientConfig`, `StateFrame<V, D>`, `StateEntry<V, D>` (a frame plus `status`, or
523
- `{ status: 'revoked' }` alone), `StateListener`, `StateId`, `StateKeyArgs`, `WritableStateId`,
720
+ `StateClientConfig<TStates>` (`sse`, `api?`, `onError?`, `retryDelay?`, `maxRetries?`, `initial?`),
721
+ `ConnectionStatus`, `ConnectionListener`,
722
+ `StateClientInitial<TStates>` (`{ snapshot, query? }`), `StateFrame<V, D>`, `StateEntry<V, D>`
723
+ (a frame plus `status`, or `{ status: 'revoked' }` alone), `StateListener`, `StateId`, `StateKeyArgs`, `WritableStateId`,
524
724
  `StateCatalog`, `StateFrameOf`, `InferStateData`, and — re-exported type-only from the server
525
725
  module — `InferStateValue`, `InferStateKey`, `InferStateInput`. From `@spfn/core/state/react`:
526
- `useServerState`, `ServerState<TStates, Id>`.
726
+ `useServerState`, `ServerState<TStates, Id>`, `useConnectionStatus`.
527
727
 
528
728
  ---
529
729