experimental-a2 0.17.0 → 0.18.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 +21 -0
- package/README.md +1 -1
- package/dist/{actor-DBbe5xeE.d.ts → actor-Bjce9_5U.d.ts} +5 -5
- package/dist/{actor-DBbe5xeE.d.ts.map → actor-Bjce9_5U.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-UrWfKeuv.js → ai-BpOGm-zB.js} +2 -2
- package/dist/{ai-UrWfKeuv.js.map → ai-BpOGm-zB.js.map} +1 -1
- package/dist/{ai-vpGtcaCB.d.ts → ai-DrUaMyRt.d.ts} +3 -3
- package/dist/{ai-vpGtcaCB.d.ts.map → ai-DrUaMyRt.d.ts.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 +634 -0
- package/dist/ai-fx.js.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-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.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 +904 -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 +106 -37
- package/dist/ai-server.js.map +1 -1
- 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-BReKqwZC.js} +3 -3
- package/dist/{client-COaiaDub.js.map → client-BReKqwZC.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-B2T3U4Rq.js} +8 -2
- package/dist/internal-B2T3U4Rq.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 +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-Rj64fNvX.js → server-Dw_k_7kO.js} +93 -60
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-CGM8WzRG.d.ts → server-En7cbi3v.d.ts} +36 -7
- package/dist/server-En7cbi3v.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-Bo-XTFEy.d.ts} +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{store-Gpkq7WBo.d.ts → store-Djdv0bnZ.d.ts} +31 -2
- package/dist/store-Djdv0bnZ.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 +32 -6
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-4_y8lETm.js → store-polling-B5FcwJfo.js} +2 -2
- package/dist/{store-polling-4_y8lETm.js.map → store-polling-B5FcwJfo.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +54 -3
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-Q1C-k2dd.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-Q1C-k2dd.js.map → store-presence-polling-BAwS8RWn.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-BRITti_o.js} +187 -68
- package/dist/store-redis-notify-BRITti_o.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 +32 -2
- 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-rSUglYo8.js} +2 -2
- package/dist/{wire-DjfHOvdW.js.map → wire-rSUglYo8.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 +108 -144
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -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 +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +1 -1
- package/examples/playground/app/a0/[appId]/page.tsx +11 -6
- 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/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 +100 -45
- 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 +261 -87
- package/src/ai-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -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 +1263 -0
- package/src/ai-sdk-step.ts +2 -2
- package/src/ai-server.ts +3 -1
- package/src/internal.ts +7 -0
- package/src/libfx.d.ts +34 -0
- package/src/reducer.ts +1 -1
- package/src/server.ts +177 -19
- package/src/store-memory.ts +42 -7
- package/src/store-postgres.ts +80 -0
- package/src/store-redis-core.ts +210 -79
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +60 -0
- package/src/store.ts +33 -0
- 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
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Reading state
|
|
3
|
-
description: "A session's state is a fold over its log: raw history when you want facts, a reducer when you want the current view."
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## History
|
|
7
|
-
|
|
8
|
-
```ts
|
|
9
|
-
// anywhere on the server:
|
|
10
|
-
const events = await ordersServer.session(orderId).history()
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Everything that happened in this session, oldest first. Always the raw log,
|
|
14
|
-
never a summary, never a snapshot. This is the session's audit trail and
|
|
15
|
-
debugging story, and it is also fine to use inside handlers for questions like
|
|
16
|
-
"did the shop already start?" Pass inclusive `gte` or `lte` indexes to read a
|
|
17
|
-
bounded slice.
|
|
18
|
-
|
|
19
|
-
## Reducers
|
|
20
|
-
|
|
21
|
-
For a computed view, derive a reducer from the contract and fold:
|
|
22
|
-
|
|
23
|
-
```ts contracts.ts
|
|
24
|
-
import { z } from 'zod'
|
|
25
|
-
import * as a2 from 'experimental-a2'
|
|
26
|
-
|
|
27
|
-
export const orders = a2.contract({
|
|
28
|
-
name: 'orders',
|
|
29
|
-
events: {
|
|
30
|
-
created: z.object({ shopId: z.string(), items: z.array(z.string()) }),
|
|
31
|
-
'shop.notified': z.object({}),
|
|
32
|
-
expired: z.object({}),
|
|
33
|
-
},
|
|
34
|
-
})
|
|
35
|
-
```
|
|
36
|
-
|
|
37
|
-
```ts reducer.ts
|
|
38
|
-
import { reducer } from 'experimental-a2'
|
|
39
|
-
import { z } from 'zod'
|
|
40
|
-
import { orders } from './contracts'
|
|
41
|
-
|
|
42
|
-
export const ordersReducer = reducer({ contract: orders,
|
|
43
|
-
name: 'order-status',
|
|
44
|
-
initialState: { status: 'new' },
|
|
45
|
-
stateSchema: z.object({
|
|
46
|
-
status: z.enum(['new', 'created', 'notified', 'expired']),
|
|
47
|
-
shopId: z.string().optional(),
|
|
48
|
-
}),
|
|
49
|
-
})
|
|
50
|
-
.fold((state, event) => {
|
|
51
|
-
switch (event.type) {
|
|
52
|
-
case 'created':
|
|
53
|
-
return { status: 'created', shopId: event.payload.shopId }
|
|
54
|
-
case 'shop.notified':
|
|
55
|
-
return { ...state, status: 'notified' }
|
|
56
|
-
case 'expired':
|
|
57
|
-
return { ...state, status: 'expired' }
|
|
58
|
-
default:
|
|
59
|
-
return state
|
|
60
|
-
}
|
|
61
|
-
})
|
|
62
|
-
```
|
|
63
|
-
|
|
64
|
-
```ts
|
|
65
|
-
// anywhere on the server, e.g. a server component before first paint:
|
|
66
|
-
const { state, index } = await ordersServer
|
|
67
|
-
.session(orderId)
|
|
68
|
-
.state(ordersReducer)
|
|
69
|
-
```
|
|
70
|
-
|
|
71
|
-
Handlers receive the same session interface for their triggering session:
|
|
72
|
-
|
|
73
|
-
```ts server.ts
|
|
74
|
-
import { createServer } from 'experimental-a2/server'
|
|
75
|
-
import { orders } from './contracts'
|
|
76
|
-
import { ordersReducer } from './reducer'
|
|
77
|
-
|
|
78
|
-
export const ordersServer = createServer({
|
|
79
|
-
contract: orders,
|
|
80
|
-
handlers: {
|
|
81
|
-
created: async ({ session }) => {
|
|
82
|
-
const { state, index } = await session.state(ordersReducer)
|
|
83
|
-
void state
|
|
84
|
-
void index
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
})
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
A reducer is a name (its identity, more on that below), `initialState`, an
|
|
91
|
-
optional `stateSchema`, and the pure `fold`, `(state, event) => state`. It is
|
|
92
|
-
derived from the contract, so the events type themselves; the two-step shape
|
|
93
|
-
fixes the state and event types before `.fold()` receives them. No type
|
|
94
|
-
arguments or annotations are needed, and literal unions survive the fold.
|
|
95
|
-
`state()` returns the result with `index`, the last log position included. The
|
|
96
|
-
fold includes every event through that index and none after it. The browser
|
|
97
|
-
resumes its live stream from that boundary. See [Live UI](/guides/react).
|
|
98
|
-
|
|
99
|
-
A root session reads the latest committed frontier by default. A handler's
|
|
100
|
-
session is causally scoped, so the same call stops at its triggering event:
|
|
101
|
-
|
|
102
|
-
```ts causal-server.ts
|
|
103
|
-
import { createServer } from 'experimental-a2/server'
|
|
104
|
-
import { orders } from './contracts'
|
|
105
|
-
import { ordersReducer } from './reducer'
|
|
106
|
-
|
|
107
|
-
export const causalOrdersServer = createServer({
|
|
108
|
-
contract: orders,
|
|
109
|
-
handlers: {
|
|
110
|
-
created: async ({ event, session }) => {
|
|
111
|
-
const atTrigger = await session.state(ordersReducer)
|
|
112
|
-
// atTrigger.index === event.index
|
|
113
|
-
|
|
114
|
-
const current = await session.state(ordersReducer, {
|
|
115
|
-
through: 'latest',
|
|
116
|
-
})
|
|
117
|
-
void current
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
})
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
The trigger boundary is stable across concurrent appends and retries. Use the
|
|
124
|
-
explicit latest read for joins or other logic that intentionally observes later
|
|
125
|
-
events. A numeric `{ through: index }` reads any inclusive log boundary;
|
|
126
|
-
`through: 0` returns `initialState`.
|
|
127
|
-
|
|
128
|
-
A bounded handler read also keeps that exact folded checkpoint available while
|
|
129
|
-
the triggering event is unfinished. This is automatic. When concurrent work
|
|
130
|
-
advances the reducer's latest snapshot, A2 retains the older checkpoint until
|
|
131
|
-
every handler event using it completes or dead-letters. There is no TTL, history
|
|
132
|
-
limit, or retention setting to tune.
|
|
133
|
-
|
|
134
|
-
Root reads and handler reads with `{ through: 'latest' }` do not retain
|
|
135
|
-
historical checkpoints. A root numeric read can use an older checkpoint that an
|
|
136
|
-
active handler already retained, but querying an old boundary does not keep it
|
|
137
|
-
around or write a stale snapshot behind the current head. If no suitable
|
|
138
|
-
checkpoint exists, A2 folds the bounded log.
|
|
139
|
-
|
|
140
|
-
The snapshot and its remaining event tail come back in one consistent store
|
|
141
|
-
operation. A missing snapshot or one rejected by `stateSchema` rebuilds from the
|
|
142
|
-
log. An operational storage-read failure propagates with its backend diagnostics;
|
|
143
|
-
it does not trigger a full-log read. Existing A2 errors pass through, and other
|
|
144
|
-
errors become `STORE_UNAVAILABLE` with the original error as their cause.
|
|
145
|
-
`state()` is observational: it never runs handlers or waits for pending work.
|
|
146
|
-
Its index marks committed history, not handler completion.
|
|
147
|
-
|
|
148
|
-
A latest read and a following `ctx.session.append(name, ...events)` are separate
|
|
149
|
-
operations. Concurrent appends and retries can move the frontier between them.
|
|
150
|
-
For joins, read with `{ through: 'latest' }`, use a monotone readiness check
|
|
151
|
-
(once ready, always ready), and give the output event a stable explicit `id`.
|
|
152
|
-
Repeated attempts then converge on the same append.
|
|
153
|
-
|
|
154
|
-
`stateSchema` declares the state's shape once. Without it, the state type
|
|
155
|
-
is inferred from `initialState`, fine while every fold arm returns the
|
|
156
|
-
same shape. With it, the schema is the type (and the seed may lean on its
|
|
157
|
-
defaults), `initialState` is validated at definition, and anywhere A2
|
|
158
|
-
rehydrates folded state instead of refolding raw events, [server
|
|
159
|
-
snapshots](#snapshots-are-a-cache), the [browser
|
|
160
|
-
cache](/guides/local-first): a cached fold that fails the schema is
|
|
161
|
-
discarded and refolded, catching shape drift a stale `name` can't.
|
|
162
|
-
|
|
163
|
-
Note what these modules import: schemas and `experimental-a2`. Never `experimental-a2/server`,
|
|
164
|
-
never a store backend. Contract and reducer are isomorphic by
|
|
165
|
-
construction; the browser runs the same reducer. More on the split in
|
|
166
|
-
[Live UI](/guides/react#keep-the-backend-out-of-the-bundle). (In a
|
|
167
|
-
server-only app you can keep the contract next to `createServer`
|
|
168
|
-
instead of in its own file.)
|
|
169
|
-
|
|
170
|
-
## Snapshots are a cache
|
|
171
|
-
|
|
172
|
-
Folding a long session on every read would get slow, so the store backend
|
|
173
|
-
caches folded state as a snapshot. You never interact with it, except for
|
|
174
|
-
one string.
|
|
175
|
-
|
|
176
|
-
Each session and reducer has one latest snapshot. Older snapshots exist only
|
|
177
|
-
while unfinished handler events need their exact state boundary. Handler
|
|
178
|
-
completion and dead-lettering release those references in the same store
|
|
179
|
-
operation and remove any historical snapshot with no remaining reader. Several
|
|
180
|
-
handlers can share one checkpoint; it stays until the last reference leaves.
|
|
181
|
-
|
|
182
|
-
Snapshots are keyed by the reducer's `name`, which makes the name do two
|
|
183
|
-
jobs. It's the identity: two different reducers over the same session
|
|
184
|
-
never fight over a cache entry, because they have different names. And
|
|
185
|
-
it's the invalidation knob. Changed the fold's logic? Change the name (a
|
|
186
|
-
`-v2` suffix works). Snapshots stored under the old name are simply
|
|
187
|
-
ignored; the next read refolds from raw events and caches under the new
|
|
188
|
-
name. That's the entire cache invalidation story: one string.
|
|
189
|
-
|
|
190
|
-
Snapshot write-back runs in platform `waitUntil` after the state is ready. One
|
|
191
|
-
same-tick group of reads for a reducer and session reads the widest required log
|
|
192
|
-
range once, folds it once, and writes the needed prefixes together. A failed cache
|
|
193
|
-
write does not fail the completed state read. A2 reports it through the
|
|
194
|
-
`a2.snapshot` telemetry span, or `console.error` by default, with session, reducer,
|
|
195
|
-
checkpoint index, and batch count. It does not attach snapshot contents. The next
|
|
196
|
-
read folds the missing tail again.
|
|
197
|
-
|
|
198
|
-
Deleting every snapshot is always safe. The log rebuilds them.
|
|
199
|
-
|
|
200
|
-
## Events don't change meaning
|
|
201
|
-
|
|
202
|
-
The log is append-only, and events are immutable in meaning: `created`
|
|
203
|
-
means forever what it meant when it was written. When a payload needs a new
|
|
204
|
-
shape, add a new event type (`created.v2`) rather than redefining the old
|
|
205
|
-
one.
|
|
206
|
-
|
|
207
|
-
This is why reducers accumulate switch cases over time, and why they never
|
|
208
|
-
migrate anything: old sessions keep their old events, new sessions write
|
|
209
|
-
new ones, and the same fold handles both.
|
|
@@ -1,193 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Timers and delays
|
|
3
|
-
description: Schedule a typed session event for later with the same adapter that handles recovery.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
## Schedule from a handler
|
|
7
|
-
|
|
8
|
-
Configure one scheduler for the server, then call
|
|
9
|
-
`session.schedule(name, timing, ...events)`. This schedules an `expired` event
|
|
10
|
-
for five days after the durable `created` event:
|
|
11
|
-
|
|
12
|
-
```ts server/orders.ts
|
|
13
|
-
import { createServer } from 'experimental-a2/server'
|
|
14
|
-
import { vercelQueues } from 'experimental-a2/scheduler-vercel'
|
|
15
|
-
import { orders } from '@/contracts'
|
|
16
|
-
|
|
17
|
-
export const scheduler = vercelQueues()
|
|
18
|
-
|
|
19
|
-
export const ordersServer = createServer({
|
|
20
|
-
contract: orders,
|
|
21
|
-
scheduler,
|
|
22
|
-
handlers: {
|
|
23
|
-
created: async ({ session }) => {
|
|
24
|
-
await session.schedule(
|
|
25
|
-
'expire-order',
|
|
26
|
-
{ delay: '5d' },
|
|
27
|
-
{ type: 'expired', payload: {} },
|
|
28
|
-
)
|
|
29
|
-
},
|
|
30
|
-
expired: async ({ session }) => {
|
|
31
|
-
const events = await session.history()
|
|
32
|
-
if (events.some((event) => event.type === 'shop.started')) return
|
|
33
|
-
// your expiry side effect goes here
|
|
34
|
-
},
|
|
35
|
-
},
|
|
36
|
-
})
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
The same scheduler route handles recovery watchdogs and scheduled events. Mount
|
|
40
|
-
it once as shown in
|
|
41
|
-
[Going to production](/guides/production#2-add-a-scheduler-for-handlers-and-timers).
|
|
42
|
-
|
|
43
|
-
`timing` is exactly one of these shapes:
|
|
44
|
-
|
|
45
|
-
```ts
|
|
46
|
-
// anywhere on the server:
|
|
47
|
-
type ScheduleTiming =
|
|
48
|
-
| { delay: `${number}${'ms' | 's' | 'm' | 'h' | 'd'}`; at?: never }
|
|
49
|
-
| { at: Date; delay?: never }
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
The template type checks the unit suffix. At runtime, the number must be an
|
|
53
|
-
unsigned base-10 decimal without leading zeros, finite, and greater than zero.
|
|
54
|
-
A2 rejects `0s`, signs, exponent notation, malformed strings, and invalid
|
|
55
|
-
dates.
|
|
56
|
-
|
|
57
|
-
The requested target is the earliest useful delivery time, not an exact
|
|
58
|
-
execution deadline. Adapters encode provider timing in whole-second slots. A
|
|
59
|
-
target that is already due publishes immediately.
|
|
60
|
-
|
|
61
|
-
The call awaits provider acceptance and returns `void`. No event enters the
|
|
62
|
-
store until the scheduler delivers the task.
|
|
63
|
-
|
|
64
|
-
## Schedule from ordinary server code
|
|
65
|
-
|
|
66
|
-
Root sessions expose the same method. Use `{ at }` when a caller may retry and
|
|
67
|
-
the target time must stay fixed:
|
|
68
|
-
|
|
69
|
-
```ts app/api/trials/route.ts
|
|
70
|
-
import { z } from 'zod'
|
|
71
|
-
import { ordersServer } from '@/server/orders'
|
|
72
|
-
|
|
73
|
-
const scheduleTrial = z.object({
|
|
74
|
-
orderId: z.string(),
|
|
75
|
-
expiresAt: z.iso.datetime().transform((value) => new Date(value)),
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
export async function POST(request: Request) {
|
|
79
|
-
const body = scheduleTrial.parse(await request.json())
|
|
80
|
-
await ordersServer.session(body.orderId).schedule(
|
|
81
|
-
'expire-order',
|
|
82
|
-
{ at: body.expiresAt },
|
|
83
|
-
{ type: 'expired', payload: {} },
|
|
84
|
-
)
|
|
85
|
-
return Response.json({ scheduled: true }, { status: 202 })
|
|
86
|
-
}
|
|
87
|
-
```
|
|
88
|
-
|
|
89
|
-
A root `{ delay: '30s' }` starts from the wall clock captured by that call. A
|
|
90
|
-
retry 10 seconds later calculates a delivery 10 seconds later too. An absolute
|
|
91
|
-
`{ at }` preserves the original target. Inside a handler, relative delays are
|
|
92
|
-
already retry-stable: A2 anchors them to the triggering event's durable
|
|
93
|
-
`createdAt`, not the retry's clock.
|
|
94
|
-
|
|
95
|
-
## Identity and retries
|
|
96
|
-
|
|
97
|
-
`name` identifies one logical schedule. On a root session its scope is the
|
|
98
|
-
contract and session. In a handler it also includes the triggering event id, so
|
|
99
|
-
different events can both use a local name such as `expire-order` while retries
|
|
100
|
-
of one event converge.
|
|
101
|
-
|
|
102
|
-
Keep the same name, timing, and events on every retry. A2 derives stable ids for
|
|
103
|
-
events whose `id` you omit. Provider deduplication folds repeated sends while
|
|
104
|
-
its deduplication window is open. After that window, duplicate deliveries
|
|
105
|
-
still append once because they carry the same event ids, within one session
|
|
106
|
-
lifetime. A [clear](/concepts/contracts#clearing-a-session) frees those ids, so
|
|
107
|
-
a root task redelivered after one appends again. A changed input under
|
|
108
|
-
the same identity conflicts when it produces a different validated event at
|
|
109
|
-
delivery.
|
|
110
|
-
|
|
111
|
-
A2 does not store a timer-intent registry, so it cannot compare every retry
|
|
112
|
-
with the first call before sending. The stable provider task and event ids are
|
|
113
|
-
the durable convergence mechanism.
|
|
114
|
-
|
|
115
|
-
## What arrives later
|
|
116
|
-
|
|
117
|
-
Scheduled payloads travel inside the provider message. Before validation, A2
|
|
118
|
-
snapshots each payload into the same plain JSON tree the provider will deliver.
|
|
119
|
-
Encode a `Date` as an ISO string and a custom class as explicit JSON data. A2
|
|
120
|
-
rejects `undefined`, functions, symbols, bigint values, cycles, accessors, and
|
|
121
|
-
non-plain prototypes. Shared object references become independent JSON values;
|
|
122
|
-
reference identity is not part of a scheduled payload. The task carries that
|
|
123
|
-
snapshotted schema input. At delivery, A2 validates it through
|
|
124
|
-
an ordinary top-level append with fixed ids. A schema transform produces the
|
|
125
|
-
stored payload from that original input, rather than receiving its earlier
|
|
126
|
-
validation output as new input.
|
|
127
|
-
|
|
128
|
-
That means scheduled events:
|
|
129
|
-
|
|
130
|
-
- enter the log only when delivered;
|
|
131
|
-
- have `cause: null`, like an ordinary server append;
|
|
132
|
-
- run handlers and lanes normally;
|
|
133
|
-
- update reducers, streams, and live clients normally; and
|
|
134
|
-
- tolerate at-least-once delivery through their stable ids.
|
|
135
|
-
|
|
136
|
-
If the deployment receiving a timer removes or changes its event schema,
|
|
137
|
-
delivery can fail validation and remain visible as a retrying scheduler error.
|
|
138
|
-
The Vercel adapter pins timers to their publishing deployment by default.
|
|
139
|
-
Cross-deployment compatibility matters when `deploymentId: null` is selected
|
|
140
|
-
or an adapter targets a stable URL, such as QStash.
|
|
141
|
-
|
|
142
|
-
## Stale timers
|
|
143
|
-
|
|
144
|
-
The order shipped on day two. Its expiry event still arrives on day five. The
|
|
145
|
-
recommended pattern is the guard in the first example: let the event land, read
|
|
146
|
-
the session, and return when it no longer applies. The log still records the
|
|
147
|
-
fact that the timer fired.
|
|
148
|
-
|
|
149
|
-
A2 has no cancellation, reschedule, cron, or timer-listing API. `name` is an
|
|
150
|
-
idempotency key, not a handle. Model repeated schedules as session events and
|
|
151
|
-
have a handler schedule the next occurrence explicitly.
|
|
152
|
-
|
|
153
|
-
## Provider limits
|
|
154
|
-
|
|
155
|
-
One scheduled task must fit the active provider's maximum delay:
|
|
156
|
-
|
|
157
|
-
| Adapter | Maximum one-shot delay |
|
|
158
|
-
| --- | --- |
|
|
159
|
-
| Vercel Queues | 6 days |
|
|
160
|
-
| QStash free | 7 days |
|
|
161
|
-
| QStash usage-based | 1 year |
|
|
162
|
-
| QStash fixed | no fixed maximum |
|
|
163
|
-
|
|
164
|
-
These are provider limits. A2 does not silently split a longer delay into
|
|
165
|
-
several tasks. Vercel Queues retains the message for seven days; A2 reserves
|
|
166
|
-
the final day for delivery and retries instead of making the delay equal the
|
|
167
|
-
message's lifetime. Leave at least one second of margin at a QStash maximum
|
|
168
|
-
because its `notBefore` timestamp uses whole Unix seconds.
|
|
169
|
-
|
|
170
|
-
## Deploy safely
|
|
171
|
-
|
|
172
|
-
The Vercel adapter's `deploymentId` setting applies to scheduled appends,
|
|
173
|
-
scheduled clears, and recovery watchdogs alike. The default uses the publishing
|
|
174
|
-
deployment. Promoting a new deployment does not move pinned tasks: older
|
|
175
|
-
deployments keep receiving their own work until it is acknowledged or expires.
|
|
176
|
-
|
|
177
|
-
Use `deploymentId: null` for deploymentless delivery of every task, including
|
|
178
|
-
recovery. Every eligible consumer must use the same setting and remain
|
|
179
|
-
compatible with pending tasks and session work. Deploymentless delivery does
|
|
180
|
-
not select the latest production deployment.
|
|
181
|
-
|
|
182
|
-
Changing the setting does not move already queued tasks. Keep consumers for
|
|
183
|
-
their original deployment scope until those tasks finish. The former
|
|
184
|
-
`scheduledAppends: 'deploymentless'` option affected only timers; its replacement,
|
|
185
|
-
`deploymentId: null`, also makes recovery deploymentless.
|
|
186
|
-
|
|
187
|
-
:::warning
|
|
188
|
-
When using deploymentless Vercel delivery or a stable callback URL, do not roll
|
|
189
|
-
back to an A2 version without timer delivery while timer tasks are still outstanding.
|
|
190
|
-
An older route can acknowledge one as a recovery message without
|
|
191
|
-
appending its events. Roll forward, or first wait for the tasks to deliver or
|
|
192
|
-
remove them through the provider.
|
|
193
|
-
:::
|
|
@@ -1,161 +0,0 @@
|
|
|
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
|
-
for the watch: no subscription is held for them.
|
|
46
|
-
|
|
47
|
-
Every handler's signal, with or without `abortOn`, also guards the run's
|
|
48
|
-
durable claim. While a handler runs, A2 renews its claim on a heartbeat,
|
|
49
|
-
and a failed renewal retries promptly while the lease can still be saved.
|
|
50
|
-
A short stall is free: blocking the event loop for less than the lease's
|
|
51
|
-
slack costs nothing, because an overdue beat fires the moment the loop
|
|
52
|
-
wakes. If no renewal lands before the lease lapses, the signal fires with
|
|
53
|
-
an `A2Error` reason of code `CLAIM_EXPIRED`. That decision is local (no
|
|
54
|
-
store round trip), so it works even while the store is unreachable, and
|
|
55
|
-
it means: assume a recovery run is taking over, stop external work now.
|
|
56
|
-
If a successor has provably claimed the event, the reason is
|
|
57
|
-
`SUPERSEDED_ATTEMPT` instead; by then a lapsed run's signal has usually
|
|
58
|
-
already fired. A superseded run can no longer write to the log or
|
|
59
|
-
complete; wiring `ctx.signal` into your external calls is what stops it
|
|
60
|
-
from racing its successor outside A2.
|
|
61
|
-
|
|
62
|
-
The right response depends on the reason, and the default wiring gets
|
|
63
|
-
every case correct: pass `ctx.signal` to your I/O and let the abort
|
|
64
|
-
propagate. A propagated lease abort (`CLAIM_EXPIRED` or
|
|
65
|
-
`SUPERSEDED_ATTEMPT`, which is exactly what `fetch(url, { signal })`
|
|
66
|
-
rejects with) makes A2 surrender the attempt: no completion recorded,
|
|
67
|
-
no failure budget spent, and the event recovers normally. A user
|
|
68
|
-
cancellation (`abortOn`) is the one case to catch and return normally,
|
|
69
|
-
because the stop is the outcome and the completion should count.
|
|
70
|
-
Return normally after a lease abort only when the work is actually
|
|
71
|
-
done: a lapsed run that finished, with no successor claimed yet, still
|
|
72
|
-
completes and its completion counts. Any other throw is a real failure
|
|
73
|
-
and spends the retry budget.
|
|
74
|
-
|
|
75
|
-
When one session multiplexes work (several generations over its
|
|
76
|
-
lifetime), match by *instance*, not just type, with a predicate:
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
// server/chat.ts, the generate entry, targeted by instance:
|
|
80
|
-
generate: {
|
|
81
|
-
abortOn: {
|
|
82
|
-
// only the cancel that names this run fires the signal
|
|
83
|
-
cancelled: (event, trigger) => event.payload.of === trigger.id,
|
|
84
|
-
},
|
|
85
|
-
handler: async (ctx) => {
|
|
86
|
-
// ...
|
|
87
|
-
},
|
|
88
|
-
}
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
The pushing side already knows the id it's cancelling; it's in the
|
|
92
|
-
`events` feed the hook exposes.
|
|
93
|
-
|
|
94
|
-
Handlers are concurrent by default. A `cancelled` handler may therefore run
|
|
95
|
-
while the interrupted handler is still unwinding. The signal is the
|
|
96
|
-
preemption channel; the event is the durable record.
|
|
97
|
-
|
|
98
|
-
When cleanup must wait for the interrupted handler, put both event types in
|
|
99
|
-
one lane. The event fires the signal as soon as it is appended, but its own
|
|
100
|
-
handler waits for earlier work in that lane:
|
|
101
|
-
|
|
102
|
-
```ts server/chat-with-cleanup.ts
|
|
103
|
-
import { createServer } from 'experimental-a2/server'
|
|
104
|
-
import { chat } from '@/contracts'
|
|
105
|
-
|
|
106
|
-
export const chatServer = createServer({
|
|
107
|
-
contract: chat,
|
|
108
|
-
handlers: {
|
|
109
|
-
generate: {
|
|
110
|
-
lane: 'generation',
|
|
111
|
-
abortOn: ['cancelled'],
|
|
112
|
-
handler: async (ctx) => {
|
|
113
|
-
// your abortable work; hand it ctx.signal:
|
|
114
|
-
// await streamText({ signal: ctx.signal, ... })
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
cancelled: {
|
|
118
|
-
lane: 'generation',
|
|
119
|
-
handler: async () => {
|
|
120
|
-
// your cleanup, after generate has returned
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
},
|
|
124
|
-
})
|
|
125
|
-
```
|
|
126
|
-
|
|
127
|
-
## The view cuts
|
|
128
|
-
|
|
129
|
-
Chunks in flight during the cancel round-trip still land in the log after
|
|
130
|
-
`cancelled`. That's correct. They're true history; the model really did
|
|
131
|
-
generate them. Keeping them out of what the user sees is the reducer's job:
|
|
132
|
-
|
|
133
|
-
```ts reducer.ts
|
|
134
|
-
case 'chunk':
|
|
135
|
-
return state.cancelled
|
|
136
|
-
? state
|
|
137
|
-
: { ...state, chunks: [...state.chunks, { index: event.index, delta: event.payload.delta }] }
|
|
138
|
-
case 'cancelled':
|
|
139
|
-
return {
|
|
140
|
-
...state,
|
|
141
|
-
cancelled: true,
|
|
142
|
-
chunks: state.chunks.filter((c) => c.index <= event.payload.lastSeenIndex),
|
|
143
|
-
}
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
`lastSeenIndex` is the client's stream frontier, a number it already
|
|
147
|
-
tracks to resume subscriptions. Cutting on it makes the trim exact and
|
|
148
|
-
clock-free: chunks the log ordered before `cancelled` but the user never
|
|
149
|
-
saw are removed retroactively, so the optimistic view and the
|
|
150
|
-
authoritative refold agree on the same result. No wall clocks, no skew.
|
|
151
|
-
|
|
152
|
-
And because the client pushes `cancelled`
|
|
153
|
-
[optimistically](/guides/react#events-and-history), its own fold cuts
|
|
154
|
-
instantly. Stragglers still arriving over the stream hit `state.cancelled`
|
|
155
|
-
and vanish from the view.
|
|
156
|
-
|
|
157
|
-
:::tip
|
|
158
|
-
Keep chunks at per-chunk granularity in state until the session reaches a
|
|
159
|
-
terminal event, then compact them into one string. That's reducer design,
|
|
160
|
-
fully yours.
|
|
161
|
-
:::
|