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,146 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Handlers
|
|
3
|
+
description: Stateless async functions that react to one event, and usually append the next one.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
```ts server/orders.ts
|
|
7
|
+
import { createServer } from 'experimental-a2/server'
|
|
8
|
+
import { orders } from '@/contracts'
|
|
9
|
+
|
|
10
|
+
export const ordersServer = createServer({
|
|
11
|
+
contract: orders,
|
|
12
|
+
handlers: {
|
|
13
|
+
created: async ({ event, append }) => {
|
|
14
|
+
// your side effect, e.g. email the shop, idempotent via event.id:
|
|
15
|
+
// await sendEmailToShop(event.payload, { idempotencyKey: event.id })
|
|
16
|
+
await append({ type: 'shop.notified', payload: {} })
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
```
|
|
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.
|
|
28
|
+
|
|
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).
|
|
35
|
+
|
|
36
|
+
## The context
|
|
37
|
+
|
|
38
|
+
Every handler receives one argument:
|
|
39
|
+
|
|
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). |
|
|
47
|
+
|
|
48
|
+
`ctx.attempt` starts at `1` and increments on every durable claim. It may skip
|
|
49
|
+
when a process dies between the claim and handler entry.
|
|
50
|
+
|
|
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.
|
|
54
|
+
|
|
55
|
+
## Chaining
|
|
56
|
+
|
|
57
|
+
The core pattern: handlers append the event that should happen next.
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
// server/orders.ts, the next link in the chain:
|
|
61
|
+
import { createServer } from 'experimental-a2/server'
|
|
62
|
+
import { orders } from '@/contracts'
|
|
63
|
+
|
|
64
|
+
export const ordersServer = createServer({
|
|
65
|
+
contract: orders,
|
|
66
|
+
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 } })
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
```
|
|
75
|
+
|
|
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.
|
|
79
|
+
|
|
80
|
+
`append` takes one or more events. A multi-event append is atomic:
|
|
81
|
+
all-or-nothing, consecutive log positions, one transaction.
|
|
82
|
+
|
|
83
|
+
## Ordering
|
|
84
|
+
|
|
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.
|
|
89
|
+
|
|
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.
|
|
95
|
+
|
|
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.
|
|
102
|
+
|
|
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).
|
|
108
|
+
|
|
109
|
+
## Handlers can run twice
|
|
110
|
+
|
|
111
|
+
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:
|
|
114
|
+
|
|
115
|
+
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.
|
|
137
|
+
|
|
138
|
+
## When handlers throw
|
|
139
|
+
|
|
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).
|
|
143
|
+
|
|
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).
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Durability
|
|
3
|
+
description: Appends are effectively-once, handlers are at-least-once, and retries preserve event identity across crashes.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## The contract
|
|
7
|
+
|
|
8
|
+
- **Appends are effectively-once.** Event ids deduplicate writes. When
|
|
9
|
+
`append` returns, the event is in the log.
|
|
10
|
+
- **Handlers are at-least-once.** They may run again after a crash, so
|
|
11
|
+
[side effects use idempotency keys](/concepts/handlers#handlers-can-run-twice).
|
|
12
|
+
- **Order holds per session.** Events are processed in log order. A2 never
|
|
13
|
+
dispatches past a pending event.
|
|
14
|
+
|
|
15
|
+
## The correctness model
|
|
16
|
+
|
|
17
|
+
The log says what needs work. The lease says who may work now. The processed
|
|
18
|
+
marker says what finished. The watchdog says when to look again.
|
|
19
|
+
|
|
20
|
+
```text
|
|
21
|
+
+-----------------+
|
|
22
|
+
| Wakeups |
|
|
23
|
+
| |
|
|
24
|
+
| append |
|
|
25
|
+
| watchdog |
|
|
26
|
+
| state / stream |
|
|
27
|
+
+--------+--------+
|
|
28
|
+
|
|
|
29
|
+
v
|
|
30
|
+
+-----------------+
|
|
31
|
+
| drain(session) |
|
|
32
|
+
+--------+--------+
|
|
33
|
+
|
|
|
34
|
+
v
|
|
35
|
+
+-----------------+
|
|
36
|
+
| claimNext |
|
|
37
|
+
+--------+--------+
|
|
38
|
+
|
|
|
39
|
+
+----+----+
|
|
40
|
+
| |
|
|
41
|
+
v v
|
|
42
|
+
+----------+ +-----------------+
|
|
43
|
+
| return | | run handler |
|
|
44
|
+
| busy or | | claimed event |
|
|
45
|
+
| settled | +-----------------+
|
|
46
|
+
+----------+
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
A queue message names a session. It carries no event index or continuation
|
|
50
|
+
state. Every wakeup runs the same drain, and the log decides what remains.
|
|
51
|
+
Append follows its causal tree; recovery and explicit `server.drain()` inspect
|
|
52
|
+
the full session. Reads never dispatch handlers.
|
|
53
|
+
|
|
54
|
+
:::note[Recovery is not an event]
|
|
55
|
+
A2 does not append `recovered` or `continued`. Recovery retries the same event.
|
|
56
|
+
Your log contains only application facts.
|
|
57
|
+
:::
|
|
58
|
+
|
|
59
|
+
## One event, many attempts
|
|
60
|
+
|
|
61
|
+
An event is pending until it has a processed marker. Attempt tracking is part
|
|
62
|
+
of the operations that already claim and advance that event:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
+----------------------------+ one backend operation
|
|
66
|
+
| claimNext |
|
|
67
|
+
| head + lease + attempt |
|
|
68
|
+
+-------------+--------------+
|
|
69
|
+
|
|
|
70
|
+
v
|
|
71
|
+
+----------------------------+
|
|
72
|
+
| handler 1 |
|
|
73
|
+
+-------------+--------------+
|
|
74
|
+
|
|
|
75
|
+
v
|
|
76
|
+
+----------------------------+ one backend operation
|
|
77
|
+
| completeAndClaimNext |
|
|
78
|
+
| complete 1 + claim 2 |
|
|
79
|
+
+-------------+--------------+
|
|
80
|
+
|
|
|
81
|
+
v
|
|
82
|
+
+----------------------------+
|
|
83
|
+
| handler 2 |
|
|
84
|
+
+----------------------------+
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`ctx.attempt` is the durable, 1-based claim ordinal. A kill after the claim can
|
|
88
|
+
consume an ordinal before user code sees it, but adds no caught failure. A
|
|
89
|
+
retry gets the same event id, index, type, and payload with a larger attempt.
|
|
90
|
+
|
|
91
|
+
Use `event.id` to deduplicate external effects. `ctx.append` derives stable
|
|
92
|
+
child ids when call order is stable. Otherwise, pass
|
|
93
|
+
[explicit child ids](/concepts/handlers#handlers-can-run-twice).
|
|
94
|
+
|
|
95
|
+
## Append and recovery
|
|
96
|
+
|
|
97
|
+
A top-level append commits first, then starts the inline drain and optional
|
|
98
|
+
watchdog arm in parallel. The drain never waits for the queue. Append joins the
|
|
99
|
+
arm for at most two seconds; on failure, its telemetry span records
|
|
100
|
+
`a2.append.armed = false`, but committed work continues. `ctx.append` rides the
|
|
101
|
+
active drain and adds no arm.
|
|
102
|
+
|
|
103
|
+
## Causal trees are durable
|
|
104
|
+
|
|
105
|
+
Every event written by `ctx.append` stores one atomic `cause` with the
|
|
106
|
+
triggering event's index and current dispatch ordinal. Top-level appends store
|
|
107
|
+
null. In newly written data that is a root, but durable readers also use null
|
|
108
|
+
for legacy events whose origin is unknown. Several appends from one handler are
|
|
109
|
+
siblings. Nested handler appends create further levels. If a handler retries
|
|
110
|
+
after writing a child, idempotency preserves that child's original cause.
|
|
111
|
+
|
|
112
|
+
The edge is part of the existing append batch and survives process restarts.
|
|
113
|
+
The stored event also carries `firstClaimedAt`, `lastClaimedAt`,
|
|
114
|
+
`lastFailedAt`, `lastFailedAttempt`, `processedAt`, and `processedByAttempt`.
|
|
115
|
+
These timestamps are adapter clock values captured for atomic log operations,
|
|
116
|
+
not exact database commit times. `processedAt` is write-once, so a stale worker
|
|
117
|
+
cannot rewrite a newer completion. The durable log can therefore rebuild a
|
|
118
|
+
causal forest and show useful lifecycle boundaries. `attemptCount` summarizes
|
|
119
|
+
intermediate claims instead of storing one row per attempt.
|
|
120
|
+
|
|
121
|
+
## Lease and watchdog timing
|
|
122
|
+
|
|
123
|
+
A heartbeat renews the session lease and arms a watchdog after each lease
|
|
124
|
+
window. For illustration, use a five-second lease, two-second heartbeat, and
|
|
125
|
+
one-second grace:
|
|
126
|
+
|
|
127
|
+
| Time | Live worker | Lease | Watchdog |
|
|
128
|
+
| ---: | --- | --- | --- |
|
|
129
|
+
| t0 | Claim attempt 1 | Through t5 | Arm t6 |
|
|
130
|
+
| t2 | Heartbeat | Through t7 | Arm t8 |
|
|
131
|
+
| t4 | Heartbeat | Through t9 | Arm t10 |
|
|
132
|
+
| t6 | Handler runs | Through t9 | Deliver t6: `busy`; ensure a later slot |
|
|
133
|
+
| t7 | Process dies | Abandoned | No more arms |
|
|
134
|
+
| t8 | | Through t9 | Deliver t8: `busy`; ensure a later slot |
|
|
135
|
+
| t9 | | Expires | t10 remains scheduled |
|
|
136
|
+
| t10 | Claim attempt 2 | New window | Deliver t10: wins |
|
|
137
|
+
|
|
138
|
+
Lease renewal and queue sends do not block each other. A busy watchdog ensures
|
|
139
|
+
a later watchdog before acknowledging; if that send fails, it does not
|
|
140
|
+
acknowledge. If a deadline is available, A2 caps the current lease window at
|
|
141
|
+
it. The deadline never gates handler dispatch.
|
|
142
|
+
|
|
143
|
+
## A chain that crosses the function timeout
|
|
144
|
+
|
|
145
|
+
Four eight-second handlers do not fit together in a 20-second invocation, but
|
|
146
|
+
each fits in a fresh one:
|
|
147
|
+
|
|
148
|
+
| Time | Work | Durable state |
|
|
149
|
+
| ---: | --- | --- |
|
|
150
|
+
| t0 | Handler 1 starts | Attempt 1 |
|
|
151
|
+
| t8 | Handler 1 finishes; handler 2 starts | Event 1 processed |
|
|
152
|
+
| t16 | Handler 2 finishes; handler 3 starts | Events 1 and 2 processed |
|
|
153
|
+
| t20 | Function is killed | Event 3 pending; `failureCount = 0` |
|
|
154
|
+
| t21 | Handler 3 starts again | Same event, attempt 2 |
|
|
155
|
+
| t29 | Handler 3 finishes; handler 4 starts | Events 1 through 3 processed |
|
|
156
|
+
| t37 | Handler 4 finishes | Session settled |
|
|
157
|
+
|
|
158
|
+
The retry restarts handler 3; it does not resume its old call.
|
|
159
|
+
|
|
160
|
+
One handler that always exceeds a fresh invocation cannot finish this way. A2
|
|
161
|
+
cannot checkpoint arbitrary async code. Split the work into smaller events or
|
|
162
|
+
increase the function duration.
|
|
163
|
+
|
|
164
|
+
## Crash after a child append
|
|
165
|
+
|
|
166
|
+
```text
|
|
167
|
+
+------------------------+
|
|
168
|
+
| Invocation A |
|
|
169
|
+
| event 2, attempt 1 |
|
|
170
|
+
| append child event 3 |
|
|
171
|
+
+-----------+------------+
|
|
172
|
+
|
|
|
173
|
+
v
|
|
174
|
+
+------------------------+
|
|
175
|
+
| Durable log |
|
|
176
|
+
| event 2: pending |
|
|
177
|
+
| event 3: pending |
|
|
178
|
+
+-----------+------------+
|
|
179
|
+
|
|
|
180
|
+
SIGKILL, then lease expiry
|
|
181
|
+
|
|
|
182
|
+
v
|
|
183
|
+
+------------------------+
|
|
184
|
+
| Invocation B |
|
|
185
|
+
| event 2, attempt 2 |
|
|
186
|
+
| append reuses event 3 |
|
|
187
|
+
+-----------+------------+
|
|
188
|
+
|
|
|
189
|
+
v
|
|
190
|
+
complete 2, drain 3
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The deterministic child id makes the retry reuse event 3. External effects
|
|
194
|
+
use event 2's id as their idempotency key.
|
|
195
|
+
|
|
196
|
+
## Why this stays correct
|
|
197
|
+
|
|
198
|
+
| Property | Durable rule |
|
|
199
|
+
| --- | --- |
|
|
200
|
+
| Commit | Store the event before any wakeup. |
|
|
201
|
+
| Order | Claim only the lowest pending index. |
|
|
202
|
+
| Ownership | Only a live lease holder can claim the next event. |
|
|
203
|
+
| Handoff | Complete the current event and claim the next atomically. |
|
|
204
|
+
| Retry | Without a processed marker, the same event remains pending. |
|
|
205
|
+
| Identity | Event and derived child ids stay stable across retries. |
|
|
206
|
+
| Liveness | A watchdog, later append, or explicit drain re-enters `drain(session)`. |
|
|
207
|
+
|
|
208
|
+
## When a handler keeps failing
|
|
209
|
+
|
|
210
|
+
A caught failure increments `failureCount`; a stale failure changes nothing.
|
|
211
|
+
Ten caught failures dead-letter the event and stall that session. Hard kills
|
|
212
|
+
do not consume this budget. Resolution is manual: fix and retry, or skip.
|
|
213
|
+
|
|
214
|
+
## Limits
|
|
215
|
+
|
|
216
|
+
| Situation | Result |
|
|
217
|
+
| --- | --- |
|
|
218
|
+
| Process dies before the first arm is durable | Retry with the same ids, append again later, or call `drain()`. |
|
|
219
|
+
| No recovery configured | A later top-level append or explicit `drain()` wakes the session. Reads never do. |
|
|
220
|
+
| Recovery dies after claiming but before arming | Its unacknowledged queue delivery is the slower fallback. |
|
|
221
|
+
| Log or lease backend unavailable | Safe progress stops until it returns. |
|
|
222
|
+
|
|
223
|
+
## What append never throws for
|
|
224
|
+
|
|
225
|
+
Append success means the event is durable, not that all handlers finished. An
|
|
226
|
+
identical batch retry returns rows with the same ids. A mixed batch throws
|
|
227
|
+
[`PARTIAL_DUPLICATE_BATCH`](/reference/errors).
|
|
228
|
+
|
|
229
|
+
For the queue route and deployment configuration, see
|
|
230
|
+
[Going to production](/guides/production#2-add-recovery).
|
|
@@ -0,0 +1,133 @@
|
|
|
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?"
|
|
17
|
+
|
|
18
|
+
## Reducers
|
|
19
|
+
|
|
20
|
+
For a computed view, derive a reducer from the contract and fold:
|
|
21
|
+
|
|
22
|
+
```ts contracts.ts
|
|
23
|
+
import { z } from 'zod'
|
|
24
|
+
import * as a2 from 'experimental-a2'
|
|
25
|
+
|
|
26
|
+
export const orders = a2.contract({
|
|
27
|
+
name: 'orders',
|
|
28
|
+
events: {
|
|
29
|
+
created: z.object({ shopId: z.string(), items: z.array(z.string()) }),
|
|
30
|
+
'shop.notified': z.object({}),
|
|
31
|
+
expired: z.object({}),
|
|
32
|
+
},
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
```ts reducer.ts
|
|
37
|
+
import { z } from 'zod'
|
|
38
|
+
import { orders } from './contracts'
|
|
39
|
+
|
|
40
|
+
export const ordersReducer = orders
|
|
41
|
+
.reducer({
|
|
42
|
+
name: 'order-status',
|
|
43
|
+
initialState: { status: 'new' },
|
|
44
|
+
stateSchema: z.object({
|
|
45
|
+
status: z.enum(['new', 'created', 'notified', 'expired']),
|
|
46
|
+
shopId: z.string().optional(),
|
|
47
|
+
}),
|
|
48
|
+
})
|
|
49
|
+
.fold((state, event) => {
|
|
50
|
+
switch (event.type) {
|
|
51
|
+
case 'created':
|
|
52
|
+
return { status: 'created', shopId: event.payload.shopId }
|
|
53
|
+
case 'shop.notified':
|
|
54
|
+
return { ...state, status: 'notified' }
|
|
55
|
+
case 'expired':
|
|
56
|
+
return { ...state, status: 'expired' }
|
|
57
|
+
default:
|
|
58
|
+
return state
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
// anywhere on the server, e.g. a server component before first paint:
|
|
65
|
+
const { state, index } = await ordersServer
|
|
66
|
+
.session(orderId)
|
|
67
|
+
.state(ordersReducer)
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
A reducer is a name (its identity, more on that below) plus the values that
|
|
71
|
+
anchor it: `initialState` (the seed), an optional `stateSchema`, and the
|
|
72
|
+
pure `fold`, `(state, event) => state`. It's derived *from* the
|
|
73
|
+
contract, so the events type themselves; the two-step shape is
|
|
74
|
+
deliberate: the first call fixes the state and event types, and
|
|
75
|
+
`.fold()` receives fully concrete ones. No type arguments, no
|
|
76
|
+
annotations, and literal unions (like the `z.enum` status above) survive
|
|
77
|
+
the fold intact. `state()` folds the session's events through it and
|
|
78
|
+
returns the result, along with `index`: the log position the state
|
|
79
|
+
reflects. The browser uses that index to resume a live stream exactly
|
|
80
|
+
where server-rendered state left off. See [Live UI](/guides/react).
|
|
81
|
+
|
|
82
|
+
The snapshot and its remaining event tail come back in one consistent log
|
|
83
|
+
operation. A missing snapshot reads the full log. A snapshot rejected by
|
|
84
|
+
`stateSchema`, or a failed cache read, falls back to the full log and rebuilds
|
|
85
|
+
from truth. `state()` is observational: it never runs handlers or waits for
|
|
86
|
+
pending work to finish.
|
|
87
|
+
|
|
88
|
+
`stateSchema` declares the state's shape once. Without it, the state type
|
|
89
|
+
is inferred from `initialState`, fine while every fold arm returns the
|
|
90
|
+
same shape. With it, the schema is the type (and the seed may lean on its
|
|
91
|
+
defaults), `initialState` is validated at definition, and anywhere A2
|
|
92
|
+
rehydrates folded state instead of refolding raw events, [server
|
|
93
|
+
snapshots](#snapshots-are-a-cache), the [browser
|
|
94
|
+
cache](/guides/local-first): a cached fold that fails the schema is
|
|
95
|
+
discarded and refolded, catching shape drift a stale `name` can't.
|
|
96
|
+
|
|
97
|
+
Note what these modules import: schemas and `experimental-a2`. Never `experimental-a2/server`,
|
|
98
|
+
never a log backend. Contract and reducer are isomorphic by
|
|
99
|
+
construction; the browser runs the same reducer. More on the split in
|
|
100
|
+
[Live UI](/guides/react#keep-the-backend-out-of-the-bundle). (In a
|
|
101
|
+
server-only app you can keep the contract next to `createServer`
|
|
102
|
+
instead of in its own file.)
|
|
103
|
+
|
|
104
|
+
## Snapshots are a cache
|
|
105
|
+
|
|
106
|
+
Folding a long session on every read would get slow, so the log backend
|
|
107
|
+
caches folded state as a snapshot. You never interact with it, except for
|
|
108
|
+
one string.
|
|
109
|
+
|
|
110
|
+
Snapshots are keyed by the reducer's `name`, which makes the name do two
|
|
111
|
+
jobs. It's the identity: two different reducers over the same session
|
|
112
|
+
never fight over a cache entry, because they have different names. And
|
|
113
|
+
it's the invalidation knob. Changed the fold's logic? Change the name (a
|
|
114
|
+
`-v2` suffix works). Snapshots stored under the old name are simply
|
|
115
|
+
ignored; the next read refolds from raw events and caches under the new
|
|
116
|
+
name. That's the entire cache invalidation story: one string.
|
|
117
|
+
|
|
118
|
+
Snapshot write-back runs in platform `waitUntil` after the state is ready. It
|
|
119
|
+
never delays the read, and a failed or interrupted cache write changes no
|
|
120
|
+
application behavior. The next read folds the missing tail again.
|
|
121
|
+
|
|
122
|
+
Deleting every snapshot is always safe. The log rebuilds them.
|
|
123
|
+
|
|
124
|
+
## Events don't change meaning
|
|
125
|
+
|
|
126
|
+
The log is append-only, and events are immutable in meaning: `created`
|
|
127
|
+
means forever what it meant when it was written. When a payload needs a new
|
|
128
|
+
shape, add a new event type (`created.v2`) rather than redefining the old
|
|
129
|
+
one.
|
|
130
|
+
|
|
131
|
+
This is why reducers accumulate switch cases over time, and why they never
|
|
132
|
+
migrate anything: old sessions keep their old events, new sessions write
|
|
133
|
+
new ones, and the same fold handles both.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Timers and delays
|
|
3
|
+
description: There is no sleep(). A delayed action is an event, delivered later, by anything that can make an HTTP call.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## There is no `sleep()`
|
|
7
|
+
|
|
8
|
+
Workflow engines let you sleep inside a function, then perform heroics to
|
|
9
|
+
make that survive a serverless platform. A2 doesn't. If something should
|
|
10
|
+
happen in five days, then in five days, something should append an event.
|
|
11
|
+
That's scheduling.
|
|
12
|
+
|
|
13
|
+
The scheduled thing is data (a session id and an event), not a suspended
|
|
14
|
+
function. No closure has to survive the gap.
|
|
15
|
+
|
|
16
|
+
## Schedule an event
|
|
17
|
+
|
|
18
|
+
Use any scheduler that can deliver an HTTP call later: QStash, a cron, your
|
|
19
|
+
payment provider's webhook. It hits a route; the route appends.
|
|
20
|
+
|
|
21
|
+
```ts server/orders.ts
|
|
22
|
+
import { createServer } from 'experimental-a2/server'
|
|
23
|
+
import { orders } from '@/contracts'
|
|
24
|
+
|
|
25
|
+
export const ordersServer = createServer({
|
|
26
|
+
contract: orders,
|
|
27
|
+
handlers: {
|
|
28
|
+
created: async ({ event }) => {
|
|
29
|
+
// schedule with anything that can deliver an HTTP call later:
|
|
30
|
+
// QStash, cron, a provider webhook:
|
|
31
|
+
//
|
|
32
|
+
// await scheduleHttpCall({
|
|
33
|
+
// delay: '5d',
|
|
34
|
+
// url: '/api/append',
|
|
35
|
+
// body: { sessionId: event.sessionId, type: 'expired', payload: {} },
|
|
36
|
+
// })
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
```ts app/api/append/route.ts
|
|
43
|
+
import { ordersServer } from '@/server/orders'
|
|
44
|
+
|
|
45
|
+
export async function POST(req: Request) {
|
|
46
|
+
const { sessionId, type, payload } = await req.json()
|
|
47
|
+
await ordersServer.session(sessionId).append({ type, payload })
|
|
48
|
+
return Response.json({ ok: true })
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
:::warning
|
|
53
|
+
Auth this route like any other. Anything that can call it can move your
|
|
54
|
+
sessions forward.
|
|
55
|
+
:::
|
|
56
|
+
|
|
57
|
+
## Stale timers
|
|
58
|
+
|
|
59
|
+
The order shipped on day two. The expiry still fires on day five. Now what?
|
|
60
|
+
|
|
61
|
+
You could hunt down the scheduled call and cancel it. The simpler default,
|
|
62
|
+
and the recommended one, is to let the timer fire and have the handler
|
|
63
|
+
check whether it still applies. Guard on read:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// server/orders.ts, the expiry handler, guarding on read:
|
|
67
|
+
import { createServer } from 'experimental-a2/server'
|
|
68
|
+
import { orders } from '@/contracts'
|
|
69
|
+
|
|
70
|
+
export const ordersServer = createServer({
|
|
71
|
+
contract: orders,
|
|
72
|
+
handlers: {
|
|
73
|
+
expired: async ({ history }) => {
|
|
74
|
+
const events = await history()
|
|
75
|
+
if (events.some((e) => e.type === 'shop.started')) return // stale, ignore
|
|
76
|
+
// ...actually expire the order
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
})
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This works because history is truth. The timer doesn't need to know what
|
|
83
|
+
happened after it was scheduled; the handler can just look. The stale
|
|
84
|
+
`expired` event still lands in the log, and that's fine: it's a fact ("the
|
|
85
|
+
timer fired"), and the handler decided it was moot.
|