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

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 (48) hide show
  1. package/dist/codegen/index.d.ts +65 -2
  2. package/dist/codegen/index.js +160 -15
  3. package/dist/codegen/index.js.map +1 -1
  4. package/dist/contract/index.d.ts +2 -2
  5. package/dist/db/index.d.ts +39 -11
  6. package/dist/db/index.js +34 -14
  7. package/dist/db/index.js.map +1 -1
  8. package/dist/{error-TsUIjp_x.d.ts → error-CIZlYJlL.d.ts} +1 -1
  9. package/dist/event/index.d.ts +3 -3
  10. package/dist/event/index.js +10 -0
  11. package/dist/event/index.js.map +1 -1
  12. package/dist/event/sse/client.d.ts +2 -2
  13. package/dist/event/sse/client.js +27 -17
  14. package/dist/event/sse/client.js.map +1 -1
  15. package/dist/event/sse/index.d.ts +3 -3
  16. package/dist/event/sse/index.js +115 -34
  17. package/dist/event/sse/index.js.map +1 -1
  18. package/dist/event/ws/client.d.ts +2 -2
  19. package/dist/event/ws/index.d.ts +3 -3
  20. package/dist/ops/index.d.ts +3 -3
  21. package/dist/ops/index.js +15 -5
  22. package/dist/ops/index.js.map +1 -1
  23. package/dist/route/index.d.ts +2 -2
  24. package/dist/route/index.js +15 -5
  25. package/dist/route/index.js.map +1 -1
  26. package/dist/{router-4YoIy5fN.d.ts → router-C57y-vz1.d.ts} +12 -0
  27. package/dist/{router-D5B0cpVN.d.ts → router-CX5uVjmU.d.ts} +29 -1
  28. package/dist/server/index.d.ts +3 -3
  29. package/dist/server/index.js +130 -39
  30. package/dist/server/index.js.map +1 -1
  31. package/dist/state/client.d.ts +73 -11
  32. package/dist/state/client.js +231 -31
  33. package/dist/state/client.js.map +1 -1
  34. package/dist/state/index.js +19 -4
  35. package/dist/state/index.js.map +1 -1
  36. package/dist/state/react.d.ts +18 -3
  37. package/dist/state/react.js +11 -5
  38. package/dist/state/react.js.map +1 -1
  39. package/dist/surface/index.d.ts +3 -3
  40. package/dist/surface/index.js +15 -5
  41. package/dist/surface/index.js.map +1 -1
  42. package/dist/{types-04gu3uMj.d.ts → types-CurMab2Q.d.ts} +19 -2
  43. package/dist/{types-CqTsDETV.d.ts → types-DncqXfWN.d.ts} +1 -1
  44. package/package.json +1 -1
  45. package/src/codegen/README.md +89 -2
  46. package/src/db/transaction/README.md +58 -35
  47. package/src/event/README.md +61 -14
  48. package/src/state/README.md +228 -35
@@ -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
 
@@ -191,11 +191,16 @@ With the default `path` `/state`:
191
191
  | `getConsentState` | `GET /state/consent` → `{ at, value, data }`. Keyed: key fields in the query, validated by the route (missing → 400) |
192
192
  | `putConsentState` | `PUT /state/consent`, only when `.write()` exists. Body = the input schema, keyed: key fields in the query. → `{ at, value, data }` |
193
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 |
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
195
  | `consent.changed` | the change signal, payload keyless `{ subject }`, keyed `{ ...keyFields, subject }`, shared `{ ...keyFields }` |
196
196
 
197
197
  `data` is present exactly when `.data()` is declared, in every response and frame.
198
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
+
199
204
  Route names are `get<Id>State` / `put<Id>State` with the id's first letter upper-cased, so an
200
205
  id must match `^[a-z][a-zA-Z0-9]*$` — `defineState` refuses another.
201
206
 
@@ -240,7 +245,11 @@ Every `read()` result is checked against `{ value, data }` as declared — `data
240
245
  `.data()` is, no other field — with a schema built once per state when the router is built.
241
246
  A result that does not match fails the read, write and snapshot routes with an `Error` (500)
242
247
  naming the state and the first validation message, never the value; on the change signal it
243
- is logged at warn and sends nothing; `grant` rejects with the same error and sends nothing.
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.
244
253
 
245
254
  A write whose `read()` then fails — it threw or returned an undeclared result — answers 500
246
255
  and sends no frame. Without a transaction the write has already committed: there is no value
@@ -314,17 +323,15 @@ Every frame — of the write route, of the change signal and of `grant` — is s
314
323
  | Where the read ran | Frame |
315
324
  | --- | --- |
316
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 |
317
- | inside a transaction | queued with `onAfterCommit` and published after the root transaction commits; never when the root rolls back |
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 |
318
327
 
319
328
  - The frame carries the value read inside the transaction, with that read's `at`; nothing is
320
329
  read again after the commit. The response is the same snapshot.
321
330
  - So a `changed.emit` or a `grant` made inside a transaction is deferred to its commit, like a
322
331
  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.
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.
328
335
  - A deferred emit runs outside the transaction context; an error it throws is logged by the
329
336
  transaction runner and reaches nobody.
330
337
 
@@ -430,6 +437,10 @@ write and snapshot routes, keeps one SSE stream open for every state, and decide
430
437
  only** — `import type { states }` — so no server code reaches the browser bundle;
431
438
  `@spfn/core/state/client` imports nothing from `@spfn/core/state` at runtime.
432
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
+
433
444
  ```typescript
434
445
  // src/client/state.ts
435
446
  import { createStateClient } from '@spfn/core/state/client';
