experimental-a2 0.17.0 → 0.18.0

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