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.
Files changed (55) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/dist/ai-server.browser.js +2 -2
  3. package/dist/ai-server.d.ts +19 -7
  4. package/dist/ai-server.js +730 -96
  5. package/dist/ai.d.ts +32 -11
  6. package/dist/ai.js +253 -75
  7. package/dist/client.d.ts +1 -1
  8. package/dist/client.js +4 -4
  9. package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
  10. package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
  11. package/dist/devtools-server.browser.js +2 -2
  12. package/dist/devtools-server.js +1 -1
  13. package/dist/http.d.ts +1 -1
  14. package/dist/http.js +4 -3
  15. package/dist/idempotent-replay-BMyHrP0L.js +19 -0
  16. package/dist/index.d.ts +4 -4
  17. package/dist/index.js +1 -1
  18. package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
  19. package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
  20. package/dist/log-memory.d.ts +1 -1
  21. package/dist/log-memory.js +173 -96
  22. package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
  23. package/dist/log-postgres.d.ts +1 -1
  24. package/dist/log-postgres.js +235 -192
  25. package/dist/log-redis.d.ts +1 -1
  26. package/dist/log-redis.js +453 -263
  27. package/dist/log-sqlite.d.ts +1 -1
  28. package/dist/log-sqlite.js +216 -127
  29. package/dist/otel.d.ts +1 -1
  30. package/dist/otel.js +1 -1
  31. package/dist/react.d.ts +1 -1
  32. package/dist/react.js +1 -1
  33. package/dist/recovery-vercel.d.ts +2 -2
  34. package/dist/recovery-vercel.js +9 -10
  35. package/dist/server-DJgD2YWP.js +877 -0
  36. package/dist/server.browser.js +4 -4
  37. package/dist/server.d.ts +46 -27
  38. package/dist/server.js +1 -1
  39. package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
  40. package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
  41. package/docs/01-quickstart.mdx +7 -7
  42. package/docs/concepts/01-contracts.mdx +22 -22
  43. package/docs/concepts/02-handlers.mdx +223 -89
  44. package/docs/concepts/03-durability.mdx +199 -112
  45. package/docs/concepts/04-state.mdx +27 -1
  46. package/docs/guides/01-timers.mdx +4 -4
  47. package/docs/guides/02-cancellation.mdx +32 -4
  48. package/docs/guides/05-production.mdx +61 -27
  49. package/docs/guides/06-ai-agents.mdx +151 -70
  50. package/docs/guides/07-devtools.mdx +6 -3
  51. package/docs/guides/08-application-data.mdx +5 -6
  52. package/docs/index.mdx +30 -14
  53. package/docs/reference/01-api.mdx +305 -70
  54. package/package.json +31 -31
  55. package/dist/server-DYsnKTTy.js +0 -780
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  title: Handlers
3
- description: Stateless async functions that react to one event, and usually append the next one.
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, append }) => {
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
- await append({ type: 'shop.notified', payload: {} })
16
+ return { type: 'shop.notified', payload: {} }
17
17
  },
18
18
  },
19
19
  })
20
20
  ```
21
21
 
22
- That's a handler: it runs when its event lands, does its work, and appends
23
- what happens next. Handlers live in the server's construction, not on
24
- sessions. One table of reactions serves every session: `order-1`,
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
- They're also plain async functions. No determinism requirements, no replay,
30
- no wrappers around side effects. Call your database, hit an API, use
31
- `Math.random()`, stream a model response for a minute. A2 heartbeats the
32
- session's lease while your handler runs, so a slow handler never loses its
33
- exclusivity to a concurrent retry. The one thing you don't do in a handler
34
- is wait for the future. For that, [append an event later](/guides/timers).
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 | What it is |
41
- | -------------------- | --------------------------------------------------------------------------------------------- |
42
- | `ctx.event` | The triggering event: `{ id, type, payload, index, sessionId, createdAt }`. |
43
- | `ctx.attempt` | The durable 1-based dispatch claim for this event. |
44
- | `ctx.append(...e)` | Append what happens next to this session's log. Typed against the contract's schemas. |
45
- | `ctx.history()` | Every past event in this session, oldest first. Always the raw log. |
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
- Use `history()` when a handler needs more than the triggering event: "has
52
- this order already been notified twice?". For a cheap computed view of a
53
- long session, use a [reducer](/concepts/state) instead.
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
- ## Chaining
70
+ ## Return what happens after success
56
71
 
57
- The core pattern: handlers append the event that should happen next.
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 ordersServer = createServer({
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
- 'shop.started': async ({ append }) => {
68
- // const driverId = await acquireDriver(), however your app finds one
69
- const driverId = 'driver-7'
70
- await append({ type: 'driver.notified', payload: { driverId } })
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
- This is how a lifecycle moves in A2. It is not a workflow definition, but a
77
- chain of facts, each one triggering the next reaction. Afterward, the full
78
- story of the order is sitting in its log, readable top to bottom.
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
- `append` takes one or more events. A multi-event append is atomic:
81
- all-or-nothing, consecutive log positions, one transaction.
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
- ## Ordering
223
+ A lane can also be derived from the validated event:
84
224
 
85
- Within a session, handlers run serially, in log order. The handler for
86
- event 5 doesn't start until events 1 through 4 have finished, even when
87
- retries and queue deliveries arrive shuffled. Across sessions there's no
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
- Chained handlers run inline for as long as the current invocation lives. A2
91
- does not predict whether the next handler fits in the remaining function
92
- time. If the platform stops the invocation, completed events stay processed
93
- and the event that was still running stays pending. Recovery retries that same
94
- event under a fresh lease.
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
- Each individual handler must still fit inside a fresh invocation. A2 can
97
- recover a chain whose total duration crosses the function limit, but it cannot
98
- split one handler while it is running. If a handler always exceeds the
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 handler fails, the session stalls at that event. Later events keep
104
- accumulating in the log (appends never fail because a handler is failing),
105
- but their handlers wait. Skipping ahead would let a handler observe a
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
- marks the event processed means the handler runs again. Two rules make this
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 re-runs. Pass it to your email provider, your payment API,
117
- anything that shouldn't happen twice.
118
-
119
- 2. **`ctx.append` is deduplicated for you.** Each append call inside a
120
- handler gets a deterministic id derived from the triggering event and the
121
- call's position. A re-run produces the same ids, hits the log's unique
122
- index, and gets the existing rows back. The straight-line case needs no
123
- thought.
124
-
125
- :::note
126
- How? Without an explicit `id`, `ctx.append` derives one deterministically:
127
- a hash of the triggering event's id plus the call's position ("the second
128
- append this handler made"). A re-run reproduces the same ids, hits the
129
- log's unique index, and gets the original rows back. It's the same
130
- [idempotency machinery](/concepts/durability#what-append-never-throws-for)
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". A2 records the failure and retries with backoff
141
- until the handler succeeds, or, after ten caught failures, dead-letters the event
142
- and stalls the session for [manual resolution](/concepts/durability#when-a-handler-keeps-failing).
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
- The flip side: a handler interrupted by a user (via `ctx.signal`) should
145
- catch and return normally. "Retry me" is exactly the wrong response to
146
- someone pressing stop. See [Cancellation](/guides/cancellation).
278
+ An interrupted handler should catch and return normally. Throwing is the
279
+ wrong response to someone pressing stop. See
280
+ [Cancellation](/guides/cancellation).