@spfn/core 0.3.0-beta.23 → 0.3.0-beta.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/contract/index.d.ts +1 -1
- package/dist/contract/index.js +2 -2
- package/dist/contract/index.js.map +1 -1
- package/dist/event/index.d.ts +3 -3
- package/dist/event/index.js +160 -2
- package/dist/event/index.js.map +1 -1
- package/dist/event/sse/client.d.ts +2 -2
- package/dist/event/sse/client.js +20 -1
- package/dist/event/sse/client.js.map +1 -1
- package/dist/event/sse/index.d.ts +16 -4
- package/dist/event/sse/index.js +366 -109
- package/dist/event/sse/index.js.map +1 -1
- package/dist/event/ws/client.d.ts +2 -2
- package/dist/event/ws/index.d.ts +3 -3
- package/dist/router-D5B0cpVN.d.ts +378 -0
- package/dist/server/index.d.ts +3 -3
- package/dist/server/index.js +366 -109
- package/dist/server/index.js.map +1 -1
- package/dist/state/index.d.ts +380 -0
- package/dist/state/index.js +571 -0
- package/dist/state/index.js.map +1 -0
- package/dist/{types-W4u_S0lF.d.ts → types-CqTsDETV.d.ts} +1 -1
- package/dist/{types-B8zAxFsE.d.ts → types-DqXYtI69.d.ts} +27 -2
- package/package.json +6 -1
- package/src/contract/README.md +5 -3
- package/src/event/README.md +150 -13
- package/src/state/README.md +350 -0
- package/dist/router-CysBmgv3.d.ts +0 -232
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
# @spfn/core/state — server-owned state, declared once
|
|
2
|
+
|
|
3
|
+
A signed-in shell shows a handful of per-person facts: has this person a workspace, agreed to
|
|
4
|
+
the current terms, which role here, is the runtime ready. Each used to be wired by hand three
|
|
5
|
+
times — a read route for the first paint, a domain event the browser re-interprets into a
|
|
6
|
+
value, and a client store that merges the two — and the three drifted.
|
|
7
|
+
|
|
8
|
+
`defineState` declares such a fact once. `defineStateRouter` derives from that one
|
|
9
|
+
declaration the read route, the write route, its entry in a snapshot route and the SSE event.
|
|
10
|
+
The server is the single source of truth: every value a client receives is what `read()`
|
|
11
|
+
returned, read on the server; the browser never computes one.
|
|
12
|
+
|
|
13
|
+
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.
|
|
15
|
+
|
|
16
|
+
## Import paths
|
|
17
|
+
|
|
18
|
+
One entry point, server only.
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
import { defineState, defineStateRouter } from '@spfn/core/state';
|
|
22
|
+
import type { InferStateValue, InferStateKey, InferStateInput } from '@spfn/core/state';
|
|
23
|
+
import { Type } from '@sinclair/typebox'; // for .key() / .value() / .data() / .write() schemas
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
## Public API (complete)
|
|
29
|
+
|
|
30
|
+
From `@spfn/core/state`:
|
|
31
|
+
|
|
32
|
+
- Builder: `defineState(id)` → `StateBuilder`
|
|
33
|
+
- Router: `defineStateRouter(states, { path?, subject? })` → `StateRouter`
|
|
34
|
+
- Types: `StateBuilder`, `StateRouter`, `StateRouterOptions`, `StateRoutes`, `StateEvents`,
|
|
35
|
+
`StatesSnapshot`, `SubjectResolver`, `InferStateValue`, `InferStateKey`, `InferStateInput`,
|
|
36
|
+
`StateShape`, `StateValue`, `StateKey`, `StateResult`, `StateSnapshot`, `StateFrame`,
|
|
37
|
+
`ReadContext`, `PermissionContext`, `WriteContext`, `ChangedPayload`, `GrantTarget`,
|
|
38
|
+
`RevokeTarget`, `StateContract`, `StateWriteOptions`, `RoutableState`
|
|
39
|
+
|
|
40
|
+
`StateBuilder` methods — immutable, every call returns a new builder, order free, no terminal
|
|
41
|
+
method:
|
|
42
|
+
|
|
43
|
+
| Method | Effect | Refused |
|
|
44
|
+
| --- | --- | --- |
|
|
45
|
+
| `.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
|
+
| `.value(schema)` | The value. Required | a second call |
|
|
47
|
+
| `.data(schema)` | Data attached to the value. Optional | a second call |
|
|
48
|
+
| `.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
|
+
| `.contract({ since, auth?, requiresSession?, deprecatedIn?, removedIn? })` | Publishes the read route, the write route and the event | a second call |
|
|
50
|
+
| `.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 |
|
|
52
|
+
| `.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
|
+
| `.write(input, fn, { since?, deprecatedIn?, removedIn? })` | Derives the write route. `fn` gets the read context plus `input` and returns nothing | a second call |
|
|
54
|
+
| `.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 |
|
|
55
|
+
|
|
56
|
+
`StateBuilder` members: `.changed` (the change signal), `.grant({ subject, key })` (`key` on a
|
|
57
|
+
keyed state only, and required there), `.revoke({ subject, key? })`, `.id`.
|
|
58
|
+
|
|
59
|
+
`StateRouter`: `.router` (a `Router` from `defineRouter`), `.events` (`Record` of the SSE
|
|
60
|
+
events keyed by wire name), `.states` (the declarations as given).
|
|
61
|
+
|
|
62
|
+
The types of `.read()`, `.write()`, `.canRead()` and `.canWrite()` contexts and of `R` are
|
|
63
|
+
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.
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Quick start
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
// src/server/state.ts
|
|
72
|
+
import { defineState, defineStateRouter } from '@spfn/core/state';
|
|
73
|
+
import { Type } from '@sinclair/typebox';
|
|
74
|
+
|
|
75
|
+
export const consent = defineState('consent') // keyless: one value per subject
|
|
76
|
+
.value(Type.Union([Type.Literal('pending'), Type.Literal('agreed')]))
|
|
77
|
+
.data(Type.Object({ marketing: Type.Boolean() }))
|
|
78
|
+
.contract({ since: '1.3.0', auth: 'clientProofV1', requiresSession: true })
|
|
79
|
+
.read(({ subject }) => consentService.current(subject))
|
|
80
|
+
.write(Type.Object({ termsVersion: Type.String(), marketing: Type.Boolean() }),
|
|
81
|
+
({ subject, input }) => consentService.record(subject, input));
|
|
82
|
+
|
|
83
|
+
export const runtime = defineState('runtime') // keyed, shared by every reader of the key
|
|
84
|
+
.key(Type.Object({ workspaceId: Type.String() }), { shared: true })
|
|
85
|
+
.value(Type.Union([Type.Literal('provisioning'), Type.Literal('ready')]))
|
|
86
|
+
.canRead(({ subject, key }) => memberService.isMember(subject, key.workspaceId))
|
|
87
|
+
.canWrite(({ subject, key }) => memberService.isAdmin(subject, key.workspaceId))
|
|
88
|
+
.read(({ key }) => runtimeService.status(key.workspaceId))
|
|
89
|
+
.write(Type.Object({ action: Type.Literal('restart') }), ({ key }) => runtimeService.restart(key.workspaceId));
|
|
90
|
+
|
|
91
|
+
export const member = defineState('member') // keyed, one value per (subject, key)
|
|
92
|
+
.key(Type.Object({ workspaceId: Type.String() }))
|
|
93
|
+
.value(Type.Union([Type.Literal('owner'), Type.Literal('admin'), Type.Literal('member')]))
|
|
94
|
+
.canRead(({ subject, key }) => memberService.isMember(subject, key.workspaceId))
|
|
95
|
+
.read(({ subject, key }) => memberService.roleOf(subject, key.workspaceId));
|
|
96
|
+
|
|
97
|
+
export const states = defineStateRouter({ consent, runtime, member });
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```typescript
|
|
101
|
+
// server.config.ts
|
|
102
|
+
export default defineServerConfig()
|
|
103
|
+
.routes(appRouter.packages([states.router]))
|
|
104
|
+
.events(defineEventRouter({ ...appEvents, ...states.events }), { auth: { enabled: true, resolvePrincipal } })
|
|
105
|
+
.build();
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`.packages([states.router])` is the documented mount: the routes reach the generated route map
|
|
109
|
+
like `authRouter`'s, and an app route with the same name is refused by the route-map
|
|
110
|
+
generator's existing duplicate check. Spreading `states.router.routes` into the app router also
|
|
111
|
+
works. `read()` and `write()` are service calls; database access stays in repositories.
|
|
112
|
+
|
|
113
|
+
After a commit that changed a value, the app emits the change signal:
|
|
114
|
+
|
|
115
|
+
```typescript
|
|
116
|
+
await consent.changed.emit({ subject: userId });
|
|
117
|
+
await member.changed.emit({ workspaceId, subject: userId });
|
|
118
|
+
await runtime.changed.emit({ workspaceId });
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
---
|
|
122
|
+
|
|
123
|
+
## Three kinds of subject
|
|
124
|
+
|
|
125
|
+
| Declaration | `read()` receives | One value per | Frame reaches |
|
|
126
|
+
| --- | --- | --- | --- |
|
|
127
|
+
| no `.key()` | `{ subject, principal }` | subject | that subject's connections |
|
|
128
|
+
| `.key(K)` | `{ subject, principal, key }` | subject and key | that subject's connections that declared the key |
|
|
129
|
+
| `.key(K, { shared: true })` | `{ principal, key }` — no `subject` | key | every connection that declared the key and passed `canRead` |
|
|
130
|
+
|
|
131
|
+
A keyed state must declare `.canRead()`: the key comes from the request, so without it any
|
|
132
|
+
subject could read any key. `defineStateRouter` refuses one that does not.
|
|
133
|
+
|
|
134
|
+
Key fields are required strings. They travel as query parameters (always strings) and the
|
|
135
|
+
SSE frame filter compares them with the connect query as strings, so a `Type.Number()` key
|
|
136
|
+
would never match — `.key()` refuses it rather than coercing on one side. `events` and
|
|
137
|
+
`token` are refused as field names: the SSE stream reads them from the same connect query.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## What one declaration derives
|
|
142
|
+
|
|
143
|
+
With the default `path` `/state`:
|
|
144
|
+
|
|
145
|
+
| Derived | Shape |
|
|
146
|
+
| --- | --- |
|
|
147
|
+
| `getConsentState` | `GET /state/consent` → `{ at, value, data }`. Keyed: key fields in the query, validated by the route (missing → 400) |
|
|
148
|
+
| `putConsentState` | `PUT /state/consent`, only when `.write()` exists. Body = the input schema, keyed: key fields in the query. → `{ at, value, data }` |
|
|
149
|
+
| `getStates` | `GET /state` → `{ at, states: { consent: { value, data }, … } }` in declaration order — one for all states of the router |
|
|
150
|
+
| `states.events['state.consent.changed']` | the SSE event, payload `{ at, value, data, key?, subject? }` — `key` on a keyed state, `subject` unless shared |
|
|
151
|
+
| `consent.changed` | the change signal, payload keyless `{ subject }`, keyed `{ ...keyFields, subject }`, shared `{ ...keyFields }` |
|
|
152
|
+
|
|
153
|
+
`data` is present exactly when `.data()` is declared, in every response and frame.
|
|
154
|
+
|
|
155
|
+
Route names are `get<Id>State` / `put<Id>State` with the id's first letter upper-cased, so an
|
|
156
|
+
id must match `^[a-z][a-zA-Z0-9]*$` — `defineState` refuses another.
|
|
157
|
+
|
|
158
|
+
`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
|
|
160
|
+
`.changedBy()` event whose payload schema lacks a field the signal needs; two states with one
|
|
161
|
+
id; a `.write(…, { since })` on a state without `.contract()`; a state already grouped by
|
|
162
|
+
another router (each router subscribes the change signal, so every change would be read and
|
|
163
|
+
sent twice). A builder without `.value()` or
|
|
164
|
+
`.read()` is also a type error at the call.
|
|
165
|
+
|
|
166
|
+
---
|
|
167
|
+
|
|
168
|
+
## Handler order
|
|
169
|
+
|
|
170
|
+
The subject comes from `options.subject(c)`, default `c.get('auth')?.userId ?? null` — the
|
|
171
|
+
SSE `getSubject` default. No subject → `UnauthorizedError` (401). The principal on HTTP is
|
|
172
|
+
`c.get('auth')` as the app's middleware set it.
|
|
173
|
+
|
|
174
|
+
- **Read** `GET <path>/<id>`: subject → key (query, validated) → `canRead` (false → 403) →
|
|
175
|
+
`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 }`.
|
|
178
|
+
A `write` that throws propagates; nothing is read and no frame is sent. The input is a
|
|
179
|
+
command, never the value: what the route answers is re-read.
|
|
180
|
+
- **Snapshot** `GET <path>`: subject → one `at` → for each state, in declaration order: a keyed
|
|
181
|
+
state whose key fields are not all in the query is omitted; `canRead` false → omitted;
|
|
182
|
+
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.
|
|
184
|
+
|
|
185
|
+
Every `read()` result is checked against `{ value, data }` as declared — `data` exactly when
|
|
186
|
+
`.data()` is, no other field — with a schema built once per state when the router is built.
|
|
187
|
+
A result that does not match fails the read, write and snapshot routes with an `Error` (500)
|
|
188
|
+
naming the state and the first validation message, never the value; on the change signal it
|
|
189
|
+
is logged at warn and sends nothing; `grant` rejects with the same error and sends nothing.
|
|
190
|
+
|
|
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.
|
|
194
|
+
|
|
195
|
+
The derived routes are ordinary `RouteDef`s registered by `registerRoutes`, so `.use()`
|
|
196
|
+
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
|
|
200
|
+
stream. A state that needs middleware only for, say, rate limiting declares
|
|
201
|
+
`.canRead(() => true)` to say so.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
205
|
+
## The change signal
|
|
206
|
+
|
|
207
|
+
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:
|
|
210
|
+
|
|
211
|
+
| State | Read | Frame |
|
|
212
|
+
| --- | --- | --- |
|
|
213
|
+
| keyless | `read({ subject })` | `{ at, value, data, subject }` |
|
|
214
|
+
| keyed | `read({ subject, key })` | `{ at, value, data, key, subject }` |
|
|
215
|
+
| keyed shared | `read({ key })` once, however many connections hold the key | `{ at, value, data, key }` |
|
|
216
|
+
|
|
217
|
+
`emit` validates its payload and refuses a missing field — the module reads exactly what the
|
|
218
|
+
payload names. On the signal path `principal` is `undefined`. A `read()` that throws is logged
|
|
219
|
+
at warn with the state id and sends nothing; the next signal works as usual.
|
|
220
|
+
|
|
221
|
+
The signal is in-process: the pod that committed reads and emits, and the SSE event (wired to
|
|
222
|
+
the cache like any event in `.events()`) carries the frame to every pod. Do not attach a cache
|
|
223
|
+
to `state.changed` itself — every pod would read and send the frame again.
|
|
224
|
+
|
|
225
|
+
`changed.emit` on a builder no router grouped throws, naming the state. Every builder call
|
|
226
|
+
returns a new builder and the router subscribes the signal of the instance it was given, so
|
|
227
|
+
export that final instance — an intermediate `base` in `base.write(…)` is never routed.
|
|
228
|
+
|
|
229
|
+
**`.changedBy([event, …])`.** A domain event the app already emits can stand in for the
|
|
230
|
+
signal: the router subscribes each listed `EventDef` and reads its payload like
|
|
231
|
+
`changed.emit`'s — keyless `subject`; keyed the key fields and `subject`; shared the key
|
|
232
|
+
fields. `defineStateRouter` refuses an event whose declared payload schema lacks one of those
|
|
233
|
+
as a required property, naming the event and the field. A payload that fails the same check at
|
|
234
|
+
runtime is logged at warn and skipped (the app emitted it; there is nobody to throw to). The
|
|
235
|
+
module attaches nothing to the event — no `canSubscribe`, no `filter`; it only subscribes.
|
|
236
|
+
|
|
237
|
+
- An event listed by two states re-reads both and sends two frames.
|
|
238
|
+
- An event that is also in the app's SSE router reaches the browser twice by design: as itself,
|
|
239
|
+
and through the state as `state.<id>.changed`.
|
|
240
|
+
- One pod re-reads: the pod that emitted the event. A listed event bound to the cache transport
|
|
241
|
+
(every event in `.events()` is) reaches every pod, so the router subscribes it with
|
|
242
|
+
`{ localOnly: true }`; the derived `state.<id>.changed` frame then fans out to every pod
|
|
243
|
+
through the cache like any event. One emit, one read, one frame.
|
|
244
|
+
- On the emitting pod `await domainEvent.emit()` also awaits the state's `read()` and its frame
|
|
245
|
+
publish, so a slow `read()` delays the caller of `emit`.
|
|
246
|
+
- The payload is checked against the signal's fields only; a field the state does not name is
|
|
247
|
+
ignored, even when the key schema declares `additionalProperties: false`.
|
|
248
|
+
|
|
249
|
+
`read()`'s result is copied into the frame, so neither a handler mutating what it returned nor
|
|
250
|
+
a subscriber mutating the payload changes the other.
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
## Permissions
|
|
255
|
+
|
|
256
|
+
| Predicate | Enforced at | On refusal |
|
|
257
|
+
| --- | --- | --- |
|
|
258
|
+
| `canRead` | `GET <path>/<id>` | 403 |
|
|
259
|
+
| `canRead` | `GET <path>` | that state omitted |
|
|
260
|
+
| `canRead` | SSE connect (`canSubscribe`), with the key from the connect query | that event in `connected.deniedEvents` |
|
|
261
|
+
| `canWrite ?? canRead` | `PUT <path>/<id>` | 403 |
|
|
262
|
+
|
|
263
|
+
On SSE the connection declares its key in the connect query —
|
|
264
|
+
`/events/stream?events=state.runtime.changed&workspaceId=w1&token=…`. The module sets each
|
|
265
|
+
derived event's `.canSubscribe()` and `.filter()` (`@spfn/core/event`):
|
|
266
|
+
|
|
267
|
+
- `canSubscribe`: keyed — any key field missing from the query denies without calling
|
|
268
|
+
`canRead`; otherwise `canRead({ subject, principal, key })`. Keyless — `canRead` when
|
|
269
|
+
declared, else allowed (a subject exists; the handler asks only then). `principal` is what
|
|
270
|
+
the SSE auth config's `resolvePrincipal` returned.
|
|
271
|
+
- `filter`: every key field of the frame equals the query's, and a frame carrying `subject`
|
|
272
|
+
reaches only that subject.
|
|
273
|
+
|
|
274
|
+
`canRead` runs once per connection. A membership that ends while the connection is open is
|
|
275
|
+
the app's `revoke` call — nothing re-evaluates it.
|
|
276
|
+
|
|
277
|
+
**State events require an authenticated stream** (`auth: { enabled: true }` in `.events()`).
|
|
278
|
+
Without auth a connection has no subject, the handler never asks `canSubscribe`, and so
|
|
279
|
+
`canRead` would be skipped; the derived `filter` therefore refuses every connection without a
|
|
280
|
+
subject, and no state frame is delivered on such a stream.
|
|
281
|
+
|
|
282
|
+
## `grant` / `revoke`
|
|
283
|
+
|
|
284
|
+
```typescript
|
|
285
|
+
await runtime.revoke({ subject: userId }); // membership ended
|
|
286
|
+
await runtime.grant({ subject: userId, key: { workspaceId } }); // membership began
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
`grant` opens exactly the granted key and does not consult `canRead` — it is the app's
|
|
290
|
+
statement that the subject may now read it. On a keyed state it calls the derived event's
|
|
291
|
+
`grant(subject, { query: key })`, so only the subject's connections whose connect query
|
|
292
|
+
declared that key are activated; a tab of the same subject on another key — one `canRead`
|
|
293
|
+
refused — stays shut. Then it reads the current value for that subject and key and sends it as
|
|
294
|
+
one frame, so the client has the value at once. `key` is required on a keyed state (a type
|
|
295
|
+
error, and a refusal naming the state at runtime); a keyless state takes `{ subject }`.
|
|
296
|
+
|
|
297
|
+
The key is checked against `.key()`: an empty or partial key (or a non-string field) rejects
|
|
298
|
+
naming the state and the field, since it would match every connection of the subject. A field
|
|
299
|
+
`.key()` does not declare is dropped, never matched on. `revoke({ subject, key })` checks the
|
|
300
|
+
same way.
|
|
301
|
+
|
|
302
|
+
`revoke({ subject, key })` deactivates the event on the subject's connections that declared
|
|
303
|
+
`key`; `revoke({ subject })` without a key reaches every connection of the subject, whatever
|
|
304
|
+
key it declared. Each receives `subscription.revoked`.
|
|
305
|
+
|
|
306
|
+
Both reach every pod through the cache transport, which applies the same key rule. A `read()`
|
|
307
|
+
that throws, or returns an undeclared result, in `grant` rejects the call and sends nothing
|
|
308
|
+
(the grant itself is already applied). Across pods the grant's control message and its frame
|
|
309
|
+
travel on separate channels, so a tab on another pod may still be denied when the frame arrives
|
|
310
|
+
and drop it; the next signal brings it up to date.
|
|
311
|
+
|
|
312
|
+
---
|
|
313
|
+
|
|
314
|
+
## `at`
|
|
315
|
+
|
|
316
|
+
`at` is `new Date().toISOString()` taken **before** `read()` starts. Every response and frame
|
|
317
|
+
carries the `at` of the read that produced it, and a snapshot carries one `at` for all its
|
|
318
|
+
states. A client applies a value only when its `at` is not older than the one it holds.
|
|
319
|
+
|
|
320
|
+
`at` is wall-clock: across pods it is as good as their clocks, typically within a few
|
|
321
|
+
milliseconds of each other. Two values with the same `at` are resolved as "same value, no
|
|
322
|
+
change" by the client (implemented in the client half). There is no version counter.
|
|
323
|
+
|
|
324
|
+
## Contract
|
|
325
|
+
|
|
326
|
+
`.contract({ since, auth?, requiresSession?, deprecatedIn?, removedIn? })` publishes, in
|
|
327
|
+
`contracts/current.json`:
|
|
328
|
+
|
|
329
|
+
- the read operation, response `Type.Object({ at: Type.String(), value, data })` (`data` when declared),
|
|
330
|
+
key fields as the request query;
|
|
331
|
+
- the write operation, request body = the input schema, the same response, and its own
|
|
332
|
+
`since` / `deprecatedIn` / `removedIn` from `.write(…, options)` falling back field by field
|
|
333
|
+
to the state's;
|
|
334
|
+
- the SSE event `state.<id>.changed` with `since` / `deprecatedIn` / `removedIn`.
|
|
335
|
+
|
|
336
|
+
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
|
|
338
|
+
operation and `event.payload.type-changed` on the event, as any route and event.
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
## Out of scope
|
|
343
|
+
|
|
344
|
+
- Browser-only state (an open panel, a draft), session and network state.
|
|
345
|
+
- 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.
|
|
348
|
+
- De-duplicating `read()` across subscribers of a per-subject keyed state (each subject has
|
|
349
|
+
its own value; a shared key is read once).
|
|
350
|
+
- A route-level "refuse until state X is Y" gate.
|
|
@@ -1,232 +0,0 @@
|
|
|
1
|
-
import { TSchema } from '@sinclair/typebox';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Event System Types
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Pub/Sub capable cache interface for multi-instance events
|
|
9
|
-
*/
|
|
10
|
-
interface PubSubCache {
|
|
11
|
-
/**
|
|
12
|
-
* Publish a message to a channel
|
|
13
|
-
*/
|
|
14
|
-
publish(channel: string, message: unknown): Promise<void>;
|
|
15
|
-
/**
|
|
16
|
-
* Subscribe to a channel
|
|
17
|
-
*/
|
|
18
|
-
subscribe(channel: string, handler: (message: unknown) => void | Promise<void>): Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Event handler function type
|
|
22
|
-
*/
|
|
23
|
-
type EventHandler<TPayload> = (payload: TPayload) => void | Promise<void>;
|
|
24
|
-
/**
|
|
25
|
-
* Job queue sender function type (used by job module)
|
|
26
|
-
*/
|
|
27
|
-
type JobQueueSender = (queueName: string, payload: unknown) => Promise<void>;
|
|
28
|
-
/**
|
|
29
|
-
* What a contracted event promises to clients compiled separately from the
|
|
30
|
-
* server, written into `contracts/current.json` by `@spfn/core:contract`.
|
|
31
|
-
*
|
|
32
|
-
* The same version markers a route's `.contract()` carries; the payload shape
|
|
33
|
-
* is the event's own schema, so it is not repeated here.
|
|
34
|
-
*/
|
|
35
|
-
interface EventContract {
|
|
36
|
-
/** Contract version the event first appeared in. */
|
|
37
|
-
since: string;
|
|
38
|
-
/** Contract version the event was announced for removal in. */
|
|
39
|
-
deprecatedIn?: string;
|
|
40
|
-
/** Contract version the event was removed in. */
|
|
41
|
-
removedIn?: string;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* Event definition interface
|
|
45
|
-
*/
|
|
46
|
-
interface EventDef<TPayload = void> {
|
|
47
|
-
/**
|
|
48
|
-
* Unique event name
|
|
49
|
-
*/
|
|
50
|
-
readonly name: string;
|
|
51
|
-
/**
|
|
52
|
-
* TypeBox payload schema (optional)
|
|
53
|
-
*/
|
|
54
|
-
readonly schema?: TSchema;
|
|
55
|
-
/**
|
|
56
|
-
* The contract declared with `.contract()`, absent when the event is not
|
|
57
|
-
* part of the published contract.
|
|
58
|
-
*
|
|
59
|
-
* Named with an underscore because `.contract()` holds the plain name, as
|
|
60
|
-
* a router's `_contractVersion` sits beside `.contractVersion()`.
|
|
61
|
-
*/
|
|
62
|
-
readonly _contract?: EventContract;
|
|
63
|
-
/**
|
|
64
|
-
* Publish this event in `contracts/current.json`.
|
|
65
|
-
*
|
|
66
|
-
* Sets the contract on this definition and returns it. An event is defined
|
|
67
|
-
* once and shared, so a second call throws instead of one of the two
|
|
68
|
-
* declarations silently winning.
|
|
69
|
-
*/
|
|
70
|
-
contract: (contract: EventContract) => EventDef<TPayload>;
|
|
71
|
-
/**
|
|
72
|
-
* Subscribe to this event (in-memory handler)
|
|
73
|
-
*/
|
|
74
|
-
subscribe: (handler: EventHandler<TPayload>) => () => void;
|
|
75
|
-
/**
|
|
76
|
-
* Unsubscribe all handlers
|
|
77
|
-
*/
|
|
78
|
-
unsubscribeAll: () => void;
|
|
79
|
-
/**
|
|
80
|
-
* Emit the event (triggers all subscribers and queued jobs)
|
|
81
|
-
*/
|
|
82
|
-
emit: TPayload extends void ? () => Promise<void> : (payload: TPayload) => Promise<void>;
|
|
83
|
-
/**
|
|
84
|
-
* Enable cache-based pub/sub for multi-instance support
|
|
85
|
-
* Must await before emitting events to ensure subscription is ready
|
|
86
|
-
*/
|
|
87
|
-
useCache: (cache: PubSubCache) => Promise<EventDef<TPayload>>;
|
|
88
|
-
/**
|
|
89
|
-
* Internal: Register a job queue to receive this event
|
|
90
|
-
* Called by job registration system
|
|
91
|
-
*/
|
|
92
|
-
_registerJobQueue: (queueName: string, sender: JobQueueSender) => void;
|
|
93
|
-
/**
|
|
94
|
-
* Internal: Drop the cache binding (cache + subscribed flag).
|
|
95
|
-
* Called by the event cache transport on shutdown so a later useCache can
|
|
96
|
-
* rebind a fresh cache in the same process.
|
|
97
|
-
*/
|
|
98
|
-
_resetCache: () => void;
|
|
99
|
-
/**
|
|
100
|
-
* Type inference helper
|
|
101
|
-
*/
|
|
102
|
-
_payload: TPayload;
|
|
103
|
-
}
|
|
104
|
-
/**
|
|
105
|
-
* Infer payload type from EventDef
|
|
106
|
-
*/
|
|
107
|
-
type InferEventPayload$1<TEvent> = TEvent extends EventDef<infer TPayload> ? TPayload : never;
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Event Router
|
|
111
|
-
*
|
|
112
|
-
* Type-safe event router for SSE subscription
|
|
113
|
-
*
|
|
114
|
-
* @example
|
|
115
|
-
* ```typescript
|
|
116
|
-
* import { defineEvent, defineEventRouter } from '@spfn/core/event';
|
|
117
|
-
* import { Type } from '@sinclair/typebox';
|
|
118
|
-
*
|
|
119
|
-
* const userCreated = defineEvent('user.created', Type.Object({
|
|
120
|
-
* userId: Type.String(),
|
|
121
|
-
* }));
|
|
122
|
-
*
|
|
123
|
-
* const orderPlaced = defineEvent('order.placed', Type.Object({
|
|
124
|
-
* orderId: Type.String(),
|
|
125
|
-
* amount: Type.Number(),
|
|
126
|
-
* }));
|
|
127
|
-
*
|
|
128
|
-
* export const eventRouter = defineEventRouter({
|
|
129
|
-
* userCreated,
|
|
130
|
-
* orderPlaced,
|
|
131
|
-
* });
|
|
132
|
-
*
|
|
133
|
-
* export type EventRouter = typeof eventRouter;
|
|
134
|
-
* ```
|
|
135
|
-
*/
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* How a client reaches the contracted events of a router.
|
|
139
|
-
*
|
|
140
|
-
* Declared on the router because it is the stream's promise, not one event's:
|
|
141
|
-
* `createServer` checks it against `.events()` at boot, so the document cannot
|
|
142
|
-
* describe a path or an auth mode the server does not serve.
|
|
143
|
-
*/
|
|
144
|
-
interface EventRouterContract {
|
|
145
|
-
/**
|
|
146
|
-
* `'tokenExchange'` when `.events()` enables `auth` — the client first posts
|
|
147
|
-
* to the token path and opens the stream with `?token=`. `'none'` otherwise.
|
|
148
|
-
*/
|
|
149
|
-
auth: 'none' | 'tokenExchange';
|
|
150
|
-
/**
|
|
151
|
-
* The stream path `.events()` registers.
|
|
152
|
-
* @default '/events/stream'
|
|
153
|
-
*/
|
|
154
|
-
streamPath?: string;
|
|
155
|
-
}
|
|
156
|
-
/**
|
|
157
|
-
* What every router of events carries — the SSE event router and the
|
|
158
|
-
* WebSocket router alike.
|
|
159
|
-
*
|
|
160
|
-
* The WebSocket router builds on this rather than on `EventRouterDef`, because
|
|
161
|
-
* only an SSE event router can be contracted.
|
|
162
|
-
*/
|
|
163
|
-
interface EventRouterBase<TEvents extends Record<string, EventDef<any>>> {
|
|
164
|
-
/**
|
|
165
|
-
* Event definitions
|
|
166
|
-
*/
|
|
167
|
-
readonly events: TEvents;
|
|
168
|
-
/**
|
|
169
|
-
* Event names as array
|
|
170
|
-
*/
|
|
171
|
-
readonly eventNames: (keyof TEvents)[];
|
|
172
|
-
/**
|
|
173
|
-
* Type inference helper - payload types by event name
|
|
174
|
-
*/
|
|
175
|
-
readonly _types: {
|
|
176
|
-
[K in keyof TEvents]: TEvents[K]['_payload'];
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
/**
|
|
180
|
-
* Event Router Definition
|
|
181
|
-
*/
|
|
182
|
-
interface EventRouterDef<TEvents extends Record<string, EventDef<any>>> extends EventRouterBase<TEvents> {
|
|
183
|
-
/**
|
|
184
|
-
* The contract declared with `.contract()`, absent when the router declares
|
|
185
|
-
* none. Underscored for the same reason as `EventDef._contract`.
|
|
186
|
-
*/
|
|
187
|
-
readonly _contract?: EventRouterContract;
|
|
188
|
-
/**
|
|
189
|
-
* Declare how a client reaches this router's contracted events.
|
|
190
|
-
*
|
|
191
|
-
* Sets the contract on this router and returns it. A `streamPath` that does
|
|
192
|
-
* not start with `/` throws here, where the typo is.
|
|
193
|
-
*/
|
|
194
|
-
contract: (contract: EventRouterContract) => EventRouterDef<TEvents>;
|
|
195
|
-
}
|
|
196
|
-
/**
|
|
197
|
-
* Infer event names from EventRouter
|
|
198
|
-
*/
|
|
199
|
-
type InferEventNames<T> = T extends EventRouterBase<infer E> ? keyof E & string : never;
|
|
200
|
-
/**
|
|
201
|
-
* Infer payload type for specific event
|
|
202
|
-
*/
|
|
203
|
-
type InferEventPayload<T extends EventRouterBase<any>, K extends InferEventNames<T>> = T['_types'][K];
|
|
204
|
-
/**
|
|
205
|
-
* Infer all event payloads map
|
|
206
|
-
*/
|
|
207
|
-
type InferEventPayloads<T extends EventRouterBase<any>> = T['_types'];
|
|
208
|
-
/**
|
|
209
|
-
* Define an event router for SSE subscription
|
|
210
|
-
*
|
|
211
|
-
* `.contract({ auth })` on the router, together with `.contract({ since })` on
|
|
212
|
-
* its events, publishes those events in `contracts/current.json` — see the
|
|
213
|
-
* contract README.
|
|
214
|
-
*
|
|
215
|
-
* @example
|
|
216
|
-
* ```typescript
|
|
217
|
-
* export const eventRouter = defineEventRouter({
|
|
218
|
-
* userCreated,
|
|
219
|
-
* orderPlaced,
|
|
220
|
-
* });
|
|
221
|
-
*
|
|
222
|
-
* // Type inference
|
|
223
|
-
* type Names = InferEventNames<typeof eventRouter>;
|
|
224
|
-
* // 'userCreated' | 'orderPlaced'
|
|
225
|
-
*
|
|
226
|
-
* type Payload = InferEventPayload<typeof eventRouter, 'userCreated'>;
|
|
227
|
-
* // { userId: string }
|
|
228
|
-
* ```
|
|
229
|
-
*/
|
|
230
|
-
declare function defineEventRouter<TEvents extends Record<string, EventDef<any>>>(events: TEvents): EventRouterDef<TEvents>;
|
|
231
|
-
|
|
232
|
-
export { type EventRouterDef as E, type InferEventNames as I, type JobQueueSender as J, type PubSubCache as P, type EventDef as a, type EventContract as b, type EventHandler as c, type EventRouterContract as d, type InferEventPayload$1 as e, type InferEventPayloads as f, type InferEventPayload as g, defineEventRouter as h, type EventRouterBase as i };
|