experimental-a2 0.0.0 → 0.2.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 +43 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.d.ts +19 -7
- package/dist/ai-server.js +730 -96
- package/dist/ai.d.ts +32 -11
- package/dist/ai.js +253 -75
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -4
- package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
- package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
- package/dist/devtools-server.browser.js +2 -2
- package/dist/devtools-server.js +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.js +4 -3
- package/dist/idempotent-replay-BMyHrP0L.js +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
- package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
- package/dist/log-memory.d.ts +1 -1
- package/dist/log-memory.js +173 -96
- package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
- package/dist/log-postgres.d.ts +1 -1
- package/dist/log-postgres.js +235 -192
- package/dist/log-redis.d.ts +1 -1
- package/dist/log-redis.js +453 -263
- package/dist/log-sqlite.d.ts +1 -1
- package/dist/log-sqlite.js +216 -127
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/recovery-vercel.d.ts +2 -2
- package/dist/recovery-vercel.js +9 -10
- package/dist/server-DJgD2YWP.js +877 -0
- package/dist/server.browser.js +4 -4
- package/dist/server.d.ts +46 -27
- package/dist/server.js +1 -1
- package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
- package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
- package/docs/01-quickstart.mdx +7 -7
- package/docs/concepts/01-contracts.mdx +22 -22
- package/docs/concepts/02-handlers.mdx +223 -89
- package/docs/concepts/03-durability.mdx +199 -112
- package/docs/concepts/04-state.mdx +27 -1
- package/docs/guides/01-timers.mdx +4 -4
- package/docs/guides/02-cancellation.mdx +32 -4
- package/docs/guides/05-production.mdx +61 -27
- package/docs/guides/06-ai-agents.mdx +151 -70
- package/docs/guides/07-devtools.mdx +6 -3
- package/docs/guides/08-application-data.mdx +5 -6
- package/docs/index.mdx +30 -14
- package/docs/reference/01-api.mdx +305 -70
- package/package.json +31 -31
- package/dist/server-DYsnKTTy.js +0 -780
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Handlers
|
|
3
|
-
description:
|
|
3
|
+
description: Concurrent reactions with atomic returned events and explicit serial lanes.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
```ts server/orders.ts
|
|
@@ -10,137 +10,271 @@ import { orders } from '@/contracts'
|
|
|
10
10
|
export const ordersServer = createServer({
|
|
11
11
|
contract: orders,
|
|
12
12
|
handlers: {
|
|
13
|
-
created: async ({ event
|
|
13
|
+
created: async ({ event }) => {
|
|
14
14
|
// your side effect, e.g. email the shop, idempotent via event.id:
|
|
15
15
|
// await sendEmailToShop(event.payload, { idempotencyKey: event.id })
|
|
16
|
-
|
|
16
|
+
return { type: 'shop.notified', payload: {} }
|
|
17
17
|
},
|
|
18
18
|
},
|
|
19
19
|
})
|
|
20
20
|
```
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
`order-2`, all of them. Because the table is complete at construction, a
|
|
26
|
-
handler can never be silently missing just because the module that
|
|
27
|
-
defined it wasn't imported.
|
|
22
|
+
A handler runs when its event lands. It is a plain async function: call a
|
|
23
|
+
database, hit an API, stream a model response. A2 durably claims the event,
|
|
24
|
+
renews that claim while the function runs, and retries after a crash.
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
26
|
+
Handlers live in the server's construction, not on sessions. One complete
|
|
27
|
+
table serves every session. A handler cannot be silently missing because the
|
|
28
|
+
module that registered it was never imported.
|
|
29
|
+
|
|
30
|
+
## Events without handlers
|
|
31
|
+
|
|
32
|
+
Handlers are optional per event type. An event without one still gets its full
|
|
33
|
+
place in the log. You can read it with `history()`, fold it into state, and
|
|
34
|
+
stream it to a live client. A2 settles it in the append transaction, with no
|
|
35
|
+
dispatch claim or recovery message.
|
|
36
|
+
|
|
37
|
+
```ts server/order-log.ts
|
|
38
|
+
import { createServer } from 'experimental-a2/server'
|
|
39
|
+
import { postgres } from 'experimental-a2/log-postgres'
|
|
40
|
+
import { orders } from '@/contracts'
|
|
41
|
+
|
|
42
|
+
export const orderLog = createServer({
|
|
43
|
+
contract: orders,
|
|
44
|
+
log: postgres({ connectionString: process.env.DATABASE_URL }),
|
|
45
|
+
})
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
A contract can mix handled and unhandled event types. Adding a handler later
|
|
49
|
+
does not replay events that were already settled. Model replay as a new event
|
|
50
|
+
or run an explicit migration when that is the behavior you want.
|
|
35
51
|
|
|
36
52
|
## The context
|
|
37
53
|
|
|
38
54
|
Every handler receives one argument:
|
|
39
55
|
|
|
40
|
-
| Property
|
|
41
|
-
|
|
|
42
|
-
| `ctx.event`
|
|
43
|
-
| `ctx.attempt`
|
|
44
|
-
| `ctx.
|
|
45
|
-
| `ctx.
|
|
46
|
-
| `ctx.signal` | An `AbortSignal`, active only with `abortOn`. See [Cancellation](/guides/cancellation). |
|
|
56
|
+
| Property | What it is |
|
|
57
|
+
| ------------- | ---------- |
|
|
58
|
+
| `ctx.event` | The triggering event: `{ id, type, payload, index, sessionId, createdAt }`. |
|
|
59
|
+
| `ctx.attempt` | The durable 1-based dispatch claim for this event. |
|
|
60
|
+
| `ctx.session` | This session's handle: `id`, `append`, `history`, `state`, and `stream`. |
|
|
61
|
+
| `ctx.signal` | An `AbortSignal`, active only with `abortOn`. See [Cancellation](/guides/cancellation). |
|
|
47
62
|
|
|
48
63
|
`ctx.attempt` starts at `1` and increments on every durable claim. It may skip
|
|
49
64
|
when a process dies between the claim and handler entry.
|
|
50
65
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
`ctx.session.id` is the same value as `ctx.event.sessionId`. Use
|
|
67
|
+
`ctx.session.history()` when a handler needs raw facts. Use
|
|
68
|
+
[`state()`](/concepts/state) for a cached computed view of a long session.
|
|
54
69
|
|
|
55
|
-
##
|
|
70
|
+
## Return what happens after success
|
|
56
71
|
|
|
57
|
-
|
|
72
|
+
Return an event when it depends on successful handler completion:
|
|
58
73
|
|
|
59
|
-
```ts
|
|
60
|
-
// server/orders.ts, the next link in the chain:
|
|
74
|
+
```ts server/return-one.ts
|
|
61
75
|
import { createServer } from 'experimental-a2/server'
|
|
62
76
|
import { orders } from '@/contracts'
|
|
63
77
|
|
|
64
|
-
export const
|
|
78
|
+
export const assignments = createServer({
|
|
79
|
+
contract: orders,
|
|
80
|
+
handlers: {
|
|
81
|
+
'shop.started': async () => {
|
|
82
|
+
return { type: 'driver.notified', payload: { driverId: 'driver-7' } }
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A handler can return one event or an array:
|
|
89
|
+
|
|
90
|
+
```ts server/return-many.ts
|
|
91
|
+
import { createServer } from 'experimental-a2/server'
|
|
92
|
+
import { orders } from '@/contracts'
|
|
93
|
+
|
|
94
|
+
export const fanout = createServer({
|
|
95
|
+
contract: orders,
|
|
96
|
+
handlers: {
|
|
97
|
+
'shop.started': async () => {
|
|
98
|
+
return orders.batch(
|
|
99
|
+
{ type: 'shop.notified', payload: {} },
|
|
100
|
+
{ type: 'driver.notified', payload: { driverId: 'driver-7' } },
|
|
101
|
+
)
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`contract.batch(...)` keeps each event's literal type when an async handler
|
|
108
|
+
returns different event types. It is a type helper; validation still happens
|
|
109
|
+
when A2 commits the returned events.
|
|
110
|
+
|
|
111
|
+
A2 marks the triggering event processed and appends the returned batch in one
|
|
112
|
+
atomic storage operation. The batch is all-or-nothing and occupies consecutive
|
|
113
|
+
log positions. If the handler throws or the process dies first, none of the
|
|
114
|
+
returned events exist.
|
|
115
|
+
|
|
116
|
+
Returned events receive deterministic ids. If A2 commits the operation but
|
|
117
|
+
loses its acknowledgement, the retry recognizes the exact returned batch and
|
|
118
|
+
does not duplicate it.
|
|
119
|
+
|
|
120
|
+
## Append what should happen now
|
|
121
|
+
|
|
122
|
+
`ctx.session.append()` has different timing. It commits immediately, so the appended
|
|
123
|
+
event can start while its parent handler is still running:
|
|
124
|
+
|
|
125
|
+
```ts server/append-now.ts
|
|
126
|
+
import { createServer } from 'experimental-a2/server'
|
|
127
|
+
import { orders } from '@/contracts'
|
|
128
|
+
|
|
129
|
+
export const immediate = createServer({
|
|
65
130
|
contract: orders,
|
|
66
131
|
handlers: {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
132
|
+
created: async ({ session }) => {
|
|
133
|
+
await session.append('notify-shop', {
|
|
134
|
+
type: 'shop.notified',
|
|
135
|
+
payload: {},
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
// The shop.notified handler may already be running here.
|
|
139
|
+
// await continueCurrentWork()
|
|
71
140
|
},
|
|
141
|
+
'shop.notified': async () => {},
|
|
72
142
|
},
|
|
73
143
|
})
|
|
74
144
|
```
|
|
75
145
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
146
|
+
Use it for streaming progress, independent fan-out, or any fact that remains
|
|
147
|
+
true even if the current handler later fails. A multi-event
|
|
148
|
+
`ctx.session.append()` is atomic per call.
|
|
149
|
+
|
|
150
|
+
The first argument names this immediate append within the triggering event.
|
|
151
|
+
It is the handler-local idempotency key for events whose `id` is omitted. Keep
|
|
152
|
+
it stable across retries:
|
|
153
|
+
|
|
154
|
+
```ts
|
|
155
|
+
// inside a handler:
|
|
156
|
+
await session.append(
|
|
157
|
+
'publish-build',
|
|
158
|
+
{ type: 'artifact.created', payload: artifact },
|
|
159
|
+
{ type: 'build.completed', payload: result },
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
A2 combines the triggering event id, this name, and each unnamed event's
|
|
164
|
+
position to derive deterministic child ids. Parallel branches can finish in
|
|
165
|
+
any order without changing their identities. Reusing a name with the same
|
|
166
|
+
generated-id batch returns the existing events. Changing that batch conflicts
|
|
167
|
+
with the rows already stored under those ids. An explicit event `id` wins over
|
|
168
|
+
the generated id, which is useful when different triggering events must
|
|
169
|
+
converge on one fact. Top-level `server.session(id).append(...events)` takes no
|
|
170
|
+
name because it is not scoped to a handler.
|
|
171
|
+
|
|
172
|
+
The distinction is small and important:
|
|
173
|
+
|
|
174
|
+
| Syntax | The event becomes durable |
|
|
175
|
+
| ------ | ------------------------- |
|
|
176
|
+
| `return event` | Atomically when the handler completes successfully |
|
|
177
|
+
| `return [a, b]` | Both atomically when the handler completes successfully |
|
|
178
|
+
| `await session.append(name, event)` | Immediately, before the handler completes |
|
|
179
|
+
|
|
180
|
+
## Concurrent by default
|
|
181
|
+
|
|
182
|
+
Every eligible event has its own durable claim. Independent handlers in the
|
|
183
|
+
same session start without waiting for earlier handlers:
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// anywhere on the server:
|
|
187
|
+
await ordersServer.session('order-42').append(
|
|
188
|
+
{ type: 'created', payload: { shopId: 'shop-7', items: [] } },
|
|
189
|
+
{ type: 'shop.started', payload: {} },
|
|
190
|
+
)
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The log order remains stable, but handler start and completion order do not.
|
|
194
|
+
A failure retries that event without holding up unlaned events or other lanes.
|
|
195
|
+
|
|
196
|
+
## Lanes
|
|
197
|
+
|
|
198
|
+
Use a lane when events share a resource and must not overlap:
|
|
199
|
+
|
|
200
|
+
```ts server/serial-orders.ts
|
|
201
|
+
import { createServer } from 'experimental-a2/server'
|
|
202
|
+
import { orders } from '@/contracts'
|
|
203
|
+
|
|
204
|
+
export const serialOrders = createServer({
|
|
205
|
+
contract: orders,
|
|
206
|
+
handlers: {
|
|
207
|
+
created: {
|
|
208
|
+
lane: 'order-work',
|
|
209
|
+
handler: async () => {},
|
|
210
|
+
},
|
|
211
|
+
'shop.started': {
|
|
212
|
+
lane: 'order-work',
|
|
213
|
+
handler: async () => {},
|
|
214
|
+
},
|
|
215
|
+
},
|
|
216
|
+
})
|
|
217
|
+
```
|
|
79
218
|
|
|
80
|
-
|
|
81
|
-
|
|
219
|
+
Within one session, events with the same lane run one at a time in log order.
|
|
220
|
+
Different lanes and unlaned events remain concurrent. A constant lane like
|
|
221
|
+
`'order-work'` recreates a serial queue for the event types that use it.
|
|
82
222
|
|
|
83
|
-
|
|
223
|
+
A lane can also be derived from the validated event:
|
|
84
224
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
coordination; two orders never wait on each other.
|
|
225
|
+
```ts server/drivers.ts
|
|
226
|
+
import { createServer } from 'experimental-a2/server'
|
|
227
|
+
import { orders } from '@/contracts'
|
|
89
228
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
event
|
|
229
|
+
export const drivers = createServer({
|
|
230
|
+
contract: orders,
|
|
231
|
+
handlers: {
|
|
232
|
+
'driver.notified': {
|
|
233
|
+
lane: ({ event }) => `driver:${event.payload.driverId}`,
|
|
234
|
+
handler: async ({ event }) => {
|
|
235
|
+
// your work for this driver, using event.payload.driverId
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
})
|
|
240
|
+
```
|
|
95
241
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
platform's function duration, every recovery attempt reaches the same limit.
|
|
100
|
-
Break that work into a chain of smaller events, or give the function enough
|
|
101
|
-
time for one handler to finish.
|
|
242
|
+
The resolved lane is stored with the event when it is appended. Recovery uses
|
|
243
|
+
that stored value, so a deployment cannot reinterpret pending work. Lane keys
|
|
244
|
+
are scoped to one session. They are not distributed locks across sessions.
|
|
102
245
|
|
|
103
|
-
If a
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
history whose earlier handlers never ran; stalling is the honest behavior.
|
|
107
|
-
The retry story is in [Durability](/concepts/durability).
|
|
246
|
+
If a lane's first unfinished event fails, later events in that lane wait.
|
|
247
|
+
Other lanes continue. After ten caught failures the event dead-letters and
|
|
248
|
+
keeps blocking its lane until manual resolution.
|
|
108
249
|
|
|
109
250
|
## Handlers can run twice
|
|
110
251
|
|
|
111
252
|
Handlers are at-least-once. A crash after your side effect but before A2
|
|
112
|
-
|
|
113
|
-
a non-issue:
|
|
253
|
+
commits completion means the handler can run again.
|
|
114
254
|
|
|
115
255
|
1. **External side effects take an idempotency key.** `event.id` is stable
|
|
116
|
-
across
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
that dedupes every append. No special case, no checkpoint state.
|
|
132
|
-
:::
|
|
133
|
-
|
|
134
|
-
One caveat on rule 2: a handler that appends in a data-dependent order
|
|
135
|
-
(looping over results from an external API, say) should pass explicit `id`s,
|
|
136
|
-
because "the same call position" isn't stable when the loop changes.
|
|
256
|
+
across attempts. Pass it to anything that should not happen twice.
|
|
257
|
+
|
|
258
|
+
2. **Immediate handler appends are deduplicated.**
|
|
259
|
+
`ctx.session.append(name, ...events)` derives ids from the triggering event,
|
|
260
|
+
the stable handler-local name, and each event's position. A retry gets the
|
|
261
|
+
existing rows back. A changed generated-id batch conflicts with those
|
|
262
|
+
existing rows.
|
|
263
|
+
|
|
264
|
+
3. **Returned events commit with completion.** They either all exist with the
|
|
265
|
+
processed marker or none exist.
|
|
266
|
+
|
|
267
|
+
A handler should give every independent immediate append a stable name. The
|
|
268
|
+
name removes any dependence on the order in which concurrent branches call
|
|
269
|
+
`append`. Use explicit event ids when several different triggering events must
|
|
270
|
+
converge on one shared fact.
|
|
137
271
|
|
|
138
272
|
## When handlers throw
|
|
139
273
|
|
|
140
|
-
Throwing means "retry me"
|
|
141
|
-
|
|
142
|
-
|
|
274
|
+
Throwing means "retry me." A2 records the failure and retries with backoff.
|
|
275
|
+
The failed event blocks only its lane. After ten caught failures it
|
|
276
|
+
dead-letters for [manual resolution](/concepts/durability#when-a-handler-keeps-failing).
|
|
143
277
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
278
|
+
An interrupted handler should catch and return normally. Throwing is the
|
|
279
|
+
wrong response to someone pressing stop. See
|
|
280
|
+
[Cancellation](/guides/cancellation).
|