experimental-a2 0.8.0 → 0.9.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 (182) hide show
  1. package/AGENTS.md +11 -0
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +29 -0
  4. package/dist/actor-client.d.ts +46 -0
  5. package/dist/actor-client.d.ts.map +1 -0
  6. package/dist/actor-client.js +54 -0
  7. package/dist/actor-client.js.map +1 -0
  8. package/dist/actor-react.d.ts +54 -0
  9. package/dist/actor-react.d.ts.map +1 -0
  10. package/dist/actor-react.js +79 -0
  11. package/dist/actor-react.js.map +1 -0
  12. package/dist/actor-shared-BACubf4x.d.ts +136 -0
  13. package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
  14. package/dist/actor-shared-DI7J5upy.js +127 -0
  15. package/dist/actor-shared-DI7J5upy.js.map +1 -0
  16. package/dist/actor.browser.d.ts +1 -0
  17. package/dist/actor.browser.js +13 -0
  18. package/dist/actor.browser.js.map +1 -0
  19. package/dist/actor.d.ts +176 -0
  20. package/dist/actor.d.ts.map +1 -0
  21. package/dist/actor.js +437 -0
  22. package/dist/actor.js.map +1 -0
  23. package/dist/ai-server.d.ts +2 -2
  24. package/dist/ai-server.js +2 -2
  25. package/dist/ai.d.ts +2 -2
  26. package/dist/client.d.ts +1 -1
  27. package/dist/client.d.ts.map +1 -1
  28. package/dist/client.js +4 -4
  29. package/dist/client.js.map +1 -1
  30. package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
  31. package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
  32. package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
  33. package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
  34. package/dist/index.d.ts +16 -3
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +2 -2
  37. package/dist/react.d.ts +1 -1
  38. package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
  39. package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
  40. package/dist/scheduler-qstash.d.ts +2 -2
  41. package/dist/scheduler-qstash.js +2 -2
  42. package/dist/scheduler-vercel.d.ts +2 -2
  43. package/dist/scheduler-vercel.js +1 -1
  44. package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
  45. package/dist/server-CBET-jSz.js.map +1 -0
  46. package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
  47. package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
  48. package/dist/server.d.ts +3 -3
  49. package/dist/server.js +1 -1
  50. package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
  51. package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
  52. package/dist/store-memory.d.ts +1 -1
  53. package/dist/store-memory.js +2 -2
  54. package/dist/store-postgres.d.ts +1 -1
  55. package/dist/store-postgres.js +2 -2
  56. package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
  57. package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
  58. package/dist/store-redis-http.d.ts +1 -1
  59. package/dist/store-redis-http.js +2 -2
  60. package/dist/store-redis.d.ts +1 -1
  61. package/dist/store-redis.js +2 -2
  62. package/dist/store-sqlite.d.ts +1 -1
  63. package/dist/store-sqlite.js +2 -2
  64. package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
  65. package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
  66. package/docs/actors/01-introduction.mdx +189 -0
  67. package/docs/actors/02-concurrency.mdx +154 -0
  68. package/docs/actors/03-timers.mdx +120 -0
  69. package/docs/actors/04-routes.mdx +352 -0
  70. package/docs/actors/meta.ts +1 -0
  71. package/docs/concepts/meta.ts +1 -0
  72. package/docs/guides/07-examples.mdx +56 -0
  73. package/docs/guides/meta.ts +1 -0
  74. package/docs/index.mdx +16 -0
  75. package/docs/reference/02-errors.mdx +33 -0
  76. package/docs/reference/meta.ts +1 -0
  77. package/examples/README.md +15 -0
  78. package/examples/playground/AGENTS.md +11 -0
  79. package/examples/playground/DEPLOY.md +106 -0
  80. package/examples/playground/README.md +19 -0
  81. package/examples/playground/activity-feed.test.ts +10 -0
  82. package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
  83. package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
  84. package/examples/playground/app/agent/events/route.ts +4 -0
  85. package/examples/playground/app/agent/model.ts +3 -0
  86. package/examples/playground/app/agent/new-agent-session.tsx +98 -0
  87. package/examples/playground/app/agent/page.tsx +25 -0
  88. package/examples/playground/app/agent/scheduler/route.ts +5 -0
  89. package/examples/playground/app/agent/server.ts +153 -0
  90. package/examples/playground/app/agent/session.ts +13 -0
  91. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
  92. package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
  93. package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
  94. package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
  95. package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
  96. package/examples/playground/app/canvas/events/route.ts +13 -0
  97. package/examples/playground/app/canvas/model.ts +94 -0
  98. package/examples/playground/app/canvas/open-canvas.tsx +40 -0
  99. package/examples/playground/app/canvas/page.tsx +20 -0
  100. package/examples/playground/app/canvas/server.ts +9 -0
  101. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
  102. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
  103. package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
  104. package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
  105. package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
  106. package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
  107. package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
  108. package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
  109. package/examples/playground/app/chat/[chatId]/session.ts +7 -0
  110. package/examples/playground/app/chat/events/route.ts +7 -0
  111. package/examples/playground/app/chat/model.test.ts +155 -0
  112. package/examples/playground/app/chat/model.ts +310 -0
  113. package/examples/playground/app/chat/new-conversation.tsx +16 -0
  114. package/examples/playground/app/chat/page.tsx +25 -0
  115. package/examples/playground/app/chat/scheduler/route.ts +5 -0
  116. package/examples/playground/app/chat/server.ts +184 -0
  117. package/examples/playground/app/components/activity-feed.tsx +54 -0
  118. package/examples/playground/app/components/connection-pill.tsx +29 -0
  119. package/examples/playground/app/counter/counter-client.tsx +72 -0
  120. package/examples/playground/app/counter/events/route.ts +4 -0
  121. package/examples/playground/app/counter/model.test.ts +36 -0
  122. package/examples/playground/app/counter/model.ts +31 -0
  123. package/examples/playground/app/counter/page.tsx +24 -0
  124. package/examples/playground/app/counter/server.ts +9 -0
  125. package/examples/playground/app/counter/session.ts +13 -0
  126. package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
  127. package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
  128. package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
  129. package/examples/playground/app/documents/[documentId]/session.ts +7 -0
  130. package/examples/playground/app/documents/events/route.ts +4 -0
  131. package/examples/playground/app/documents/model.ts +55 -0
  132. package/examples/playground/app/documents/open-document.tsx +40 -0
  133. package/examples/playground/app/documents/page.tsx +22 -0
  134. package/examples/playground/app/documents/server.ts +9 -0
  135. package/examples/playground/app/globals.css +2078 -0
  136. package/examples/playground/app/layout.tsx +44 -0
  137. package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
  138. package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
  139. package/examples/playground/app/orders/[orderId]/session.ts +11 -0
  140. package/examples/playground/app/orders/create/route.ts +30 -0
  141. package/examples/playground/app/orders/events/route.ts +4 -0
  142. package/examples/playground/app/orders/model.ts +79 -0
  143. package/examples/playground/app/orders/new-order-form.tsx +98 -0
  144. package/examples/playground/app/orders/page.tsx +22 -0
  145. package/examples/playground/app/orders/scheduler/route.ts +5 -0
  146. package/examples/playground/app/orders/server.ts +50 -0
  147. package/examples/playground/app/page.tsx +111 -0
  148. package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
  149. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
  150. package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
  151. package/examples/playground/app/recovery/events/route.ts +3 -0
  152. package/examples/playground/app/recovery/model.ts +55 -0
  153. package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
  154. package/examples/playground/app/recovery/page.tsx +22 -0
  155. package/examples/playground/app/recovery/scheduler/route.ts +7 -0
  156. package/examples/playground/app/recovery/server.ts +53 -0
  157. package/examples/playground/app/recovery/start/route.ts +41 -0
  158. package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
  159. package/examples/playground/app/vault/page.tsx +12 -0
  160. package/examples/playground/app/vault/server.ts +9 -0
  161. package/examples/playground/app/vault/vault-client.tsx +124 -0
  162. package/examples/playground/app/vault/vault.test.ts +147 -0
  163. package/examples/playground/app/vault/vault.ts +119 -0
  164. package/examples/playground/css.d.ts +4 -0
  165. package/examples/playground/lib/store.ts +15 -0
  166. package/examples/playground/next-env.d.ts +5 -0
  167. package/examples/playground/next.config.ts +10 -0
  168. package/examples/playground/package.json +46 -0
  169. package/examples/playground/tsconfig.json +37 -0
  170. package/examples/playground/vercel.json +40 -0
  171. package/package.json +11 -2
  172. package/src/actor-client.ts +132 -0
  173. package/src/actor-react.ts +143 -0
  174. package/src/actor-shared.ts +356 -0
  175. package/src/actor.browser.ts +12 -0
  176. package/src/actor.ts +914 -0
  177. package/src/client.ts +9 -1
  178. package/src/errors.ts +15 -0
  179. package/src/index.ts +1 -1
  180. package/src/server.ts +13 -3
  181. package/dist/contract-jIfaR085.d.ts.map +0 -1
  182. package/dist/server-B2XNevQA.js.map +0 -1