@@ -480,28 +491,132 @@ of fields, goes to `onError`, and the subscription is dropped without reopening
480
491
  `write` on a state the manifest marks `write: false` rejects naming the state, before any
481
492
  request. A first snapshot that fails goes to `onError` (or rejects the call that needed it).
482
493
  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
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
486
499
  in flight discards its answer, and a `get` or `write` waiting on it rejects: the client was
487
500
  closed.
488
501
 
489
- ### `createStateClient<typeof states>({ sse, api?, onError?, retryDelay? })`
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
+ ```
490
601
 
491
602
  | Member | Behaviour |
492
603
  | --- | --- |
493
604
  | `get(id, key?)` | `GET` `get<Id>State`, key fields in the query. The answer is applied under the ordering rule and returned |
494
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 |
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) |
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) |
496
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 |
497
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 |
498
- | `close()` | Closes the stream, drops every listener. Idempotent |
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 |
499
613
 
500
614
  `onError` receives a failure of something the client ran on its own — the first snapshot, the
501
615
  snapshot after the stream opened, the read after a grant — and a refused `subscribe` (default
502
616
  `console.error`). Calls the app makes reject.
503
617
 
504
- A listener that throws is logged at warn with the state id and does not stop the others.
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.
505
620
 
506
621
  Give the state client an `SSEClient` of its own: an `SSEClient` holds one subscription, and
507
622
  the state client's supersedes any other made on it.
@@ -510,16 +625,24 @@ the state client's supersedes any other made on it.
510
625
 
511
626
  Per `(id, key)`, every arrival — a snapshot entry, a frame, a read or a write answer — goes
512
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.
513
630
 
514
631
  | Arrival | Result |
515
632
  | --- | --- |
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 |
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 |
520
637
  | a write answer | applied like a frame — its own frame, arriving later with the same `at` and value, changes nothing |
521
638
  | a frame for a key not subscribed here | dropped (the server already filters by the connect query; this is the second guard) |
522
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
+
523
646
  The ordering rule decides the value; the status is decided apart (next section): an arrival
524
647
  on a revoked `(id, key)` supplies its value and leaves it `'revoked'`.
525
648
 
@@ -532,23 +655,79 @@ changed.
532
655
  - **One stream per client**, opened when the first subscriber arrives and closed when the last
533
656
  leaves. Subscriptions made in the same task (components mounting together, a StrictMode
534
657
  unmount-remount) are batched into one change.
535
- - **Snapshot first**: the first stream opens after the first snapshot, whose manifest it needs.
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.
536
660
  - **Every event requested**: `events=` lists `state.<id>.changed` for every state of the
537
661
  manifest, in its order, never only the subscribed ones. The server's `grant` activates only an event the
538
662
  connection requested, so a state subscribed later would otherwise be ungrantable.
539
663
  - **Keys in the query**: the connect query is the union of the subscribed keys' fields (SSE
540
664
  `subscribe({ query })`). A new key value, or the last subscriber of one leaving, closes the
541
665
  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.)
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.)
546
677
  - **One value per key field per connection.** A connect query carries one `workspaceId`; when
547
678
  two subscribed keys disagree on a field, the most recently subscribed key is carried. A key
548
679
  the stream does not carry is read once (`get`) when the stream opens and receives no frames
549
680
  until it is carried again. A screen that must follow two workspaces live uses a second
550
681
  state client.
551
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
+
552
731
  ### `'revoked'` — the server does not serve this to you
553
732
 
554
733
  One status says the server stopped serving a `(id, key)` to this subject, however the client
@@ -558,12 +737,13 @@ value has arrived yet.
558
737
  | Arrival | Client |
559
738
  | --- | --- |
560
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 |
561
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 |
562
742
  | a frame, a read or a snapshot started before the revoke | supplies its value under the ordering rule; the status stays `'revoked'` |
563
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 |
564
- | the snapshot of a connection opened after the revoke, holding the state | clears the mark: that connection's own connect check served it |
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 |
565
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 |
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` |
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` |
567
747
 
568
748
  Every automatic read is tagged with the connection it was started for, which is how a
569
749
  snapshot in flight across a revoke is told from one read after it.
@@ -575,29 +755,42 @@ snapshot in flight across a revoke is told from one read after it.
575
755
  ```
576
756
 
577
757
  - 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.
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`.
580
762
  - The key is compared by its fields (stable-stringified), so `{ workspaceId }` written inline
581
763
  does not resubscribe on every render; another `workspaceId` unsubscribes and subscribes. A
582
764
  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).
765
+ entry as `'ready'` until the reopen's frame or snapshot lands (entries are not evicted).
584
766
  - `write` is typed only for a state with a write, bound to the hook's key. At runtime it is on
585
767
  every result — the type decides, so no render waits on the manifest — and on a state
586
768
  without a write it rejects naming the state.
587
769
  - `react` (`^18 || ^19`) is an **optional peer** of `@spfn/core`: only `@spfn/core/state/react`
588
770
  imports it. `@spfn/core/state/client` is framework-neutral.
589
771
 
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.
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.
592
783
 
593
784
  ### Types
594
785
 
595
786
  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`,
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`,
598
791
  `StateCatalog`, `StateFrameOf`, `InferStateData`, and — re-exported type-only from the server
599
792
  module — `InferStateValue`, `InferStateKey`, `InferStateInput`. From `@spfn/core/state/react`:
600
- `useServerState`, `ServerState<TStates, Id>`.
793
+ `useServerState`, `ServerState<TStates, Id>`, `useConnectionStatus`.
601
794
 
602
795
  ---
603
796