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.
- package/AGENTS.md +11 -0
- package/CHANGELOG.md +36 -0
- package/README.md +29 -0
- package/dist/actor-client.d.ts +46 -0
- package/dist/actor-client.d.ts.map +1 -0
- package/dist/actor-client.js +54 -0
- package/dist/actor-client.js.map +1 -0
- package/dist/actor-react.d.ts +54 -0
- package/dist/actor-react.d.ts.map +1 -0
- package/dist/actor-react.js +79 -0
- package/dist/actor-react.js.map +1 -0
- package/dist/actor-shared-BACubf4x.d.ts +136 -0
- package/dist/actor-shared-BACubf4x.d.ts.map +1 -0
- package/dist/actor-shared-DI7J5upy.js +127 -0
- package/dist/actor-shared-DI7J5upy.js.map +1 -0
- package/dist/actor.browser.d.ts +1 -0
- package/dist/actor.browser.js +13 -0
- package/dist/actor.browser.js.map +1 -0
- package/dist/actor.d.ts +176 -0
- package/dist/actor.d.ts.map +1 -0
- package/dist/actor.js +437 -0
- package/dist/actor.js.map +1 -0
- package/dist/ai-server.d.ts +2 -2
- package/dist/ai-server.js +2 -2
- package/dist/ai.d.ts +2 -2
- package/dist/client.d.ts +1 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/{errors-BQuJpe82.js → errors-DCk6ch5n.js} +16 -2
- package/dist/{errors-BQuJpe82.js.map → errors-DCk6ch5n.js.map} +1 -1
- package/dist/{idempotent-replay-DuqEkYA7.js → idempotent-replay-DVOlyYbx.js} +2 -2
- package/dist/{idempotent-replay-DuqEkYA7.js.map → idempotent-replay-DVOlyYbx.js.map} +1 -1
- package/dist/index.d.ts +16 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/react.d.ts +1 -1
- package/dist/{contract-jIfaR085.d.ts → reducer-DJKWm3cp.d.ts} +39 -39
- package/dist/reducer-DJKWm3cp.d.ts.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +1 -1
- package/dist/{server-B2XNevQA.js → server-CBET-jSz.js} +6 -6
- package/dist/server-CBET-jSz.js.map +1 -0
- package/dist/{server-DjPhHnbI.d.ts → server-CKY3_lbw.d.ts} +3 -3
- package/dist/{server-DjPhHnbI.d.ts.map → server-CKY3_lbw.d.ts.map} +1 -1
- package/dist/server.d.ts +3 -3
- package/dist/server.js +1 -1
- package/dist/{store-RJO35BMj.d.ts → store-DGHeBtIQ.d.ts} +2 -2
- package/dist/{store-RJO35BMj.d.ts.map → store-DGHeBtIQ.d.ts.map} +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.js +2 -2
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.js +2 -2
- package/dist/{store-redis-core-DT01r4GZ.js → store-redis-core-z-ykbyMg.js} +3 -3
- package/dist/{store-redis-core-DT01r4GZ.js.map → store-redis-core-z-ykbyMg.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +2 -2
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.js +2 -2
- package/dist/{wire-B6te_wns.js → wire--yji6mO3.js} +2 -2
- package/dist/{wire-B6te_wns.js.map → wire--yji6mO3.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +189 -0
- package/docs/actors/02-concurrency.mdx +154 -0
- package/docs/actors/03-timers.mdx +120 -0
- package/docs/actors/04-routes.mdx +352 -0
- package/docs/actors/meta.ts +1 -0
- package/docs/concepts/meta.ts +1 -0
- package/docs/guides/07-examples.mdx +56 -0
- package/docs/guides/meta.ts +1 -0
- package/docs/index.mdx +16 -0
- package/docs/reference/02-errors.mdx +33 -0
- package/docs/reference/meta.ts +1 -0
- package/examples/README.md +15 -0
- package/examples/playground/AGENTS.md +11 -0
- package/examples/playground/DEPLOY.md +106 -0
- package/examples/playground/README.md +19 -0
- package/examples/playground/activity-feed.test.ts +10 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +376 -0
- package/examples/playground/app/agent/[agentId]/page.tsx +29 -0
- package/examples/playground/app/agent/events/route.ts +4 -0
- package/examples/playground/app/agent/model.ts +3 -0
- package/examples/playground/app/agent/new-agent-session.tsx +98 -0
- package/examples/playground/app/agent/page.tsx +25 -0
- package/examples/playground/app/agent/scheduler/route.ts +5 -0
- package/examples/playground/app/agent/server.ts +153 -0
- package/examples/playground/app/agent/session.ts +13 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +682 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.test.ts +68 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-replay.ts +19 -0
- package/examples/playground/app/canvas/[canvasId]/page.tsx +22 -0
- package/examples/playground/app/canvas/[canvasId]/session.ts +19 -0
- package/examples/playground/app/canvas/events/route.ts +13 -0
- package/examples/playground/app/canvas/model.ts +94 -0
- package/examples/playground/app/canvas/open-canvas.tsx +40 -0
- package/examples/playground/app/canvas/page.tsx +20 -0
- package/examples/playground/app/canvas/server.ts +9 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +118 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +316 -0
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +922 -0
- package/examples/playground/app/chat/[chatId]/chat-view.test.ts +152 -0
- package/examples/playground/app/chat/[chatId]/chat-view.ts +101 -0
- package/examples/playground/app/chat/[chatId]/composer.test.ts +44 -0
- package/examples/playground/app/chat/[chatId]/composer.ts +30 -0
- package/examples/playground/app/chat/[chatId]/page.tsx +30 -0
- package/examples/playground/app/chat/[chatId]/session.ts +7 -0
- package/examples/playground/app/chat/events/route.ts +7 -0
- package/examples/playground/app/chat/model.test.ts +155 -0
- package/examples/playground/app/chat/model.ts +310 -0
- package/examples/playground/app/chat/new-conversation.tsx +16 -0
- package/examples/playground/app/chat/page.tsx +25 -0
- package/examples/playground/app/chat/scheduler/route.ts +5 -0
- package/examples/playground/app/chat/server.ts +184 -0
- package/examples/playground/app/components/activity-feed.tsx +54 -0
- package/examples/playground/app/components/connection-pill.tsx +29 -0
- package/examples/playground/app/counter/counter-client.tsx +72 -0
- package/examples/playground/app/counter/events/route.ts +4 -0
- package/examples/playground/app/counter/model.test.ts +36 -0
- package/examples/playground/app/counter/model.ts +31 -0
- package/examples/playground/app/counter/page.tsx +24 -0
- package/examples/playground/app/counter/server.ts +9 -0
- package/examples/playground/app/counter/session.ts +13 -0
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +80 -0
- package/examples/playground/app/documents/[documentId]/document-client.tsx +525 -0
- package/examples/playground/app/documents/[documentId]/page.tsx +23 -0
- package/examples/playground/app/documents/[documentId]/session.ts +7 -0
- package/examples/playground/app/documents/events/route.ts +4 -0
- package/examples/playground/app/documents/model.ts +55 -0
- package/examples/playground/app/documents/open-document.tsx +40 -0
- package/examples/playground/app/documents/page.tsx +22 -0
- package/examples/playground/app/documents/server.ts +9 -0
- package/examples/playground/app/globals.css +2078 -0
- package/examples/playground/app/layout.tsx +44 -0
- package/examples/playground/app/orders/[orderId]/order-client.tsx +140 -0
- package/examples/playground/app/orders/[orderId]/page.tsx +29 -0
- package/examples/playground/app/orders/[orderId]/session.ts +11 -0
- package/examples/playground/app/orders/create/route.ts +30 -0
- package/examples/playground/app/orders/events/route.ts +4 -0
- package/examples/playground/app/orders/model.ts +79 -0
- package/examples/playground/app/orders/new-order-form.tsx +98 -0
- package/examples/playground/app/orders/page.tsx +22 -0
- package/examples/playground/app/orders/scheduler/route.ts +5 -0
- package/examples/playground/app/orders/server.ts +50 -0
- package/examples/playground/app/page.tsx +111 -0
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +31 -0
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +144 -0
- package/examples/playground/app/recovery/[recoveryId]/session.ts +7 -0
- package/examples/playground/app/recovery/events/route.ts +3 -0
- package/examples/playground/app/recovery/model.ts +55 -0
- package/examples/playground/app/recovery/new-recovery-session.tsx +20 -0
- package/examples/playground/app/recovery/page.tsx +22 -0
- package/examples/playground/app/recovery/scheduler/route.ts +7 -0
- package/examples/playground/app/recovery/server.ts +53 -0
- package/examples/playground/app/recovery/start/route.ts +41 -0
- package/examples/playground/app/vault/[vaultId]/route.ts +19 -0
- package/examples/playground/app/vault/page.tsx +12 -0
- package/examples/playground/app/vault/server.ts +9 -0
- package/examples/playground/app/vault/vault-client.tsx +124 -0
- package/examples/playground/app/vault/vault.test.ts +147 -0
- package/examples/playground/app/vault/vault.ts +119 -0
- package/examples/playground/css.d.ts +4 -0
- package/examples/playground/lib/store.ts +15 -0
- package/examples/playground/next-env.d.ts +5 -0
- package/examples/playground/next.config.ts +10 -0
- package/examples/playground/package.json +46 -0
- package/examples/playground/tsconfig.json +37 -0
- package/examples/playground/vercel.json +40 -0
- package/package.json +11 -2
- package/src/actor-client.ts +132 -0
- package/src/actor-react.ts +143 -0
- package/src/actor-shared.ts +356 -0
- package/src/actor.browser.ts +12 -0
- package/src/actor.ts +914 -0
- package/src/client.ts +9 -1
- package/src/errors.ts +15 -0
- package/src/index.ts +1 -1
- package/src/server.ts +13 -3
- package/dist/contract-jIfaR085.d.ts.map +0 -1
- 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).
|