@@ -0,0 +1,189 @@
1
+ ---
2
+ title: Introduction
3
+ description: "A durable object built on the log: one named instance, typed state, and handlers that process one message at a time."
4
+ ---
5
+
6
+ ## The model
7
+
8
+ An actor is state behind a mailbox. You address an instance by id, send
9
+ it typed events, and its handlers process them one at a time: read
10
+ state, decide, mutate, return. Each handler ends in one atomic commit.
11
+ Between messages the instance is rows in your store, not a process:
12
+ nothing to keep alive, no locks to take, no lost updates to debug.
13
+
14
+ Every instance is an ordinary A2 session underneath, so everything the
15
+ log gives a session (durability, retries, history, devtools) holds for
16
+ actors too. The module is three entry points: `experimental-a2/actor`
17
+ on the server, `experimental-a2/actor/client` anywhere, and
18
+ `experimental-a2/actor/react` for the hook.
19
+
20
+ ## Define one
21
+
22
+ A protocol declares the state shape and the events with their inputs.
23
+ Types, not schemas: handlers are trusted server code, and the compiler
24
+ holds them to the protocol. Pass it explicitly.
25
+
26
+ ```ts vault.ts
27
+ import { NonRetriableError } from 'experimental-a2'
28
+ import { actor } from 'experimental-a2/actor'
29
+
30
+ interface Vault {
31
+ state: { balance: number }
32
+ events: {
33
+ deposit: { amount: number }
34
+ withdraw: { amount: number }
35
+ }
36
+ }
37
+
38
+ export const vault = actor<Vault>({
39
+ name: 'vault',
40
+ state: { balance: 0 },
41
+ handlers: {
42
+ deposit: (ctx, input) => {
43
+ ctx.state.balance += input.amount
44
+ },
45
+ withdraw: (ctx, input) => {
46
+ if (ctx.state.balance < input.amount) {
47
+ throw new NonRetriableError('insufficient funds')
48
+ }
49
+ ctx.state.balance -= input.amount
50
+ },
51
+ },
52
+ })
53
+ ```
54
+
55
+ The `handlers` record must cover every declared event: a missing
56
+ handler, an extra one, or a wrong input shape fails to compile. Inputs
57
+ arrive typed; `ctx.state` is a mutable draft of the instance's state,
58
+ committed atomically when the handler returns. `name` is identity (it
59
+ prefixes storage keys), and `state` seeds new instances.
60
+
61
+ Types are compile-time claims. When an event can arrive from the wire,
62
+ guard its input in the handler's first lines and throw
63
+ `NonRetriableError` on bad shapes (bring a schema library and `.parse`
64
+ if you prefer; it is userland either way).
65
+
66
+ `actor` also accepts the `store`, `scheduler`, and `telemetry` options
67
+ `createServer` takes. Without a store it uses the same development
68
+ default; see [Going to production](/guides/production). A protocol can
69
+ declare one more vocabulary, `presence`, for live audience state; see
70
+ [Presence](/actors/routes#presence).
71
+
72
+ ## Call it
73
+
74
+ `call` sends the event and awaits its answer: the state after the
75
+ handler ran, and the log index of that commit.
76
+
77
+ ```ts
78
+ // anywhere on the server:
79
+ import { vault } from '@/vault'
80
+
81
+ const savings = vault.actor('savings')
82
+
83
+ const { state, index } = await savings.call.deposit({ amount: 100 })
84
+ // state reflects this deposit, and every message before it
85
+ ```
86
+
87
+ `vault.actor(id)` is a handle, not a resource; nothing exists until the
88
+ first message. Concurrent calls from any number of serverless
89
+ invocations serialize on the instance's lane, and each answer reflects
90
+ its own message. That serialization is the feature:
91
+ [Concurrency](/actors/concurrency) is the full story.
92
+
93
+ ### Refusals
94
+
95
+ A handler says no by throwing `NonRetriableError`. That refusal is an
96
+ answer, not a failure: nothing commits, nothing retries, and the caller
97
+ gets the message.
98
+
99
+ ```ts
100
+ // anywhere on the server:
101
+ import { ActorRefusedError } from 'experimental-a2/actor'
102
+ import { vault } from '@/vault'
103
+
104
+ try {
105
+ await vault.actor('savings').call.withdraw({ amount: 1_000_000 })
106
+ } catch (error) {
107
+ if (error instanceof ActorRefusedError) {
108
+ // the answer was no: state unchanged, message settled
109
+ }
110
+ }
111
+ ```
112
+
113
+ Three kinds of failure, three behaviors:
114
+
115
+ - **`NonRetriableError`** is a refusal, and a refusal is all or
116
+ nothing: no state change, no buffered sends. The message is settled,
117
+ and the caller receives `ActorRefusedError`.
118
+ - **Any other throw** is an infrastructure failure. A2 retries the
119
+ message with the usual budget and backoff; the instance's lane waits,
120
+ which is deliberate (correctness over liveness). See
121
+ [Errors](/reference/errors).
122
+ - **A crash** re-runs the handler from the pre-message state. `ctx.id`
123
+ is the message id, stable across re-runs: use it as the idempotency
124
+ key for external I/O.
125
+
126
+ ## Send, don't wait
127
+
128
+ `send` validates, appends the event durably, and returns without
129
+ waiting for the handler.
130
+
131
+ ```ts
132
+ // anywhere on the server:
133
+ import { vault } from '@/vault'
134
+
135
+ const { id } = await vault.actor('savings').send.deposit({ amount: 25 })
136
+ // durably queued; the handler runs on the instance's lane
137
+ ```
138
+
139
+ Same mailbox, same ordering; the only difference is that nobody waits
140
+ for the answer. Pass `{ id }` in the options to make a retried send
141
+ idempotent.
142
+
143
+ ## Read without queueing
144
+
145
+ ```ts
146
+ // anywhere on the server:
147
+ import { vault } from '@/vault'
148
+
149
+ const { state } = await vault.actor('savings').state()
150
+ ```
151
+
152
+ A snapshot read. It never queues behind pending messages, and a new
153
+ instance reads as its seed state.
154
+
155
+ ## State is remembered, not recomputed
156
+
157
+ Every commit appends a state event to the instance's log, and reads
158
+ fold those by last write wins. History is never reinterpreted: you can
159
+ rewrite a handler freely without changing what any past commit means.
160
+ (This is the opposite trade from [reducers](/concepts/state), which
161
+ recompute views from events; actor state is the recorded decision
162
+ itself.)
163
+
164
+ Because state lives in the log, it holds to the same floor as any
165
+ payload: plain JSON trees. A `Date`, a `Map`, or `NaN` fails the commit
166
+ loudly instead of being silently coerced.
167
+
168
+ Two consequences worth designing for:
169
+
170
+ - **Old instances meet new code.** After a deploy, state written by the
171
+ previous version flows into the new handlers. Default new fields
172
+ defensively (`ctx.state.pending ??= {}` as a first line) instead of
173
+ assuming the latest shape.
174
+ - **The seed applies to new instances only.** Changing `state` in the
175
+ definition does not migrate existing instances; they keep their
176
+ remembered state.
177
+
178
+ ## The log is still there
179
+
180
+ An actor is a layer over an ordinary A2 session, not a wall around
181
+ one. `handle.session` is that session (history, streams, scheduling),
182
+ the definition
183
+ exposes its assembled `contract`, `server`, and `reducer`, and the full
184
+ mailbox is visible in devtools. Event names starting with `a2.` are
185
+ reserved for the actor's own bookkeeping.
186
+
187
+ Next: [Concurrency](/actors/concurrency) for the execution
188
+ model, [Deadlines and timers](/actors/timers) for scheduled events, and
189
+ [Routes, clients, and React](/actors/routes) for the wire.
@@ -0,0 +1,154 @@
1
+ ---
2
+ title: Concurrency
3
+ description: "Each instance processes one message at a time. Slow work goes in concurrent handlers: they run in parallel and report back by sending events."
4
+ ---
5
+
6
+ ## One message at a time
7
+
8
+ Every message an instance receives runs on its
9
+ [lane](/concepts/handlers#lanes): a serial queue, one handler at a
10
+ time, in arrival order. This is what makes handlers easy to trust. A
11
+ handler always sees the state every previous message produced, its
12
+ checks cannot be raced by another message, and its changes commit
13
+ before the next message runs.
14
+
15
+ The lane is per instance. A thousand vaults process in parallel; each
16
+ vault processes in order. If a single instance receives more traffic
17
+ than one queue can absorb, split the identity and give the work more
18
+ instances (per user, per order, per document) instead of trying to
19
+ make one instance concurrent.
20
+
21
+ ## The problem with slow work
22
+
23
+ A serial handler holds the lane while it runs. If `withdraw` awaits a
24
+ two-second bank API, every deposit waits behind it. The instance stays
25
+ correct, but it stops responding.
26
+
27
+ The fix is not to weaken the lane. It is to split the work: serial
28
+ handlers make the decisions, concurrent handlers do the slow parts,
29
+ and the results come back as events.
30
+
31
+ ## Concurrent handlers
32
+
33
+ Wrap a handler in `concurrent(...)` and it leaves the lane: it runs in
34
+ parallel with serial handlers and with other concurrent handlers. Two
35
+ things change inside it:
36
+
37
+ - There is no `ctx.state` draft to mutate. Reading state becomes a
38
+ function call, `ctx.state()`, and the snapshot it returns can go
39
+ stale while the handler keeps running.
40
+ - State changes happen by sending events. Their serial handlers run on
41
+ the lane and decide against current state.
42
+
43
+ ## Reserve, transfer, settle
44
+
45
+ A withdrawal that talks to a slow bank, without ever blocking the
46
+ vault: reserve the money, run the transfer, then settle or refund.
47
+
48
+ ```ts vault.ts
49
+ import { NonRetriableError } from 'experimental-a2'
50
+ import { actor, concurrent } from 'experimental-a2/actor'
51
+
52
+ interface Vault {
53
+ state: {
54
+ balance: number
55
+ /** In-flight transfers: ref to reserved amount. */
56
+ pending: Record<string, number>
57
+ }
58
+ events: {
59
+ deposit: { amount: number }
60
+ withdraw: { amount: number }
61
+ transfer: { ref: string; amount: number }
62
+ settle: { ref: string }
63
+ refund: { ref: string }
64
+ }
65
+ }
66
+
67
+ export const vault = actor<Vault>({
68
+ name: 'vault',
69
+ state: { balance: 0, pending: {} },
70
+ handlers: {
71
+ deposit: (ctx, input) => {
72
+ ctx.state.balance += input.amount
73
+ },
74
+
75
+ // the reserve: serial, so the guard sees fresh state
76
+ withdraw: (ctx, input) => {
77
+ if (ctx.state.balance < input.amount) {
78
+ throw new NonRetriableError('insufficient funds')
79
+ }
80
+ ctx.state.balance -= input.amount
81
+ ctx.state.pending[ctx.id] = input.amount
82
+ ctx.send.transfer({ ref: ctx.id, amount: input.amount })
83
+ },
84
+
85
+ // the slow part: off the lane, in parallel
86
+ transfer: concurrent(async (ctx, input) => {
87
+ // your slow call; ctx.id makes retried attempts idempotent:
88
+ // const ok = await bank.transfer(input.amount, { idempotencyKey: ctx.id })
89
+ const ok: boolean = true
90
+ if (!ok) {
91
+ ctx.send.refund({ ref: input.ref })
92
+ return
93
+ }
94
+ ctx.send.settle({ ref: input.ref })
95
+ }),
96
+
97
+ // finalizers: serial again, idempotent by the pending guard
98
+ settle: (ctx, input) => {
99
+ if (ctx.state.pending[input.ref] === undefined) return
100
+ delete ctx.state.pending[input.ref]
101
+ },
102
+ refund: (ctx, input) => {
103
+ const amount = ctx.state.pending[input.ref]
104
+ if (amount === undefined) return
105
+ delete ctx.state.pending[input.ref]
106
+ ctx.state.balance += amount
107
+ },
108
+ },
109
+ })
110
+ ```
111
+
112
+ Step by step:
113
+
114
+ 1. **`withdraw` reserves.** It runs on the lane, so the balance check
115
+ cannot be raced by another withdrawal. The balance drops, `pending`
116
+ records the reserved amount, and the `transfer` event commits
117
+ atomically with both. The withdrawal cannot half-start.
118
+ 2. **`transfer` does the slow part.** It runs off the lane, so
119
+ deposits keep flowing meanwhile. It finishes by sending an event:
120
+ `settle` on success, `refund` when the bank says no. It never
121
+ changes state itself, and it reports failure by sending, not by
122
+ throwing.
123
+ 3. **`settle` and `refund` finish the job.** Both check `pending[ref]`
124
+ first and return when the entry is gone, so a duplicate or late
125
+ message does nothing. The refund amount comes from state, not from
126
+ recomputing it.
127
+
128
+ A crash at any step re-runs that one message from the state it started
129
+ with. Wherever the process dies, the vault lands in a named state:
130
+ reserved, settled, or refunded. Money is never half-transferred.
131
+
132
+ ## Buffered sends
133
+
134
+ `ctx.send` does not append immediately. Everything a handler sends is
135
+ buffered and commits when the handler completes: for a serial handler,
136
+ in the same commit as its state change; for a concurrent handler, when
137
+ it finishes. A refusal discards the buffer along with the state
138
+ changes, so a refused message leaves no trace. Re-runs produce the
139
+ same message ids, so a retried handler does not append its sends
140
+ twice.
141
+
142
+ ## When handlers fail
143
+
144
+ - A **serial** handler that throws `NonRetriableError` refuses the
145
+ message: nothing commits, and the caller gets the answer (see
146
+ [Introduction](/actors/introduction#refusals)).
147
+ - A **serial** handler that throws anything else is retried on the
148
+ lane, and the instance waits. This is deliberate: state never
149
+ advances past a failure.
150
+ - A **concurrent** handler that throws is retried off the lane;
151
+ nothing else waits for it.
152
+ - `call` works on serial events only. A concurrent handler produces no
153
+ answer to wait for, so calling one is rejected: `send` those events
154
+ instead.
@@ -0,0 +1,120 @@
1
+ ---
2
+ title: Deadlines and timers
3
+ description: "Schedule a typed event back to the same instance. Deadlines survive deploys and land on the lane like any other message."
4
+ ---
5
+
6
+ ## A deadline is a message, delivered later
7
+
8
+ `ctx.schedule` mirrors `ctx.send`, one method per declared event, with
9
+ a timing argument: exactly one of `{ delay: '1h' }` or `{ at: date }`.
10
+ It hands the event to the configured scheduler, and delivery lands on
11
+ the instance's lane like any other message.
12
+
13
+ ```ts escrow.ts
14
+ import { actor } from 'experimental-a2/actor'
15
+ import { vercelQueues } from 'experimental-a2/scheduler-vercel'
16
+
17
+ interface Escrow {
18
+ state: { held: Record<string, number> }
19
+ events: {
20
+ hold: { offerId: string; amount: number }
21
+ accept: { offerId: string }
22
+ release: { offerId: string }
23
+ }
24
+ }
25
+
26
+ export const escrow = actor<Escrow>({
27
+ name: 'escrow',
28
+ state: { held: {} },
29
+ scheduler: vercelQueues(),
30
+ handlers: {
31
+ hold: async (ctx, input) => {
32
+ ctx.state.held[input.offerId] = input.amount
33
+ await ctx.schedule.release({ offerId: input.offerId }, { delay: '1h' })
34
+ },
35
+ accept: (ctx, input) => {
36
+ if (ctx.state.held[input.offerId] === undefined) return
37
+ delete ctx.state.held[input.offerId]
38
+ // your payout; ctx.id is the idempotency key
39
+ },
40
+ release: (ctx, input) => {
41
+ if (ctx.state.held[input.offerId] === undefined) return
42
+ delete ctx.state.held[input.offerId]
43
+ // your refund; ctx.id is the idempotency key
44
+ },
45
+ },
46
+ })
47
+ ```
48
+
49
+ Held funds now have two futures, and both are handled: someone accepts
50
+ the offer, or the hour passes and the funds release themselves. The
51
+ deadline is durable: it outlives the invocation that armed it and any
52
+ deploys in between.
53
+
54
+ Scheduling requires the `scheduler` option. Adapters, delay limits, and
55
+ delivery semantics are the session timers underneath; see
56
+ [Timers and delays](/guides/timers).
57
+
58
+ ## Stale timers are no-ops
59
+
60
+ When `accept` wins the race, the release timer still fires an hour
61
+ later. It meets the same first-line guard the finalizer pattern already
62
+ uses (`held[offerId] === undefined`) and does nothing. When the timer
63
+ wins, a late `accept` meets that guard instead.
64
+
65
+ There is no cancellation API, and the log records that the timer fired.
66
+ Guarded delivery is the pattern: let the event land, read the state,
67
+ return when it no longer applies. The guard that makes finalizers
68
+ idempotent is the same guard that makes deadlines safe.
69
+
70
+ ## Identity and retries
71
+
72
+ A timer's name defaults to the target event's name, scoped to the
73
+ triggering message. A re-run of a crashed handler therefore addresses
74
+ the same timer rather than arming a second one, and provider
75
+ deduplication plus stable event ids fold duplicate deliveries into one
76
+ append. Relative delays anchor to the triggering message's durable
77
+ `createdAt`, so re-runs resolve the same due time.
78
+
79
+ One handler run gets one timer per target event. To arm several toward
80
+ the same event, name them apart:
81
+
82
+ ```ts
83
+ // inside a handler:
84
+ await ctx.schedule.release(
85
+ { offerId: 'offer-42' },
86
+ { delay: '30m', name: 'first-nudge' },
87
+ )
88
+ ```
89
+
90
+ ## Scheduling is immediate
91
+
92
+ :::warning
93
+ Unlike `ctx.send`, `ctx.schedule` is not buffered. A timer lives at the
94
+ provider, not in the log, so it cannot ride the handler's atomic
95
+ commit: the call awaits provider acceptance right where it happens. A
96
+ handler that schedules and then refuses has still armed the timer.
97
+ Guarded delivery makes that safe (the stale timer fires into a no-op),
98
+ but order handlers accordingly: guards and refusals first, `ctx.schedule`
99
+ last.
100
+ :::
101
+
102
+ ## From outside a handler
103
+
104
+ The typed surface lives on handler contexts. To schedule from ordinary
105
+ server code, use the instance's underlying session, which takes an
106
+ explicit name, timing, and event:
107
+
108
+ ```ts
109
+ // anywhere on the server:
110
+ import { escrow } from '@/escrow'
111
+
112
+ await escrow.actor('offer-42').session.schedule(
113
+ 'expire-offer',
114
+ { at: new Date('2030-01-01T00:00:00Z') },
115
+ { type: 'release', payload: { offerId: 'offer-42' } },
116
+ )
117
+ ```
118
+
119
+ Root scheduling semantics apply (`{ at }` for retry-stable targets);
120
+ see [Timers and delays](/guides/timers#schedule-from-ordinary-server-code).