experimental-a2 0.0.0
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 +128 -0
- package/dist/ai-server.browser.d.ts +1 -0
- package/dist/ai-server.browser.js +4 -0
- package/dist/ai-server.d.ts +65 -0
- package/dist/ai-server.js +494 -0
- package/dist/ai.d.ts +282 -0
- package/dist/ai.js +922 -0
- package/dist/cache-indexeddb.d.ts +1 -0
- package/dist/cache-indexeddb.js +0 -0
- package/dist/client.d.ts +90 -0
- package/dist/client.js +410 -0
- package/dist/contract-B0kAXoaL.js +60 -0
- package/dist/contract-DL8btVd9.d.ts +161 -0
- package/dist/devtools-server.browser.d.ts +1 -0
- package/dist/devtools-server.browser.js +4 -0
- package/dist/devtools-server.d.ts +22 -0
- package/dist/devtools-server.js +1087 -0
- package/dist/errors-BJRMd-h6.js +23 -0
- package/dist/errors-xL_JTXsY.d.ts +20 -0
- package/dist/http.d.ts +44 -0
- package/dist/http.js +119 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +3 -0
- package/dist/inspection-E7qbD0Xj.js +10 -0
- package/dist/internal-Dm8Ejnud.js +36 -0
- package/dist/log-Dg1I8NRr.d.ts +245 -0
- package/dist/log-memory.d.ts +11 -0
- package/dist/log-memory.js +345 -0
- package/dist/log-polling-RO7kclzR.js +83 -0
- package/dist/log-postgres.d.ts +40 -0
- package/dist/log-postgres.js +628 -0
- package/dist/log-redis.d.ts +31 -0
- package/dist/log-redis.js +711 -0
- package/dist/log-sqlite.d.ts +17 -0
- package/dist/log-sqlite.js +450 -0
- package/dist/log-yJbXUf72.js +5 -0
- package/dist/otel.d.ts +12 -0
- package/dist/otel.js +41 -0
- package/dist/react.d.ts +54 -0
- package/dist/react.js +85 -0
- package/dist/recovery-vercel.d.ts +60 -0
- package/dist/recovery-vercel.js +120 -0
- package/dist/retryable-lazy-DZWmHpii.js +19 -0
- package/dist/server-DYsnKTTy.js +780 -0
- package/dist/server.browser.d.ts +1 -0
- package/dist/server.browser.js +11 -0
- package/dist/server.d.ts +136 -0
- package/dist/server.js +2 -0
- package/dist/telemetry-C78al20p.d.ts +32 -0
- package/dist/validate-XKT4FSNn.js +28 -0
- package/dist/wire-2QpU1EtJ.js +62 -0
- package/docs/01-quickstart.mdx +214 -0
- package/docs/concepts/01-contracts.mdx +138 -0
- package/docs/concepts/02-handlers.mdx +146 -0
- package/docs/concepts/03-durability.mdx +230 -0
- package/docs/concepts/04-state.mdx +133 -0
- package/docs/guides/01-timers.mdx +85 -0
- package/docs/guides/02-cancellation.mdx +107 -0
- package/docs/guides/03-react.mdx +234 -0
- package/docs/guides/04-local-first.mdx +88 -0
- package/docs/guides/05-production.mdx +179 -0
- package/docs/guides/06-ai-agents.mdx +659 -0
- package/docs/guides/07-devtools.mdx +101 -0
- package/docs/guides/08-application-data.mdx +114 -0
- package/docs/index.mdx +282 -0
- package/docs/reference/01-api.mdx +637 -0
- package/docs/reference/02-errors.mdx +77 -0
- package/package.json +111 -0
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Cancellation
|
|
3
|
+
description: There is no abort() API. Cancelling is appending, and the running handler finds out through a signal.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Cancelling is appending
|
|
7
|
+
|
|
8
|
+
A user presses stop on a streaming AI response. In A2 that's not a special
|
|
9
|
+
operation with its own machinery. It's an event:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
// in the browser: push from useSession (see Live UI)
|
|
13
|
+
push({ type: 'cancelled', payload: { lastSeenIndex: index } })
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Same route, same auth, same log as every other event. Two things then
|
|
17
|
+
happen, at two different speeds.
|
|
18
|
+
|
|
19
|
+
## The running handler stops
|
|
20
|
+
|
|
21
|
+
A handler doing abortable work opts in by naming the events that should
|
|
22
|
+
interrupt it:
|
|
23
|
+
|
|
24
|
+
```ts server/chat.ts
|
|
25
|
+
import { createServer } from 'experimental-a2/server'
|
|
26
|
+
import { chat } from '@/contracts'
|
|
27
|
+
|
|
28
|
+
export const chatServer = createServer({
|
|
29
|
+
contract: chat,
|
|
30
|
+
handlers: {
|
|
31
|
+
generate: {
|
|
32
|
+
abortOn: ['cancelled'],
|
|
33
|
+
handler: async (ctx) => {
|
|
34
|
+
// your abortable work; hand it ctx.signal:
|
|
35
|
+
// await streamText({ signal: ctx.signal, ... })
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
While the handler runs, A2 watches the session's log. The moment a
|
|
43
|
+
`cancelled` event lands (or one already landed before the handler
|
|
44
|
+
started), `ctx.signal` fires. Handlers without `abortOn` pay nothing.
|
|
45
|
+
|
|
46
|
+
One rule: an aborted handler should catch and return normally. Throwing
|
|
47
|
+
means "retry me", exactly the wrong response to someone pressing stop.
|
|
48
|
+
|
|
49
|
+
When one session multiplexes work (several generations over its
|
|
50
|
+
lifetime), match by *instance*, not just type, with a predicate:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// server/chat.ts, the generate entry, targeted by instance:
|
|
54
|
+
generate: {
|
|
55
|
+
abortOn: {
|
|
56
|
+
// only the cancel that names this run fires the signal
|
|
57
|
+
cancelled: (event, trigger) => event.payload.of === trigger.id,
|
|
58
|
+
},
|
|
59
|
+
handler: async (ctx) => {
|
|
60
|
+
// ...
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
The pushing side already knows the id it's cancelling; it's in the
|
|
66
|
+
`events` feed the hook exposes.
|
|
67
|
+
|
|
68
|
+
Because sessions process serially, the `cancelled` event's own handler runs
|
|
69
|
+
*after* the interrupted one settles. That makes it the natural place for
|
|
70
|
+
cleanup. The signal is the preemption channel; the event is the durable
|
|
71
|
+
record.
|
|
72
|
+
|
|
73
|
+
## The view cuts
|
|
74
|
+
|
|
75
|
+
Chunks in flight during the cancel round-trip still land in the log after
|
|
76
|
+
`cancelled`. That's correct. They're true history; the model really did
|
|
77
|
+
generate them. Keeping them out of what the user sees is the reducer's job:
|
|
78
|
+
|
|
79
|
+
```ts reducer.ts
|
|
80
|
+
case 'chunk':
|
|
81
|
+
return state.cancelled
|
|
82
|
+
? state
|
|
83
|
+
: { ...state, chunks: [...state.chunks, { index: event.index, delta: event.payload.delta }] }
|
|
84
|
+
case 'cancelled':
|
|
85
|
+
return {
|
|
86
|
+
...state,
|
|
87
|
+
cancelled: true,
|
|
88
|
+
chunks: state.chunks.filter((c) => c.index <= event.payload.lastSeenIndex),
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`lastSeenIndex` is the client's stream frontier, a number it already
|
|
93
|
+
tracks to resume subscriptions. Cutting on it makes the trim exact and
|
|
94
|
+
clock-free: chunks the log ordered before `cancelled` but the user never
|
|
95
|
+
saw are removed retroactively, so the optimistic view and the
|
|
96
|
+
authoritative refold agree on the same result. No wall clocks, no skew.
|
|
97
|
+
|
|
98
|
+
And because the client pushes `cancelled`
|
|
99
|
+
[optimistically](/guides/react#the-client-component), its own fold cuts
|
|
100
|
+
instantly. Stragglers still arriving over the stream hit `state.cancelled`
|
|
101
|
+
and vanish from the view.
|
|
102
|
+
|
|
103
|
+
:::tip
|
|
104
|
+
Keep chunks at per-chunk granularity in state until the session reaches a
|
|
105
|
+
terminal event, then compact them into one string. That's reducer design,
|
|
106
|
+
fully yours.
|
|
107
|
+
:::
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Live UI with React
|
|
3
|
+
description: "Synchronize one session from server to browser: render its state, stream new events, and push writes optimistically through one reducer."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## The model
|
|
7
|
+
|
|
8
|
+
A session can be read live from the browser. The server renders the first
|
|
9
|
+
paint, a stream keeps it fresh, and writes apply instantly. One reducer
|
|
10
|
+
produces every one of those views; server and client fold the same log
|
|
11
|
+
with the same function, so they can't disagree.
|
|
12
|
+
|
|
13
|
+
That's the trick, really. The client doesn't sync state. It syncs events,
|
|
14
|
+
and folds them locally. State sync is a hard problem. An append-only log
|
|
15
|
+
with positions is not.
|
|
16
|
+
|
|
17
|
+
This is session sync. The client follows one known session id; it does not
|
|
18
|
+
subscribe to database tables or queries across many sessions. Keep those
|
|
19
|
+
collection views in your [application database](/guides/application-data).
|
|
20
|
+
|
|
21
|
+
The pieces: two route handlers (read and write), a factory that binds your
|
|
22
|
+
reducer, and the provider + hook it returns.
|
|
23
|
+
|
|
24
|
+
## The API route
|
|
25
|
+
|
|
26
|
+
One file exposes a session over HTTP: `GET` streams events, `POST` appends.
|
|
27
|
+
These are ordinary route handlers; put whatever checks you like in front.
|
|
28
|
+
|
|
29
|
+
```ts app/api/order-events/route.ts
|
|
30
|
+
import { A2Error } from 'experimental-a2'
|
|
31
|
+
import { ordersServer } from '@/server/orders'
|
|
32
|
+
import { errorResponse, parsePushBody, sseResponse } from 'experimental-a2/http'
|
|
33
|
+
|
|
34
|
+
export async function GET(req: Request) {
|
|
35
|
+
const { searchParams } = new URL(req.url)
|
|
36
|
+
const sessionId = searchParams.get('sessionId')
|
|
37
|
+
if (!sessionId) {
|
|
38
|
+
return errorResponse(new A2Error('INVALID_PAYLOAD', 'missing sessionId'))
|
|
39
|
+
}
|
|
40
|
+
const startAt = Number(searchParams.get('index')) || 0
|
|
41
|
+
|
|
42
|
+
// here's where you'd do auth, or any other checks
|
|
43
|
+
|
|
44
|
+
return sseResponse(ordersServer.session(sessionId).stream({ startAt }))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function POST(req: Request) {
|
|
48
|
+
try {
|
|
49
|
+
const { sessionId, events } = await parsePushBody(req)
|
|
50
|
+
|
|
51
|
+
// here's where you'd do auth, or any other checks
|
|
52
|
+
|
|
53
|
+
const result = await ordersServer.session(sessionId).append(...events)
|
|
54
|
+
return Response.json(result)
|
|
55
|
+
} catch (err) {
|
|
56
|
+
return errorResponse(err)
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`GET` is the read path. `stream({ startAt })` is a live `AsyncIterable` of
|
|
62
|
+
one session's events starting after a given position, and `sseResponse`
|
|
63
|
+
pipes it into a server-sent events response. Clients pass `index` to resume
|
|
64
|
+
exactly where they left off, after a first paint or a dropped
|
|
65
|
+
connection.
|
|
66
|
+
|
|
67
|
+
`POST` is the write path. `parsePushBody` validates the envelope (and
|
|
68
|
+
throws `INVALID_PAYLOAD` on garbage), then `append` does the rest. The
|
|
69
|
+
response is the appended events: an ack, not a stream. `errorResponse`
|
|
70
|
+
serializes any thrown [`A2Error`](/reference/errors#over-the-wire) so the
|
|
71
|
+
client can branch on the same codes.
|
|
72
|
+
|
|
73
|
+
## The session module
|
|
74
|
+
|
|
75
|
+
Bind your reducer once, in a `'use client'` file, and export the pair:
|
|
76
|
+
|
|
77
|
+
```tsx app/orders/[orderId]/session.ts
|
|
78
|
+
'use client'
|
|
79
|
+
import { createClient } from 'experimental-a2/client'
|
|
80
|
+
import { createReact } from 'experimental-a2/react'
|
|
81
|
+
import { ordersReducer } from '@/reducer'
|
|
82
|
+
|
|
83
|
+
export const ordersClient = createClient({
|
|
84
|
+
reducer: ordersReducer,
|
|
85
|
+
api: '/api/order-events',
|
|
86
|
+
})
|
|
87
|
+
export const { SessionProvider, useSession } = createReact({ client: ordersClient })
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
This is the whole client/server seam. `createReact` is a factory (like
|
|
91
|
+
`createContext`): everything it returns is typed by the reducer value;
|
|
92
|
+
the state from its fold, the pushable events from its vocabulary. No type
|
|
93
|
+
arguments anywhere, and nothing in this file touches the server module.
|
|
94
|
+
The client also gives every session a stable in-memory identity. Calling
|
|
95
|
+
`ordersClient.session(orderId)` before navigation and mounting the provider
|
|
96
|
+
after navigation reaches the same optimistic state, with no handoff store.
|
|
97
|
+
|
|
98
|
+
## The server component
|
|
99
|
+
|
|
100
|
+
The first paint costs no client JavaScript: fold on the server, pass the
|
|
101
|
+
result down.
|
|
102
|
+
|
|
103
|
+
```tsx app/orders/[orderId]/page.tsx
|
|
104
|
+
import { ordersServer } from '@/server/orders'
|
|
105
|
+
import { ordersReducer } from '@/reducer'
|
|
106
|
+
import { SessionProvider } from './session'
|
|
107
|
+
import { OrderClient } from './order-client'
|
|
108
|
+
|
|
109
|
+
export default async function OrderPage({
|
|
110
|
+
params,
|
|
111
|
+
}: {
|
|
112
|
+
params: Promise<{ orderId: string }>
|
|
113
|
+
}) {
|
|
114
|
+
const { orderId } = await params
|
|
115
|
+
const session = ordersServer.session(orderId)
|
|
116
|
+
const { state, index } = await session.state(ordersReducer)
|
|
117
|
+
const initialEvents = (await session.history()).filter(
|
|
118
|
+
(event) => event.index <= index,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<SessionProvider
|
|
123
|
+
sessionId={orderId}
|
|
124
|
+
initialState={state}
|
|
125
|
+
initialIndex={index}
|
|
126
|
+
initialEvents={initialEvents}
|
|
127
|
+
>
|
|
128
|
+
<OrderClient />
|
|
129
|
+
</SessionProvider>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Three props do the heavy lifting. `initialState` is the fold; `initialIndex`
|
|
135
|
+
is its frontier, the log position the fold reflects; `initialEvents` makes
|
|
136
|
+
the raw feed available in the server render too. Reading state before history
|
|
137
|
+
and filtering at the state frontier gives both values one consistent boundary.
|
|
138
|
+
The client opens its stream at exactly that position. Nothing missed, nothing
|
|
139
|
+
folded twice.
|
|
140
|
+
(No `reducer` or `api` prop: the provider got both from the shared client.)
|
|
141
|
+
|
|
142
|
+
## The client component
|
|
143
|
+
|
|
144
|
+
```tsx app/orders/[orderId]/order-client.tsx
|
|
145
|
+
'use client'
|
|
146
|
+
import { useSession } from './session'
|
|
147
|
+
|
|
148
|
+
export function OrderClient() {
|
|
149
|
+
const { state, push, events, index } = useSession()
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<button onClick={() => push({ type: 'shop.started', payload: {} })}>
|
|
153
|
+
Start
|
|
154
|
+
</button>
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
What the hook gives you:
|
|
160
|
+
|
|
161
|
+
- **`state`**: the live view, folded through the shared reducer. It
|
|
162
|
+
updates three ways: optimistically when you push, corrected when the
|
|
163
|
+
server acks, and continuously as the stream delivers events, yours and
|
|
164
|
+
everyone else's.
|
|
165
|
+
- **`push`**: typed against the contract's vocabulary (via the reducer). Payloads are
|
|
166
|
+
validated locally before anything leaves the browser, apply instantly,
|
|
167
|
+
swap in the real event when the `POST` acks, and refold without it if
|
|
168
|
+
the server says no. Rejections carry the same
|
|
169
|
+
[`A2Error` codes](/reference/errors) the server throws, so both sides
|
|
170
|
+
branch on `code`. Awaiting `push(...)` gives the server ack; the
|
|
171
|
+
result also carries **`.confirmed`**, a promise for the moment the
|
|
172
|
+
live stream delivers the batch back and the view shows server truth.
|
|
173
|
+
`await push(...).confirmed` is "continue once this is real"; two
|
|
174
|
+
`performance.now()` calls around the two awaits are a complete
|
|
175
|
+
push→ack→stream latency meter.
|
|
176
|
+
- **`events`**: the raw feed `state` is folded from, for UI that wants
|
|
177
|
+
the log itself: an activity feed, a debug panel.
|
|
178
|
+
- **`index`**: the stream frontier, the last server-confirmed log
|
|
179
|
+
position. This is the `lastSeenIndex` that makes
|
|
180
|
+
[cancellation](/guides/cancellation) exact.
|
|
181
|
+
- **`connection`**: a discriminated union of `{ status: 'idle' }`,
|
|
182
|
+
`{ status: 'connecting', reconnects, error }`,
|
|
183
|
+
`{ status: 'live', reconnects }`, or `{ status: 'closed' }`. A status
|
|
184
|
+
dot is one ternary away; "reconnecting…" is
|
|
185
|
+
`status === 'connecting' && reconnects > 0`; `error` (why the last
|
|
186
|
+
connection ended) only exists while disconnected, so impossible
|
|
187
|
+
states don't compile. It's honest about silence, too: the server
|
|
188
|
+
heartbeats the stream (`: ping` every 15s), and a client that hears
|
|
189
|
+
nothing for two beats treats the connection as dead and reconnects, so
|
|
190
|
+
`live` means bytes are flowing, not "the socket hasn't errored yet".
|
|
191
|
+
|
|
192
|
+
The provider opens the stream on mount, closes it on unmount, and
|
|
193
|
+
reconnects with backoff, resuming from `index`, when the connection
|
|
194
|
+
drops. Components that only write don't need the hook; `POST` to the
|
|
195
|
+
route directly, or call `ordersClient.session(id).push(...)` so a later
|
|
196
|
+
provider sees the same optimistic session.
|
|
197
|
+
|
|
198
|
+
That identity is an in-memory L1, not another source of truth. Repeated
|
|
199
|
+
`session(id)` calls reuse it while active and for five idle minutes by
|
|
200
|
+
default. A newer server fold advances it, pending pushes stay overlaid,
|
|
201
|
+
and a stale server render cannot rewind it. The optional IndexedDB cache
|
|
202
|
+
is the L2: it survives reloads; the memory runtime does not.
|
|
203
|
+
|
|
204
|
+
One detail worth knowing: every push carries a client-generated event id.
|
|
205
|
+
That id is how the ack finds its optimistic entry, and it makes retrying
|
|
206
|
+
a failed `POST` idempotent for free (`push` auto-retries only
|
|
207
|
+
`LOG_UNAVAILABLE`).
|
|
208
|
+
|
|
209
|
+
Optimistic pushes are also what make [cancellation](/guides/cancellation)
|
|
210
|
+
feel instant: the `cancelled` event folds locally before the server ever
|
|
211
|
+
sees it.
|
|
212
|
+
|
|
213
|
+
Sessions can outlive the tab, too: hand `createReact` a `cache` and
|
|
214
|
+
revisits paint from the local copy, the stream resumes from the cached
|
|
215
|
+
frontier, and offline pushes queue and replay. See
|
|
216
|
+
[Local-first](/guides/local-first).
|
|
217
|
+
|
|
218
|
+
## Keep the backend out of the bundle
|
|
219
|
+
|
|
220
|
+
The split is structural, not disciplinary. Core `experimental-a2` (the contract,
|
|
221
|
+
reducers, errors) imports no backend, ever; only `experimental-a2/server` can reach
|
|
222
|
+
one, and its exports map resolves to a loud error under the browser
|
|
223
|
+
condition. The layout above rides that:
|
|
224
|
+
|
|
225
|
+
1. `contracts.ts` and `reducer.ts` import nothing but schemas and `experimental-a2`,
|
|
226
|
+
isomorphic by construction.
|
|
227
|
+
2. The session module imports the reducer, never `experimental-a2/server`. Client
|
|
228
|
+
components don't need the server's *type*, even: the factory carries
|
|
229
|
+
all the typing.
|
|
230
|
+
|
|
231
|
+
Belt and suspenders: put `import 'server-only'` at the top of your
|
|
232
|
+
server module and enable `verbatimModuleSyntax` in your tsconfig; an
|
|
233
|
+
accidental value import then fails your build too, with your own error
|
|
234
|
+
message.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Local-first
|
|
3
|
+
description: "Cache one A2 session in the browser: instant revisits, stream resumption, and offline writes."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## One option
|
|
7
|
+
|
|
8
|
+
```tsx app/orders/[orderId]/session.ts
|
|
9
|
+
'use client'
|
|
10
|
+
import { createReact } from 'experimental-a2/react'
|
|
11
|
+
import { indexedDb } from 'experimental-a2/cache-indexeddb'
|
|
12
|
+
import { ordersReducer } from '@/reducer'
|
|
13
|
+
|
|
14
|
+
export const { SessionProvider, useSession } = createReact({
|
|
15
|
+
reducer: ordersReducer,
|
|
16
|
+
cache: indexedDb(),
|
|
17
|
+
})
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This is the [session module](/guides/react#the-session-module) from Live
|
|
21
|
+
UI with one addition. The cache is created here, in client code, where
|
|
22
|
+
live objects belong; server components keep importing `SessionProvider`
|
|
23
|
+
exactly as before; nothing crosses the server boundary.
|
|
24
|
+
|
|
25
|
+
With `cache` set:
|
|
26
|
+
|
|
27
|
+
- **Revisits paint instantly.** State folds from the local copy before the
|
|
28
|
+
network says a word. Works offline too.
|
|
29
|
+
- **The stream resumes from the cached frontier.** Reconnects fetch only
|
|
30
|
+
what this browser hasn't seen, no matter how long it's been.
|
|
31
|
+
- **Pushes queue while offline**, and replay, in order, when the
|
|
32
|
+
connection returns.
|
|
33
|
+
|
|
34
|
+
Without it, nothing changes; the provider behaves exactly as before.
|
|
35
|
+
|
|
36
|
+
## Why this is safe
|
|
37
|
+
|
|
38
|
+
Local-first is usually hard because it means syncing state, and state
|
|
39
|
+
needs merging. A2 caches an append-only log with server-assigned positions
|
|
40
|
+
instead. A cached event can never be *wrong*; this browser can only be
|
|
41
|
+
*behind*. Catching up is fetching events after an index, which is what
|
|
42
|
+
`stream({ startAt })` does anyway. There's no merge function because
|
|
43
|
+
there's nothing to merge.
|
|
44
|
+
|
|
45
|
+
## What gets stored
|
|
46
|
+
|
|
47
|
+
Three things, in IndexedDB:
|
|
48
|
+
|
|
49
|
+
| Stored | Invalidated by |
|
|
50
|
+
| --------------------------------------------- | ------------------------------------------------------- |
|
|
51
|
+
| the session's events | nothing; they're immutable |
|
|
52
|
+
| a folded snapshot, keyed by reducer name | a reducer rename (or a `stateSchema` mismatch, when the reducer declares one): discarded, refolded from cached events |
|
|
53
|
+
| pending pushes | the server's ack |
|
|
54
|
+
|
|
55
|
+
Note the middle row: the reducer's `name` does the same job here that it
|
|
56
|
+
does for [server snapshots](/concepts/state#snapshots-are-a-cache). One
|
|
57
|
+
string, both sides of the wire.
|
|
58
|
+
|
|
59
|
+
Deleting the cache is always safe. It's a replica of what this browser
|
|
60
|
+
already saw; the log on the server stays the only authority.
|
|
61
|
+
|
|
62
|
+
## Offline writes
|
|
63
|
+
|
|
64
|
+
`push` while offline persists the events, client ids and all, and
|
|
65
|
+
applies them optimistically, same as always. On reconnect they replay in
|
|
66
|
+
order. The replay is idempotent by construction: an identical batch is an
|
|
67
|
+
idempotent success, so a flaky reconnect, or a second tab replaying the
|
|
68
|
+
same queue, causes no duplicates. Rejections refold without the event,
|
|
69
|
+
exactly like an online push the server refused.
|
|
70
|
+
|
|
71
|
+
What this is *not*: local execution. Handlers run on the server, when the
|
|
72
|
+
events arrive. An offline push is a queued intent, not a completed
|
|
73
|
+
action. Design the UI accordingly ("sending…" is honest; "sent" is not).
|
|
74
|
+
|
|
75
|
+
## Logout
|
|
76
|
+
|
|
77
|
+
Cached events outlive the session cookie. If your app authenticates,
|
|
78
|
+
clear the cache when the user leaves:
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// in the browser, wherever your logout flow runs:
|
|
82
|
+
await cache.clear() // everything, or cache.clear(sessionId) for one session
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
That's the one obligation `cache` puts on you. The cache never *grants*
|
|
86
|
+
access (your routes still authorize every read and write) but it
|
|
87
|
+
*retains* what was legitimately seen, and retained data belongs to the
|
|
88
|
+
person who saw it.
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Going to production
|
|
3
|
+
description: Point the log at Postgres, add queue-backed recovery, and know what to do when an event dead-letters.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## Two pieces
|
|
7
|
+
|
|
8
|
+
Development needs zero setup: SQLite appears under `.a2/`, and tests run
|
|
9
|
+
in memory. Production needs two deliberate pieces. Neither changes your
|
|
10
|
+
handlers.
|
|
11
|
+
|
|
12
|
+
## 1. Choose a log
|
|
13
|
+
|
|
14
|
+
Production has no default log, on purpose. A server without one throws
|
|
15
|
+
`LOG_NOT_CONFIGURED` at startup. A failed boot beats events written to a
|
|
16
|
+
filesystem that evaporates.
|
|
17
|
+
|
|
18
|
+
```ts server/orders.ts
|
|
19
|
+
import { createServer } from 'experimental-a2/server'
|
|
20
|
+
import { postgres } from 'experimental-a2/log-postgres'
|
|
21
|
+
import { orders } from '@/contracts'
|
|
22
|
+
|
|
23
|
+
export const ordersServer = createServer({
|
|
24
|
+
contract: orders,
|
|
25
|
+
log: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
26
|
+
handlers: {
|
|
27
|
+
/* ... */
|
|
28
|
+
},
|
|
29
|
+
})
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The Postgres backend uses real transactions; appends serialize per
|
|
33
|
+
session on an advisory lock, and the live stream polls the log with an
|
|
34
|
+
activity-adaptive cadence: 25ms while a session is producing events
|
|
35
|
+
(a token stream reads smoothly, not in clumps), backing off to 250ms
|
|
36
|
+
when it goes quiet (a LISTEN/NOTIFY upgrade could still land without
|
|
37
|
+
any API change). Any Postgres works: Neon, Supabase, RDS, your own
|
|
38
|
+
box; transaction-mode poolers included, which is exactly why polling
|
|
39
|
+
is the default. `pg` is an optional peer dependency; pass
|
|
40
|
+
`connectionString`, or inject your own pool as `client`.
|
|
41
|
+
|
|
42
|
+
This configures storage for A2's session logs. It does not connect A2 to your
|
|
43
|
+
application tables or make them part of the append transaction. See
|
|
44
|
+
[A2 and your database](/guides/application-data) for that boundary.
|
|
45
|
+
|
|
46
|
+
## 2. Add recovery
|
|
47
|
+
|
|
48
|
+
Recovery is what puts a clock on healing. `experimental-a2/recovery-vercel` rides
|
|
49
|
+
Vercel Queues (`@vercel/queue` is a peer
|
|
50
|
+
dependency). Same `server/orders.ts`, now with `recovery`:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
// server/orders.ts, now with recovery:
|
|
54
|
+
import { createServer } from 'experimental-a2/server'
|
|
55
|
+
import { postgres } from 'experimental-a2/log-postgres'
|
|
56
|
+
import { vercelQueues } from 'experimental-a2/recovery-vercel'
|
|
57
|
+
import { orders } from '@/contracts'
|
|
58
|
+
|
|
59
|
+
export const recovery = vercelQueues()
|
|
60
|
+
|
|
61
|
+
export const ordersServer = createServer({
|
|
62
|
+
contract: orders,
|
|
63
|
+
log: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
64
|
+
recovery,
|
|
65
|
+
handlers: {
|
|
66
|
+
/* ... */
|
|
67
|
+
},
|
|
68
|
+
})
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
One recovery instance is shared by every server, and mounted once:
|
|
72
|
+
|
|
73
|
+
```ts app/api/a2/recovery/route.ts
|
|
74
|
+
import { recovery, ordersServer, billingServer } from '@/server'
|
|
75
|
+
|
|
76
|
+
export const POST = recovery.handler(ordersServer, billingServer)
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
```json vercel.json
|
|
80
|
+
{
|
|
81
|
+
"functions": {
|
|
82
|
+
"app/api/a2/recovery/route.ts": {
|
|
83
|
+
"experimentalTriggers": [
|
|
84
|
+
{
|
|
85
|
+
"type": "queue/v2beta",
|
|
86
|
+
"topic": "a2"
|
|
87
|
+
}
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
The trigger makes the route private. Only queue infrastructure can invoke
|
|
95
|
+
it, so it needs no auth of its own.
|
|
96
|
+
|
|
97
|
+
One route, one job. Every top-level append starts a delayed, coalesced
|
|
98
|
+
"drain this session" arm alongside its inline handler. The handler does not
|
|
99
|
+
wait for the queue, while `append` joins the initial arm for up to two seconds
|
|
100
|
+
before it returns. An unresponsive queue therefore cannot hold the append open
|
|
101
|
+
indefinitely. Every lease renewal arms another watchdog for just after that
|
|
102
|
+
lease window. A live holder keeps moving the watchdog forward. A killed holder
|
|
103
|
+
stops heartbeating, its lease expires, and the next watchdog retries the
|
|
104
|
+
pending event. When Vercel exposes the function deadline, A2 caps the final
|
|
105
|
+
lease window there so timeout recovery starts promptly. A failing handler
|
|
106
|
+
keeps the current message and redelivers with backoff until the session
|
|
107
|
+
settles.
|
|
108
|
+
|
|
109
|
+
Due times are rounded to one-second slots. Top-level arms, lease renewals, and
|
|
110
|
+
racing deliveries targeting the same slot deduplicate into one queue message.
|
|
111
|
+
Busy deliveries continue the current message's heartbeat-aligned slot series,
|
|
112
|
+
so they do not create an independent stream of watchdog callbacks.
|
|
113
|
+
Events appended by handlers ride their current execution window and add no
|
|
114
|
+
recovery operation of their own.
|
|
115
|
+
|
|
116
|
+
No cron, no sweep, no notification bookkeeping. The queue message is
|
|
117
|
+
the recovery state, and the log is the only thing it consults.
|
|
118
|
+
|
|
119
|
+
## 3. When an event dead-letters
|
|
120
|
+
|
|
121
|
+
After ten caught handler failures, A2 stops retrying an event and the session
|
|
122
|
+
stalls at it; [Durability](/concepts/durability#when-a-handler-keeps-failing)
|
|
123
|
+
explains why stalling is the honest choice. Resolution is manual, and has
|
|
124
|
+
exactly two shapes:
|
|
125
|
+
|
|
126
|
+
- **Fix and retry.** Deploy the handler fix, clear the event's failure
|
|
127
|
+
marker.
|
|
128
|
+
- **Skip.** Mark the event processed, accepting that its effects never
|
|
129
|
+
happened.
|
|
130
|
+
|
|
131
|
+
Both are one-column updates on the events table.
|
|
132
|
+
|
|
133
|
+
## Watch it in traces
|
|
134
|
+
|
|
135
|
+
A2 takes an optional `telemetry`, an instrumentation hook that
|
|
136
|
+
`experimental-a2/otel` implements over OpenTelemetry (`@opentelemetry/api` is a peer
|
|
137
|
+
dependency):
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
// server/orders.ts, with telemetry:
|
|
141
|
+
import { createServer } from 'experimental-a2/server'
|
|
142
|
+
import { otel } from 'experimental-a2/otel'
|
|
143
|
+
import { orders } from '@/contracts'
|
|
144
|
+
|
|
145
|
+
export const ordersServer = createServer({
|
|
146
|
+
contract: orders,
|
|
147
|
+
// ...log, recovery, and handlers as above
|
|
148
|
+
telemetry: otel(),
|
|
149
|
+
})
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
With any OTel setup registered (on Vercel, `@vercel/otel`), the happy
|
|
153
|
+
path lands in a single trace: the request → `a2.append` → the inline
|
|
154
|
+
`a2.drain` → an `a2.event` span per handler → the appends those handlers
|
|
155
|
+
make: the whole causal chain, visually. Handler failures mark their `a2.event`
|
|
156
|
+
span with the exception, each dispatch reports `ctx.attempt` as
|
|
157
|
+
`a2.event.attempt`, and `a2.event.outcome = dead_lettered` is the attribute to
|
|
158
|
+
alert on when a session [stalls for manual
|
|
159
|
+
resolution](#3-when-an-event-dead-letters). See the
|
|
160
|
+
[API reference](/reference/api#a2otel) for the span catalogue.
|
|
161
|
+
|
|
162
|
+
## Running without a queue
|
|
163
|
+
|
|
164
|
+
Skip `recovery`, and the only wakeups are a top-level append or explicit
|
|
165
|
+
`server.drain()`. Reads never wake the session.
|
|
166
|
+
|
|
167
|
+
That's a real configuration, not a broken one. Fine for internal tools
|
|
168
|
+
and low-stakes apps where "heals on the next write" is acceptable. But
|
|
169
|
+
there's no clock in it: a session nobody wakes stays stuck until someone does.
|
|
170
|
+
For production, configure recovery.
|
|
171
|
+
|
|
172
|
+
## Checklist
|
|
173
|
+
|
|
174
|
+
| Piece | Done when |
|
|
175
|
+
| ------------------- | -------------------------------------------------------------------- |
|
|
176
|
+
| Log | `log: postgres(...)` on every server |
|
|
177
|
+
| Recovery | one shared `vercelQueues()`, route mounted, trigger in `vercel.json` |
|
|
178
|
+
| Idempotent handlers | external side effects take `event.id` as an idempotency key |
|
|
179
|
+
| Client split | contracts/reducers isomorphic; only `experimental-a2/server` touches backends |
|