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.
Files changed (68) hide show
  1. package/CHANGELOG.md +128 -0
  2. package/dist/ai-server.browser.d.ts +1 -0
  3. package/dist/ai-server.browser.js +4 -0
  4. package/dist/ai-server.d.ts +65 -0
  5. package/dist/ai-server.js +494 -0
  6. package/dist/ai.d.ts +282 -0
  7. package/dist/ai.js +922 -0
  8. package/dist/cache-indexeddb.d.ts +1 -0
  9. package/dist/cache-indexeddb.js +0 -0
  10. package/dist/client.d.ts +90 -0
  11. package/dist/client.js +410 -0
  12. package/dist/contract-B0kAXoaL.js +60 -0
  13. package/dist/contract-DL8btVd9.d.ts +161 -0
  14. package/dist/devtools-server.browser.d.ts +1 -0
  15. package/dist/devtools-server.browser.js +4 -0
  16. package/dist/devtools-server.d.ts +22 -0
  17. package/dist/devtools-server.js +1087 -0
  18. package/dist/errors-BJRMd-h6.js +23 -0
  19. package/dist/errors-xL_JTXsY.d.ts +20 -0
  20. package/dist/http.d.ts +44 -0
  21. package/dist/http.js +119 -0
  22. package/dist/index.d.ts +5 -0
  23. package/dist/index.js +3 -0
  24. package/dist/inspection-E7qbD0Xj.js +10 -0
  25. package/dist/internal-Dm8Ejnud.js +36 -0
  26. package/dist/log-Dg1I8NRr.d.ts +245 -0
  27. package/dist/log-memory.d.ts +11 -0
  28. package/dist/log-memory.js +345 -0
  29. package/dist/log-polling-RO7kclzR.js +83 -0
  30. package/dist/log-postgres.d.ts +40 -0
  31. package/dist/log-postgres.js +628 -0
  32. package/dist/log-redis.d.ts +31 -0
  33. package/dist/log-redis.js +711 -0
  34. package/dist/log-sqlite.d.ts +17 -0
  35. package/dist/log-sqlite.js +450 -0
  36. package/dist/log-yJbXUf72.js +5 -0
  37. package/dist/otel.d.ts +12 -0
  38. package/dist/otel.js +41 -0
  39. package/dist/react.d.ts +54 -0
  40. package/dist/react.js +85 -0
  41. package/dist/recovery-vercel.d.ts +60 -0
  42. package/dist/recovery-vercel.js +120 -0
  43. package/dist/retryable-lazy-DZWmHpii.js +19 -0
  44. package/dist/server-DYsnKTTy.js +780 -0
  45. package/dist/server.browser.d.ts +1 -0
  46. package/dist/server.browser.js +11 -0
  47. package/dist/server.d.ts +136 -0
  48. package/dist/server.js +2 -0
  49. package/dist/telemetry-C78al20p.d.ts +32 -0
  50. package/dist/validate-XKT4FSNn.js +28 -0
  51. package/dist/wire-2QpU1EtJ.js +62 -0
  52. package/docs/01-quickstart.mdx +214 -0
  53. package/docs/concepts/01-contracts.mdx +138 -0
  54. package/docs/concepts/02-handlers.mdx +146 -0
  55. package/docs/concepts/03-durability.mdx +230 -0
  56. package/docs/concepts/04-state.mdx +133 -0
  57. package/docs/guides/01-timers.mdx +85 -0
  58. package/docs/guides/02-cancellation.mdx +107 -0
  59. package/docs/guides/03-react.mdx +234 -0
  60. package/docs/guides/04-local-first.mdx +88 -0
  61. package/docs/guides/05-production.mdx +179 -0
  62. package/docs/guides/06-ai-agents.mdx +659 -0
  63. package/docs/guides/07-devtools.mdx +101 -0
  64. package/docs/guides/08-application-data.mdx +114 -0
  65. package/docs/index.mdx +282 -0
  66. package/docs/reference/01-api.mdx +637 -0
  67. package/docs/reference/02-errors.mdx +77 -0
  68. 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.