experimental-a2 0.17.0 → 0.18.1
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/CHANGELOG.md +32 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-C7b-1arn.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-C7b-1arn.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +2 -2
- package/dist/{ai-vpGtcaCB.d.ts → ai-BMLlI2Ff.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-BMLlI2Ff.d.ts.map} +1 -1
- package/dist/{ai-UrWfKeuv.js → ai-D6mGlO8j.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-D6mGlO8j.js.map} +1 -1
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +692 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-B2suP2Qh.d.ts +26 -0
- package/dist/ai-harness-B2suP2Qh.d.ts.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +950 -0
- package/dist/ai-pi.js.map +1 -0
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +47 -25
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +113 -40
- package/dist/ai-server.js.map +1 -1
- package/dist/ai-tool-input-DmSqMUQi.js +40 -0
- package/dist/ai-tool-input-DmSqMUQi.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-COaiaDub.js → client-BhHLoNKF.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BhHLoNKF.js.map} +1 -1
- package/dist/{client-qRnRUzBS.d.ts → client-h_DKFAjj.d.ts} +3 -3
- package/dist/{client-qRnRUzBS.d.ts.map → client-h_DKFAjj.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/index.d.ts +5 -5
- package/dist/{initial-snapshot-BrLso5x_.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +2 -2
- package/dist/{initial-snapshot-BrLso5x_.d.ts.map → initial-snapshot-Bx-c0pDP.d.ts.map} +1 -1
- package/dist/{internal-CbVvEwad.js → internal-CscSY7K_.js} +11 -3
- package/dist/internal-CscSY7K_.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/{react-BbJ-g6Uu.d.ts → react-BBXgHWFc.d.ts} +4 -4
- package/dist/{react-BbJ-g6Uu.d.ts.map → react-BBXgHWFc.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/reducer-BAXc8JyF.js.map +1 -1
- package/dist/{reducer-BYu6DT7h.d.ts → reducer-CqA59Q6U.d.ts} +2 -2
- package/dist/{reducer-BYu6DT7h.d.ts.map → reducer-CqA59Q6U.d.ts.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +7 -7
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +9 -10
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-Rj64fNvX.js → server-D-Y4pqwu.js} +126 -67
- package/dist/server-D-Y4pqwu.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-DYAATFuT.d.ts} +42 -7
- package/dist/server-DYAATFuT.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +1 -1
- package/dist/{session-clear-Dshpuhff.d.ts → session-clear-C-QwTXV1.d.ts} +2 -2
- package/dist/session-clear-C-QwTXV1.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-CyLUy86v.d.ts} +53 -5
- package/dist/store-CyLUy86v.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +56 -11
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-7WJV0YWP.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-7WJV0YWP.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +106 -25
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-B1ixeqIS.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-B1ixeqIS.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-tqWNnMRu.js → store-redis-notify-CM0AfLY0.js} +271 -101
- package/dist/store-redis-notify-CM0AfLY0.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +66 -21
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-cZH-cibj.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-DjfHOvdW.js → wire-BKxH1vbP.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-BKxH1vbP.js.map} +1 -1
- package/docs/01-quickstart.mdx +49 -83
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -86
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -150
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +109 -145
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +860 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +731 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +142 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +323 -0
- package/docs/server/06-guarantees.mdx +132 -0
- package/examples/playground/DEPLOY.md +3 -5
- package/examples/playground/app/a0/[appId]/page.tsx +12 -13
- package/examples/playground/app/a0/app-list.tsx +9 -2
- package/examples/playground/app/a0/app-view.tsx +9 -1
- package/examples/playground/app/a0/apps/[appId]/route.ts +10 -5
- package/examples/playground/app/a0/apps.test.ts +12 -0
- package/examples/playground/app/a0/apps.ts +22 -7
- package/examples/playground/app/a0/chat.tsx +47 -1
- package/examples/playground/app/a0/devtools.tsx +2 -8
- package/examples/playground/app/a0/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
- package/examples/playground/app/a0/model.test.ts +18 -1
- package/examples/playground/app/a0/model.ts +47 -10
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
- package/examples/playground/app/a0/sandbox-log.ts +10 -4
- package/examples/playground/app/a0/sandbox-session.ts +152 -0
- package/examples/playground/app/a0/scheduler/route.ts +2 -2
- package/examples/playground/app/a0/server.ts +98 -54
- package/examples/playground/app/a0/session.ts +17 -5
- package/examples/playground/app/a0/shell.tsx +11 -3
- package/examples/playground/app/agent/ingress.test.ts +6 -3
- package/examples/playground/app/agent/server.ts +9 -6
- package/examples/playground/app/agent/telemetry.test.ts +71 -50
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +30 -26
- package/examples/playground/app/chat/server.ts +46 -46
- package/examples/playground/next.config.ts +17 -5
- package/examples/playground/package.json +4 -1
- package/examples/playground/vercel.json +18 -0
- package/package.json +27 -3
- package/src/ai-agent.ts +271 -90
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +912 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1322 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/ai-tool-input.ts +45 -0
- package/src/internal.ts +13 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/scheduler-qstash.ts +5 -5
- package/src/scheduler-vercel.ts +14 -15
- package/src/server.ts +233 -29
- package/src/store-memory.ts +92 -16
- package/src/store-postgres.ts +156 -25
- package/src/store-redis-core.ts +322 -110
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +121 -28
- package/src/store.ts +55 -3
- package/src/telemetry.ts +1 -0
- package/dist/internal-CbVvEwad.js.map +0 -1
- package/dist/server-CGM8WzRG.d.ts.map +0 -1
- package/dist/server-Rj64fNvX.js.map +0 -1
- package/dist/session-clear-Dshpuhff.d.ts.map +0 -1
- package/dist/store-Gpkq7WBo.d.ts.map +0 -1
- package/dist/store-redis-notify-tqWNnMRu.js.map +0 -1
- package/dist/telemetry-cZH-cibj.d.ts.map +0 -1
- package/docs/concepts/01-contracts.mdx +0 -237
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -193
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -324
- package/docs/guides/04-local-first.mdx +0 -47
- package/docs/guides/05-production.mdx +0 -534
- package/docs/guides/06-ai-agents.mdx +0 -1408
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -262
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -2358
- package/docs/reference/02-errors.mdx +0 -150
- /package/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: React
|
|
3
|
+
description: "Start from a snapshot, fold new events, and push optimistic writes through one shared session."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Publish the state
|
|
7
|
+
|
|
8
|
+
The reducer lives with the event vocabulary, in a shared module:
|
|
9
|
+
|
|
10
|
+
```ts model.ts
|
|
11
|
+
import { contract } from 'experimental-a2'
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
|
|
14
|
+
export const counter = contract({
|
|
15
|
+
name: 'counter',
|
|
16
|
+
events: { added: z.number() },
|
|
17
|
+
reducer: {
|
|
18
|
+
name: 'count.v1',
|
|
19
|
+
initialState: 0,
|
|
20
|
+
fold: (state, event) => state + event.payload,
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
A contract can publish one reducer. The client starts from a snapshot of its
|
|
26
|
+
state and folds later events locally, for one known session ID. Queries across
|
|
27
|
+
sessions belong in your [application database](/contract/sessions).
|
|
28
|
+
|
|
29
|
+
The reducer name identifies its [snapshot format](/contract/state#snapshots-are-a-cache):
|
|
30
|
+
change it when the fold's meaning or stored shape changes, and run the same
|
|
31
|
+
version on server and client. An inline fold returning a discriminated union
|
|
32
|
+
may need `as const` or an explicit return type; the
|
|
33
|
+
[`reducer` builder](/contract/state#reducers) fixes the state type first.
|
|
34
|
+
Other reducers read on the server with `session.state(reducer)` publish no
|
|
35
|
+
client state.
|
|
36
|
+
|
|
37
|
+
```ts server/counter.ts
|
|
38
|
+
import { createServer } from 'experimental-a2/server'
|
|
39
|
+
import { counter } from '../model'
|
|
40
|
+
|
|
41
|
+
export const counterServer = createServer({ contract: counter })
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts app/api/counter/route.ts
|
|
45
|
+
import { counterServer } from '../../../server/counter'
|
|
46
|
+
|
|
47
|
+
export const GET = counterServer.fetch
|
|
48
|
+
export const POST = counterServer.fetch
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`GET` serves the subscription and history reads; `POST` appends writes.
|
|
52
|
+
Authentication and `authorize` live on [Routes](/server/routes). Bootstrap
|
|
53
|
+
is authorized as a `stream` operation with `bootstrap: true` before A2 reads
|
|
54
|
+
state.
|
|
55
|
+
|
|
56
|
+
## Bind React once
|
|
57
|
+
|
|
58
|
+
`createReact` binds the client's types into a Provider and hook. It creates
|
|
59
|
+
no separate connection pool.
|
|
60
|
+
|
|
61
|
+
```tsx app/counter/session.ts
|
|
62
|
+
'use client'
|
|
63
|
+
import { createClient } from 'experimental-a2/client'
|
|
64
|
+
import { createReact } from 'experimental-a2/react'
|
|
65
|
+
import { counter } from '../../model'
|
|
66
|
+
|
|
67
|
+
export const counterClient = createClient({
|
|
68
|
+
contract: counter,
|
|
69
|
+
api: '/api/counter',
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
export const { SessionProvider, useSession } = createReact({
|
|
73
|
+
client: counterClient,
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
```tsx app/counter/counter.tsx
|
|
78
|
+
'use client'
|
|
79
|
+
import { useSession } from './session'
|
|
80
|
+
|
|
81
|
+
export function Counter() {
|
|
82
|
+
const { state, connection, push } = useSession()
|
|
83
|
+
const error = 'error' in connection ? connection.error : null
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<>
|
|
87
|
+
<button onClick={() => void push({ type: 'added', payload: 1 })}>
|
|
88
|
+
Count: {state}
|
|
89
|
+
</button>
|
|
90
|
+
{error && <p role="alert">{error.message}</p>}
|
|
91
|
+
</>
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`useSession()` returns `state`, `hydrated`, `events`, `index`, `connection`,
|
|
97
|
+
`push`, `reconnect`, `loadHistory`, and `history`; presence contracts add
|
|
98
|
+
`presence` and `setPresence`. The [reference](/reference/client) covers each.
|
|
99
|
+
|
|
100
|
+
Without a [seed](#seed-the-first-render), `state` starts at
|
|
101
|
+
`reducer.initialState` (through the reducer's client projection when present),
|
|
102
|
+
which can look empty while an existing session loads. The subscription's first
|
|
103
|
+
frame is a snapshot that replaces it atomically. Later events fold on top,
|
|
104
|
+
including any appended while the snapshot was read. `connection` stays
|
|
105
|
+
`connecting` until then; bootstrap failures retry like any stream failure, and
|
|
106
|
+
a reconnect keeps the last state.
|
|
107
|
+
|
|
108
|
+
A push during bootstrap is sent right away, and its optimistic update appears
|
|
109
|
+
once the snapshot is applied. If it is still unacknowledged when the snapshot
|
|
110
|
+
arrives, it stays hidden until its ack (the snapshot may already contain it),
|
|
111
|
+
along with the pushes queued behind it, so the view never skips a write. The
|
|
112
|
+
push promise resolves on the server ack; `.confirmed` waits for stream
|
|
113
|
+
delivery, and the snapshot counts as delivery through its index.
|
|
114
|
+
|
|
115
|
+
## Without server rendering
|
|
116
|
+
|
|
117
|
+
A Provider needs only the session ID. It supplies scope and optional data;
|
|
118
|
+
mounted hooks own connection leases.
|
|
119
|
+
|
|
120
|
+
```tsx app/counter/page.tsx
|
|
121
|
+
import { Counter } from './counter'
|
|
122
|
+
import { SessionProvider } from './session'
|
|
123
|
+
|
|
124
|
+
export default function Page() {
|
|
125
|
+
return (
|
|
126
|
+
<SessionProvider sessionId="shared">
|
|
127
|
+
<Counter />
|
|
128
|
+
</SessionProvider>
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Or skip the Provider. The bound hook already knows its client:
|
|
134
|
+
|
|
135
|
+
```tsx app/counter/standalone.tsx
|
|
136
|
+
'use client'
|
|
137
|
+
import { useSession } from './session'
|
|
138
|
+
|
|
139
|
+
export function StandaloneCounter({ sessionId }: { sessionId: string }) {
|
|
140
|
+
const { state, push } = useSession({ sessionId })
|
|
141
|
+
return (
|
|
142
|
+
<button onClick={() => void push({ type: 'added', payload: 1 })}>
|
|
143
|
+
{state}
|
|
144
|
+
</button>
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Without the factory, the direct `useSession(client, sessionId, options?)`
|
|
150
|
+
export behaves the same.
|
|
151
|
+
|
|
152
|
+
## Seed the first render
|
|
153
|
+
|
|
154
|
+
Server rendering can skip the bootstrap read. Supply the state and its index
|
|
155
|
+
together:
|
|
156
|
+
|
|
157
|
+
```tsx app/seeded-counter/page.tsx
|
|
158
|
+
import { counterServer } from '../../server/counter'
|
|
159
|
+
import { Counter } from '../counter/counter'
|
|
160
|
+
import { SessionProvider } from '../counter/session'
|
|
161
|
+
|
|
162
|
+
export default async function Page() {
|
|
163
|
+
const initial = await counterServer.session('shared').state()
|
|
164
|
+
return (
|
|
165
|
+
<SessionProvider sessionId="shared" initial={initial}>
|
|
166
|
+
<Counter />
|
|
167
|
+
</SessionProvider>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Index 10 means the state includes events through 10, so the stream starts
|
|
173
|
+
after 10. Index 0 with the reducer's initial state requests replay from the
|
|
174
|
+
beginning; an index alone cannot describe a state. The standalone hook takes
|
|
175
|
+
the same `initial` option.
|
|
176
|
+
|
|
177
|
+
Seeds are render-local: one request's seed never changes another's state, and
|
|
178
|
+
hydration first renders the supplied snapshot even when the browser cache has
|
|
179
|
+
advanced.
|
|
180
|
+
|
|
181
|
+
`hydrated` turns `true` once a supplied snapshot or bootstrap initializes the
|
|
182
|
+
session (index 0 counts) and stays `true` through disconnections, reconnects,
|
|
183
|
+
and disabled readers while the session keeps its state. It is `false` while
|
|
184
|
+
only the reducer seed shows, including during a pending or rejected cold
|
|
185
|
+
preload. Use it to tell an empty queue from an unloaded one; it says nothing
|
|
186
|
+
about freshness, connectivity, or permission to write. Imperative code reads
|
|
187
|
+
`client.session(id).getSnapshot().hydrated`.
|
|
188
|
+
|
|
189
|
+
## Start a preload on the server
|
|
190
|
+
|
|
191
|
+
`initial` also accepts a Promise of a snapshot:
|
|
192
|
+
|
|
193
|
+
```tsx app/preloaded-counter/page.tsx
|
|
194
|
+
import { Suspense } from 'react'
|
|
195
|
+
import { counterServer } from '../../server/counter'
|
|
196
|
+
import { SessionProvider } from '../counter/session'
|
|
197
|
+
import { PreloadedCounter } from './counter'
|
|
198
|
+
|
|
199
|
+
export default function Page() {
|
|
200
|
+
const initial = counterServer.session('shared').state()
|
|
201
|
+
return (
|
|
202
|
+
<SessionProvider sessionId="shared" initial={initial}>
|
|
203
|
+
<h1>Counter</h1>
|
|
204
|
+
<Suspense fallback={<p>Loading counter…</p>}>
|
|
205
|
+
<PreloadedCounter />
|
|
206
|
+
</Suspense>
|
|
207
|
+
</SessionProvider>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
```tsx app/preloaded-counter/counter.tsx
|
|
213
|
+
'use client'
|
|
214
|
+
import { useSession } from '../counter/session'
|
|
215
|
+
|
|
216
|
+
export function PreloadedCounter() {
|
|
217
|
+
const { state, push } = useSession({ suspend: true })
|
|
218
|
+
return (
|
|
219
|
+
<button onClick={() => void push({ type: 'added', payload: 1 })}>
|
|
220
|
+
{state}
|
|
221
|
+
</button>
|
|
222
|
+
)
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
A cold session waits for the Promise before subscribing, showing
|
|
227
|
+
`reducer.initialState` meanwhile. A snapshot result skips the bootstrap read;
|
|
228
|
+
`null` or `undefined` falls back to it. A2 starts no extra request.
|
|
229
|
+
|
|
230
|
+
`suspend: true` reads the Promise with React `use()`, so the nearest Suspense
|
|
231
|
+
and Error boundaries handle pending and rejected states. Without it, the
|
|
232
|
+
server response renders the reducer seed; to render the loaded session there,
|
|
233
|
+
await the snapshot or suspend inside a boundary. The Provider and automatic
|
|
234
|
+
bootstrap never suspend. Create the Promise outside the consuming component so
|
|
235
|
+
its identity stays stable.
|
|
236
|
+
|
|
237
|
+
A rejected cold preload pauses startup and reports through `connection`. A2
|
|
238
|
+
cannot retry your Promise: supply a new Promise, a snapshot, or `null`. To
|
|
239
|
+
fall back to bootstrap instead, catch the failure in your Promise and return
|
|
240
|
+
`null`.
|
|
241
|
+
|
|
242
|
+
A warm session keeps its state and connection while a new Promise is pending;
|
|
243
|
+
a newer snapshot hydrates as usual, and a late rejection does not disrupt it.
|
|
244
|
+
Replacing the source or calling the session's `close()` discards unapplied
|
|
245
|
+
results. Unmounting releases the hook's lease but never cancels your fetch,
|
|
246
|
+
and repeating the same Promise in a cached session does not restart cancelled
|
|
247
|
+
work.
|
|
248
|
+
|
|
249
|
+
## Connections and activation
|
|
250
|
+
|
|
251
|
+
Hooks on the same client instance and session ID share one runtime,
|
|
252
|
+
connection, state, and optimistic queue, under any Providers. Another client
|
|
253
|
+
instance is isolated, even with the same URL. A reconnect resumes after the
|
|
254
|
+
last delivered event, without another snapshot.
|
|
255
|
+
|
|
256
|
+
`useSession({ enabled: false })` observes cached or initial data without
|
|
257
|
+
taking a connection lease (default `true`). Disabling or unmounting a hook
|
|
258
|
+
releases only its own lease. Explicit pushes still acquire a connection, so
|
|
259
|
+
disabling is not an authorization boundary.
|
|
260
|
+
|
|
261
|
+
The bound hook accepts `sessionId`, `client`, `participant`, `initial`,
|
|
262
|
+
`enabled`, and `suspend`. It inherits the Provider's initial data and
|
|
263
|
+
participant only when the resolved client and session ID match the
|
|
264
|
+
Provider's.
|
|
265
|
+
|
|
266
|
+
## Events and history
|
|
267
|
+
|
|
268
|
+
`events` is the raw feed: supplied `initialEvents`, loaded history, live
|
|
269
|
+
events, and pending optimistic events. A snapshot adds no historical rows, so
|
|
270
|
+
without `initialEvents` the feed starts after the snapshot's index.
|
|
271
|
+
|
|
272
|
+
`loadHistory({ before, limit })` loads older entries into `events`, by default
|
|
273
|
+
up to 50 before the oldest loaded one, without touching state or the live
|
|
274
|
+
cursor. `history` reports loading, the oldest loaded index, and whether the
|
|
275
|
+
beginning is loaded. History reads need the HTTP
|
|
276
|
+
[transport](/client/transports).
|
|
277
|
+
|
|
278
|
+
`events` lives in memory, unpaginated, so long-lived sessions accumulate
|
|
279
|
+
events. Snapshots bound replay work, not the size of state or retained events:
|
|
280
|
+
choose reducer contents that fit.
|
|
281
|
+
|
|
282
|
+
## Client lifetime
|
|
283
|
+
|
|
284
|
+
Define the client once in a module, as `session.ts` does, so components
|
|
285
|
+
share its sessions. `createClient({ gcTime })` keeps an idle session cached,
|
|
286
|
+
five minutes by default; active connections, listeners, and pending work keep
|
|
287
|
+
it alive.
|
|
288
|
+
|
|
289
|
+
The cache is in memory, so a reload discards it. A2 ships no IndexedDB adapter
|
|
290
|
+
(`experimental-a2/cache-indexeddb` has no implementation) and no persistent
|
|
291
|
+
offline write queue. Pushes retry a bounded number of times and roll back when
|
|
292
|
+
they fail. To persist state yourself, restore a state/index pair through
|
|
293
|
+
`initial`, only for the same session and reducer version, and keep
|
|
294
|
+
authentication and retention policy in your application.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Transports
|
|
3
|
+
description: "One client, three wires: one HTTP route, split routes for per-verb timeouts, or one WebSocket."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## One wire seam, same client
|
|
7
|
+
|
|
8
|
+
`api` picks the wire:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
// in a 'use client' session module:
|
|
12
|
+
api: '/api/order-events'
|
|
13
|
+
api: (sessionId) => `/api/orders/${encodeURIComponent(sessionId)}/events`
|
|
14
|
+
api: { type: 'http', push: '/api/order-push', stream: '/api/order-stream' }
|
|
15
|
+
api: { type: 'ws', url: '/api/order-events' }
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
- **One route** is the default and the recommendation: a fixed URL or a
|
|
19
|
+
session URL function, serving GET (SSE stream and history) and POST (push).
|
|
20
|
+
- **`http` split** serves the two verbs from separate routes, for platforms
|
|
21
|
+
whose duration limits differ per verb.
|
|
22
|
+
- **`ws`** carries streams, pushes, and presence over one socket per session.
|
|
23
|
+
A socket is one connection in both directions, so it has no split form.
|
|
24
|
+
|
|
25
|
+
The server side of each lives on [Routes](/server/routes). Everything above
|
|
26
|
+
the wire is shared: `useSession`, push acks and `confirmed`, presence
|
|
27
|
+
cadence, connection status, stall detection, and reconnection. SSE and
|
|
28
|
+
WebSocket resume from the same durable frontier and treat presence as
|
|
29
|
+
ephemeral, repaintable state.
|
|
30
|
+
|
|
31
|
+
## Session URL functions
|
|
32
|
+
|
|
33
|
+
Every session-scoped URL may be a `(sessionId) => string` function. A2
|
|
34
|
+
resolves it for each request or reconnect, then adds its standard query
|
|
35
|
+
parameters. Encode the ID when placing it in a path segment.
|
|
36
|
+
|
|
37
|
+
The function chooses a route, not authority. If a path or connection token is
|
|
38
|
+
scoped to one session, [`authorize`](/server/routes#the-http-route) must
|
|
39
|
+
compare that scope to `operation.sessionId`. Bind URL tokens to the same
|
|
40
|
+
session and keep them short-lived, because URLs can appear in infrastructure
|
|
41
|
+
logs.
|
|
42
|
+
|
|
43
|
+
## WebSocket
|
|
44
|
+
|
|
45
|
+
```ts app/orders/[orderId]/session.ts
|
|
46
|
+
'use client'
|
|
47
|
+
import { createClient } from 'experimental-a2/client'
|
|
48
|
+
import { createReact } from 'experimental-a2/react'
|
|
49
|
+
import { orders } from '@/contracts'
|
|
50
|
+
|
|
51
|
+
export const ordersClient = createClient({
|
|
52
|
+
contract: orders,
|
|
53
|
+
api: { type: 'ws', url: '/api/order-events' },
|
|
54
|
+
})
|
|
55
|
+
export const { SessionProvider, useSession } = createReact({
|
|
56
|
+
client: ordersClient,
|
|
57
|
+
})
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
To share one socket across the client's sessions, set `multiplex: true` in
|
|
61
|
+
`api` and `multiplexWebSocket: true` on the
|
|
62
|
+
[route](/server/routes#the-websocket-route). A multiplexed URL is one plain
|
|
63
|
+
string, since the connection carries more than one session.
|
|
64
|
+
|
|
65
|
+
`ws` has no history lane. [`loadHistory`](/client/react#events-and-history)
|
|
66
|
+
is a bounded cold read over plain HTTP; on a `ws` api it throws a
|
|
67
|
+
`TypeError`.
|
|
68
|
+
|
|
69
|
+
## Lifecycle
|
|
70
|
+
|
|
71
|
+
When a socket closes, the client reconnects at its current frontier per its
|
|
72
|
+
reconnection policy, receives a fresh presence snapshot, and re-sends its own
|
|
73
|
+
latest presence. A multiplexed socket re-subscribes every active session at
|
|
74
|
+
its own frontier. Pushes keep their client-generated ids, so retries stay
|
|
75
|
+
idempotent.
|
|
76
|
+
|
|
77
|
+
The `createClient({ reconnect })` policy returns a delay, or `false` to
|
|
78
|
+
pause. The default reconnects with backoff indefinitely. A clean close also
|
|
79
|
+
runs the policy, with `error: null`. A paused session keeps its state and
|
|
80
|
+
leases; `session.reconnect()` resumes it.
|
|
81
|
+
|
|
82
|
+
Upgrade authorization stays in the
|
|
83
|
+
[HTTP route](/server/routes#the-websocket-route). Browser WebSockets hide a
|
|
84
|
+
rejected upgrade's status and body, so it surfaces as an opaque
|
|
85
|
+
`CONNECTION_FAILED` error. Structured subscription errors arrive once a
|
|
86
|
+
multiplexed socket is open.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Presence
|
|
3
|
+
description: "Ephemeral participant state on a durable session: cursors, typing, live progress. Presence repaints; the log replays."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Two planes, one session
|
|
7
|
+
|
|
8
|
+
Events are facts: stored, folded by reducers, replayed on demand. Presence is
|
|
9
|
+
not. "Her cursor is at (312, 88)" is useful for about two seconds, has no
|
|
10
|
+
replay value, and a lost update is repainted by the next one. The test:
|
|
11
|
+
|
|
12
|
+
> Will anyone, human or reducer, care about this datum after the moment
|
|
13
|
+
> passes? Yes: event, in the log. No: presence.
|
|
14
|
+
|
|
15
|
+
Messages, document updates, cancellations: events. Cursors, selections,
|
|
16
|
+
typing, "who's online", a handler's live progress: presence.
|
|
17
|
+
|
|
18
|
+
Presence rides the session you already have: same contract, route, auth, and
|
|
19
|
+
stream. A2 replicates each participant's latest field values to every
|
|
20
|
+
subscriber and stores none of them in the log. Presence never appears in
|
|
21
|
+
`history()`, `state()`, or a reducer's input type; never arms the scheduler,
|
|
22
|
+
takes a claim, retries, or blocks an append; and never enters the browser
|
|
23
|
+
cache.
|
|
24
|
+
|
|
25
|
+
> **Presence repaints; the log replays.** Anything that decides (a fold,
|
|
26
|
+
> a retry, an abort match) reads the log. Presence only hurries pixels.
|
|
27
|
+
|
|
28
|
+
## Declare it in the contract
|
|
29
|
+
|
|
30
|
+
`presence` is a second vocabulary next to `events`, one schema per field of a
|
|
31
|
+
participant's state:
|
|
32
|
+
|
|
33
|
+
```ts contracts.ts
|
|
34
|
+
import { z } from 'zod'
|
|
35
|
+
import * as a2 from 'experimental-a2'
|
|
36
|
+
|
|
37
|
+
export const canvas = a2.contract({
|
|
38
|
+
name: 'canvas',
|
|
39
|
+
events: {
|
|
40
|
+
'shape.created': z.object({
|
|
41
|
+
id: z.string(),
|
|
42
|
+
kind: z.enum(['rect', 'ellipse']),
|
|
43
|
+
x: z.number(),
|
|
44
|
+
y: z.number(),
|
|
45
|
+
}),
|
|
46
|
+
'shape.moved': z.object({ id: z.string(), x: z.number(), y: z.number() }),
|
|
47
|
+
},
|
|
48
|
+
reducer: { name: 'scene', initialState: {}, fold: (state) => state },
|
|
49
|
+
presence: {
|
|
50
|
+
cursor: z.object({ x: z.number(), y: z.number() }),
|
|
51
|
+
dragging: z.object({ shapeId: z.string(), x: z.number(), y: z.number() }),
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The field is the unit of replication: last writer wins per participant per
|
|
57
|
+
field, so setting `cursor` leaves `dragging` alone. Fields are absent until
|
|
58
|
+
set. They clear when set to `null` (pointer leaves the canvas, blur, unmount)
|
|
59
|
+
or when their TTL lapses, so a closed tab disappears by silence, with no
|
|
60
|
+
explicit leave. The TTL is 60 seconds by default; set
|
|
61
|
+
`createServer({ presence: { ttlMs } })` to change it. Expiry counts on the
|
|
62
|
+
backend's clock, not the sender's stamp.
|
|
63
|
+
|
|
64
|
+
## The route
|
|
65
|
+
|
|
66
|
+
```ts app/api/canvas-events/route.ts
|
|
67
|
+
import { canvasServer } from '@/server/canvas'
|
|
68
|
+
|
|
69
|
+
export const GET = canvasServer.fetch
|
|
70
|
+
export const POST = canvasServer.fetch
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
This is the usual [`server.fetch` route](/server/routes). A contract with
|
|
74
|
+
presence fields streams presence patches with events, starting with a snapshot
|
|
75
|
+
of the current map, and its push body accepts an optional `presence` sibling
|
|
76
|
+
to `events`, forwarded to `setPresence`. `authorize` sees the complete
|
|
77
|
+
presence patch, including its caller-supplied participant id, over HTTP and
|
|
78
|
+
WebSocket.
|
|
79
|
+
|
|
80
|
+
`setPresence` validates each field against the contract, then broadcasts: no
|
|
81
|
+
append, no dispatch, no log row. A bad field throws `INVALID_PAYLOAD` and an
|
|
82
|
+
unknown one throws `UNKNOWN_PRESENCE_FIELD`, broadcasting nothing. A
|
|
83
|
+
presence-only push acks `[]`.
|
|
84
|
+
|
|
85
|
+
## The browser
|
|
86
|
+
|
|
87
|
+
Presence needs a `participant` id, yours to mint: a user id, a tab nonce. Set
|
|
88
|
+
it on `createClient({ participant })` when the app knows it at module scope,
|
|
89
|
+
or on the Provider, whose `participant` prop overrides the client's. The
|
|
90
|
+
session module is the usual [pair](/client/react#bind-react-once):
|
|
91
|
+
|
|
92
|
+
```ts app/canvas/[canvasId]/session.ts
|
|
93
|
+
'use client'
|
|
94
|
+
import { createClient } from 'experimental-a2/client'
|
|
95
|
+
import { createReact } from 'experimental-a2/react'
|
|
96
|
+
import { canvas } from '@/contracts'
|
|
97
|
+
|
|
98
|
+
export const canvasClient = createClient({
|
|
99
|
+
contract: canvas,
|
|
100
|
+
api: '/api/canvas-events',
|
|
101
|
+
})
|
|
102
|
+
export const { SessionProvider, useSession } = createReact({
|
|
103
|
+
client: canvasClient,
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```tsx app/canvas/[canvasId]/canvas-client.tsx
|
|
108
|
+
'use client'
|
|
109
|
+
import { useSession } from './session'
|
|
110
|
+
|
|
111
|
+
export function CanvasClient() {
|
|
112
|
+
const { presence, setPresence } = useSession()
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
onPointerMove={(e) => setPresence({ cursor: { x: e.clientX, y: e.clientY } })}
|
|
117
|
+
>
|
|
118
|
+
{Object.entries(presence).map(([participant, fields]) =>
|
|
119
|
+
// render-time expiry: a cursor silent for a few seconds is gone
|
|
120
|
+
fields.cursor && Date.now() - fields.cursor.at.getTime() < 5_000 ? (
|
|
121
|
+
<Cursor key={participant} {...fields.cursor.value} />
|
|
122
|
+
) : null,
|
|
123
|
+
)}
|
|
124
|
+
</div>
|
|
125
|
+
)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// your cursor sprite:
|
|
129
|
+
// function Cursor({ x, y }: { x: number; y: number }) { ... }
|
|
130
|
+
const Cursor = (props: { x: number; y: number }) => null
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- **`setPresence(values)`** is fire-and-forget. It validates locally, merges
|
|
134
|
+
into the local map immediately, and coalesces on the wire: the first set
|
|
135
|
+
sends immediately, and later values within the interval merge per field
|
|
136
|
+
(latest wins, `null` included) into one trailing send. A 60Hz pointer
|
|
137
|
+
becomes about fifteen sends per second. No ack, no `confirmed`, no retry,
|
|
138
|
+
no rollback: nothing was ever true.
|
|
139
|
+
- **`presence`** is the replicated map,
|
|
140
|
+
`Record<participantId, { [field]: { value, seen, at } }>`, including your
|
|
141
|
+
own participant. `at` is the sender's stamp for when the value was set and
|
|
142
|
+
the last-writer-wins comparator. The client stamps sends monotonically, so
|
|
143
|
+
overlapping sends never invert. How long to paint a value is view logic,
|
|
144
|
+
decided at render time.
|
|
145
|
+
|
|
146
|
+
Both members are typed from the contract's `presence` map, with no type
|
|
147
|
+
arguments. On a contract without `presence` they do not exist, so reaching
|
|
148
|
+
for them is a compile error.
|
|
149
|
+
|
|
150
|
+
## Reconciling the planes: `seen`
|
|
151
|
+
|
|
152
|
+
Every presence value carries `seen`, the sender's stream frontier when it was
|
|
153
|
+
set, stamped automatically. It orders the two planes without wall clocks.
|
|
154
|
+
|
|
155
|
+
The classic race: a participant drags a shape (presence), drops it (event),
|
|
156
|
+
and a stale `dragging` patch arrives after the durable `shape.moved`. Without
|
|
157
|
+
a shared order, the shape flickers backward. With `seen`, superseding is one
|
|
158
|
+
render-time rule:
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
// in render code: a ghost is dead once the log has moved past it.
|
|
162
|
+
// show fields.dragging only if
|
|
163
|
+
// fields.dragging.seen >= state.shapes[fields.dragging.value.shapeId].movedAt
|
|
164
|
+
// where movedAt is the index your reducer recorded for the last shape.moved
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
The durable event at index 214 beats any presence stamped 213 or lower. A2
|
|
168
|
+
carries the stamp; the comparison is yours, in view code.
|
|
169
|
+
|
|
170
|
+
## Handlers broadcast too
|
|
171
|
+
|
|
172
|
+
`ctx.session.setPresence` is the same operation, so a handler can publish live
|
|
173
|
+
progress without writing non-facts to the log:
|
|
174
|
+
|
|
175
|
+
```ts server/canvas.ts
|
|
176
|
+
import { createServer } from 'experimental-a2/server'
|
|
177
|
+
import { canvas } from '@/contracts'
|
|
178
|
+
|
|
179
|
+
export const canvasServer = createServer({
|
|
180
|
+
contract: canvas,
|
|
181
|
+
handlers: {
|
|
182
|
+
'shape.created': async (ctx) => {
|
|
183
|
+
// long work; broadcast progress as it goes:
|
|
184
|
+
// await ctx.session.setPresence({
|
|
185
|
+
// participant: 'system:thumbnails',
|
|
186
|
+
// values: { progress: { step: 2, of: 5 } },
|
|
187
|
+
// })
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The participant id is the handler's choice; a `system:` prefix keeps robots
|
|
194
|
+
apart from humans in the map. When the work completes, append the outcome as
|
|
195
|
+
an event: the progress was never a fact, the result is.
|
|
196
|
+
|
|
197
|
+
## Backends
|
|
198
|
+
|
|
199
|
+
Declaring `presence` requires a store with the presence capability;
|
|
200
|
+
otherwise `createServer` throws `PRESENCE_NOT_SUPPORTED`. Every built-in
|
|
201
|
+
store has it, at two tiers:
|
|
202
|
+
|
|
203
|
+
| Backend | Delivery |
|
|
204
|
+
| --- | --- |
|
|
205
|
+
| `store-redis` | push: TTL'd hash plus pub/sub patches |
|
|
206
|
+
| `store-redis-http` | push over HTTP subscriptions; adaptive polling when unsupported |
|
|
207
|
+
| `store-memory` | in-process, immediate |
|
|
208
|
+
| `store-postgres` | native notifications with `listenConnectionString`; adaptive polling by default |
|
|
209
|
+
| `store-sqlite` | degraded: bounded TTL'd rows, read on the live feed's poll cadence |
|
|
210
|
+
|
|
211
|
+
Degraded means later, not lost while watched. Presence-only traffic re-reads
|
|
212
|
+
adaptively, from 25ms backing off to two seconds while unchanged; new patches
|
|
213
|
+
or events restore the fast cadence. An idle remote viewer may wait up to two
|
|
214
|
+
seconds plus query latency for the first change. Cursors want the push tier;
|
|
215
|
+
typing indicators and progress read fine on either.
|
|
216
|
+
|
|
217
|
+
Redis Pub/Sub does not broadcast TTL expiry: silent departures keep their
|
|
218
|
+
last values in connected maps until a snapshot reconciles them, so expire at
|
|
219
|
+
render time as above. Native Postgres subscriptions reconcile missing or
|
|
220
|
+
expired fields on their ten-second safety reads.
|
|
221
|
+
|
|
222
|
+
Presence rows are bounded per session and participant, so they are not log
|
|
223
|
+
growth in disguise. On metered backends every patch is still a network
|
|
224
|
+
operation.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export default { order: 4 }
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Events
|
|
3
|
+
description: "A contract names every event a session can hold, with a schema for each payload."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Declare the events
|
|
7
|
+
|
|
8
|
+
```ts contracts.ts
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import * as a2 from 'experimental-a2'
|
|
11
|
+
|
|
12
|
+
export const orders = a2.contract({
|
|
13
|
+
name: 'orders',
|
|
14
|
+
events: {
|
|
15
|
+
created: z.object({ shopId: z.string(), items: z.array(z.string()) }),
|
|
16
|
+
'shop.notified': z.object({}),
|
|
17
|
+
'shop.started': z.object({}),
|
|
18
|
+
'driver.notified': z.object({ driverId: z.string() }),
|
|
19
|
+
expired: z.object({}),
|
|
20
|
+
},
|
|
21
|
+
})
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
A **contract** defines what can happen. A [session](/contract/sessions) is one
|
|
25
|
+
instance of it.
|
|
26
|
+
|
|
27
|
+
`name` identifies the contract everywhere: in storage, in queue messages, in
|
|
28
|
+
logs. It keeps an app with several contracts unambiguous.
|
|
29
|
+
|
|
30
|
+
`events` is the full vocabulary. Each key is an event name; each value is a
|
|
31
|
+
schema for its payload. These docs use Zod, but any
|
|
32
|
+
[Standard Schema](https://standardschema.dev) library works: Valibot, ArkType,
|
|
33
|
+
your own.
|
|
34
|
+
|
|
35
|
+
The contract is a plain, importable, **isomorphic** value. The server
|
|
36
|
+
implements it with [handlers](/server/handlers), [reducers](/contract/state)
|
|
37
|
+
derive the current view from it, and the browser types its pushes off it.
|
|
38
|
+
|
|
39
|
+
## Validation
|
|
40
|
+
|
|
41
|
+
Every append is validated against the contract. A bad payload throws
|
|
42
|
+
`INVALID_PAYLOAD` before anything is written; an unknown type throws
|
|
43
|
+
`UNKNOWN_EVENT_TYPE`. The log stores the validated output, with defaults
|
|
44
|
+
applied and transforms run.
|
|
45
|
+
|
|
46
|
+
## Presence
|
|
47
|
+
|
|
48
|
+
A contract can also declare `presence`: ephemeral participant state (cursors,
|
|
49
|
+
typing, live progress) that is replicated but never stored in the log. See
|
|
50
|
+
[Presence](/client/presence).
|
|
51
|
+
|
|
52
|
+
:::tip
|
|
53
|
+
Events are facts, so name them in past tense: `created`, `expired`,
|
|
54
|
+
`shop.notified`. A name that reads like a command (`notifyShop`) describes
|
|
55
|
+
what a handler should do, not what happened. Dots are a naming convention, not
|
|
56
|
+
a namespace.
|
|
57
|
+
:::
|