Introduction
Typed state behind a mailbox. Each instance processes one message at a time, and each handler ends in one atomic commit.
The model
An actor is state behind a mailbox: you send an instance typed events, and its handlers process them one at a time. Between messages an instance is rows in your store, not a process. Every instance is an ordinary A2 session, so durability, retries, history, and devtools hold for actors too.
Entry points: experimental-a2/actor on the server,
experimental-a2/actor/client anywhere, and
experimental-a2/actor/react for the hook.
Define one
import { NonRetriableError } from 'experimental-a2'
import { actor } from 'experimental-a2/actor'
interface Vault {
state: { balance: number }
events: {
deposit: { amount: number }
withdraw: { amount: number }
}
}
export const vault = actor<Vault>({
name: 'vault',
state: { balance: 0 },
handlers: {
deposit: (ctx, input) => {
ctx.state.balance += input.amount
},
withdraw: (ctx, input) => {
if (ctx.state.balance < input.amount) {
throw new NonRetriableError('insufficient funds')
}
ctx.state.balance -= input.amount
},
},
})Pass the protocol explicitly as the type argument. It declares types,
not schemas: handlers are trusted server code, and a missing handler,
an extra one, or a wrong input shape fails to compile. ctx.state is a
mutable draft, committed atomically when the handler returns. name is
identity (it prefixes storage keys), and state seeds new instances.
When an event can arrive from the wire, guard its input in the
handler's first lines and throw NonRetriableError on bad shapes.
actor also accepts createServer's store, scheduler, and
telemetry options, with the same development default store; see
Stores and Schedulers. A protocol can also declare
presence.
Call it
// anywhere on the server:
import { vault } from '@/vault'
const savings = vault.actor('savings')
const { state, index } = await savings.call.deposit({ amount: 100 })
// state reflects this deposit, and every message before itcall awaits the answer: the state after the handler ran and the log
index of that commit. vault.actor(id) is a handle; nothing exists
until the first message. Concurrent calls from any number of
invocations serialize on the instance's lane, and each answer reflects
its own message; see Concurrency.
Refusals
// anywhere on the server:
import { ActorRefusedError } from 'experimental-a2/actor'
import { vault } from '@/vault'
try {
await vault.actor('savings').call.withdraw({ amount: 1_000_000 })
} catch (error) {
if (error instanceof ActorRefusedError) {
// the answer was no: state unchanged, message settled
}
}Throwing NonRetriableError refuses the message. A refusal is an
answer, and it is all or nothing: no state change, no buffered sends,
no retry. The message settles, and the caller receives
ActorRefusedError.
- Any other throw retries the message with the usual budget and backoff while the instance's lane waits; see Errors.
- A crash re-runs the handler from the pre-message state.
ctx.idis the message id, stable across re-runs: use it as the idempotency key for external I/O. - A clear by
handle.session.clear()before the answer rejects the call with anA2Errorwhose code isSESSION_CLEARED. The handler may already have run. Over HTTP it's a410.
Send, don't wait
// anywhere on the server:
import { vault } from '@/vault'
const { id } = await vault.actor('savings').send.deposit({ amount: 25 })
// durably queued; the handler runs on the instance's lanesend validates and appends the event durably without waiting for the
handler: same mailbox, same ordering. Pass { id } in the options to
make a retried send idempotent.
Read without queueing
// anywhere on the server:
import { vault } from '@/vault'
const { state } = await vault.actor('savings').state()A snapshot read: it never queues behind pending messages, and a new instance reads as its seed state.
State is remembered, not recomputed
Every commit appends a state event, and reads fold them by last write
wins. Unlike reducers, nothing is recomputed, so
rewriting a handler never changes what a past commit means. State must
be plain JSON: a Date, a Map, or NaN fails the commit loudly.
- Old instances meet new code. After a deploy, default new fields
defensively (
ctx.state.pending ??= {}as a first line). - The seed applies to new instances only. Changing
statedoes not migrate existing instances.
The log is still there
handle.session is the instance's underlying session (history,
streams, scheduling). The definition exposes its assembled contract,
server, and reducer, and devtools show the full mailbox. Event
names starting with a2. are reserved for the actor's own bookkeeping.