Persistence overview
Esta página aún no está disponible en tu idioma.
By default, actor state lives in memory. When an actor crashes and restarts, every field starts fresh. For state that should survive — user accounts, shopping carts, order workflows, anything beyond the current request — you need persistence.
actor-ts offers two complementary models:
| Model | What you persist | When |
|---|---|---|
Event sourcing (PersistentActor) | A log of events — every state-changing fact ever observed. | Audit trails, time travel, projections, when “how did we get here” matters. |
Durable state (DurableStateActor) | A single snapshot — the current state, overwritten on each update. | When the current value is all you need and history isn’t useful. |
Both replay or restore on actor startup, so the resurrected actor picks up where the last one left off.
The big picture
Section titled “The big picture”The journal and the durable-state store are pluggable. The framework ships:
| Backend | Journal | Durable state | Snapshot store | Indexed tag query | Runtimes |
|---|---|---|---|---|---|
| In-memory | ✓ | ✓ | ✓ | in-process scan | all |
| SQLite | ✓ | ✓ | ✓ | ✓ (join table) | all |
| libSQL / Turso | ✓ | ✓ | ✓ | ✓ (join table) | all |
| PostgreSQL | ✓ | ✓ | ✓ | — (polling scan) | all |
| MariaDB / MySQL | ✓ | ✓ | ✓ | — (polling scan) | all |
| Microsoft SQL Server | ✓ | ✓ | ✓ | — (polling scan) | all |
| MongoDB | ✓ | ✓ | ✓ | ✓ (multikey index) | all |
| DynamoDB | ✓ | ✓ | ✓ | — (polling scan) | all |
| Cloudflare D1 | ✓ | ✓ | ✓ | ✓ (join table) | all |
| CockroachDB, YugabyteDB | ✓ | ✓ | ✓ | — (polling scan) | all |
| Cassandra | ✓ | — | ✓ | ✓ (tag-index table) | all |
| Filesystem / S3 (object storage) | — | ✓ | ✓ | n/a | all |
CockroachDB and YugabyteDB are not separate backends — they speak the PostgreSQL wire protocol, so the Postgres stores serve them unchanged. See wire-compatible databases for what is certified and the caveats that come with each.
“Indexed tag query” is what PersistenceQuery.currentEventsByTag can push
down to storage: a backend with a tag index walks it, while the others scan
the journal and refine in memory — correct either way, but only bounded in
cost where there is an index. SQLite runs on every runtime since the
built-in node:sqlite driver landed; the local file needs better-sqlite3
only if you prefer it on Node.
Plus an extension point — implement the Journal /
DurableStateStore / SnapshotStore interfaces for your own
storage.
Event sourcing in five minutes
Section titled “Event sourcing in five minutes”import { match } from 'ts-pattern';import { Actor, PersistentActor, ActorSystem } from 'actor-ts';
type DepositCommand = { kind: 'deposit'; amount: number };type WithdrawCommand = { kind: 'withdraw'; amount: number };type Command = DepositCommand | WithdrawCommand;
type DepositedEvent = { kind: 'deposited'; amount: number; ts: number };type WithdrawnEvent = { kind: 'withdrawn'; amount: number; ts: number };type Event = DepositedEvent | WithdrawnEvent;
type State = { balance: number; };
class Account extends PersistentActor<Command, Event, State> { readonly persistenceId = 'account-42';
initialState(): State { return { balance: 0 }; }
// Pure: state + event → new state. Replayed during recovery. onEvent(state: State, e: Event): State { return match(e) .with({ kind: 'deposited' }, (ev) => ({ balance: state.balance + ev.amount })) .with({ kind: 'withdrawn' }, (ev) => ({ balance: state.balance - ev.amount })) .exhaustive(); }
// Validates command, persists event, runs side effects post-persist. onCommand(state: State, command: Command): void { match(command) .with({ kind: 'deposit' }, (c) => this.onDeposit(c)) .with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c)) .exhaustive(); }
private onDeposit(command: DepositCommand): void { this.persist({ kind: 'deposited', amount: command.amount, ts: Date.now() }, (next) => { /* side effects with the persisted-and-applied state */ }); }
private onWithdraw(state: State, command: WithdrawCommand): void { if (state.balance < command.amount) { // Reject — don't persist anything. return; } this.persist({ kind: 'withdrawn', amount: command.amount, ts: Date.now() }, () => {}); }}Three methods do all the work:
onCommand— validates the request. Decides what event(s) to persist viathis.persist(event, afterPersist). Side effects go inafterPersist.onEvent— pure function from state + event to new state. No side effects here — this function runs during recovery to replay the journal, possibly many times.initialState— what the state looks like before any events.
On startup, the framework reads every event for account-42
from the journal, replays them through onEvent, and the
resulting state is what onCommand sees. Commands aren’t
processed until recovery completes.
See PersistentActor for the full surface.
Durable state in five minutes
Section titled “Durable state in five minutes”import { match } from 'ts-pattern';import { DurableStateActor, DurableStateOptions, type ActorRef } from 'actor-ts';
type State = { items: string[]; };
type AddCommand = { kind: 'add'; sku: string };type ViewCommand = { kind: 'view'; replyTo: ActorRef<State> };type CartCommand = AddCommand | ViewCommand;
class Cart extends DurableStateActor<CartCommand, State> { constructor(options: DurableStateOptions<State>) { super(options); }
override async onCommand(command: CartCommand): Promise<void> { await match(command) .with({ kind: 'add' }, (c) => this.onAdd(c)) .with({ kind: 'view' }, (c) => this.onView(c)) .exhaustive(); }
private async onAdd(command: AddCommand): Promise<void> { const next: State = { items: [...this.state.items, command.sku] }; await this.persist(next); // overwrites the stored state }
private onView(command: ViewCommand): void { command.replyTo.tell(this.state); }}persist(newState) overwrites the stored snapshot. On restart,
preStart loads it back; this.state reflects the loaded value.
No event log; no replay; just “save the current state.”
See DurableStateActor for the full API.
Event sourcing vs durable state — picking one
Section titled “Event sourcing vs durable state — picking one”The honest decision tree:
Event sourcing wins when:
- History matters — auditing, regulatory compliance, “show me how we got here,” projections.
- State is large but changes are small — appending a 100-byte event is cheaper than writing the whole state.
- You want projections — read-side views over the event stream, see Projections.
- Schema evolution is a long game — event types can be migrated independently from current state.
Durable state wins when:
- History isn’t useful — the current value is all you need.
- State is small and simple — overwriting is cheap.
- You want optimistic concurrency — durable state stores have
a revision counter; concurrent writes raise
DurableStateConcurrencyError.
Many production systems mix them — durable state for the configuration-style “single current value” things, event-sourcing for the workflow-style “history-of-decisions” things.
Snapshots
Section titled “Snapshots”Replaying 100 000 events at startup is slow. Snapshots cut the replay window:
class Account extends PersistentActor<Command, Event, State> { // ... override snapshotPolicy() { return everyNEvents(100); } // After every 100 events, the current state is written as a snapshot.}On startup, the framework:
- Loads the latest snapshot (if any).
- Replays events from after that snapshot’s seqNr onward.
A 100-event window is fast. Pick the snapshot interval based on your event rate and acceptable startup time.
See Snapshots for the configuration and per-actor policy options.
Projections — read-side views
Section titled “Projections — read-side views”A PersistentActor writes events. A projection consumes
them, building a derived view tailored for queries:
import { match } from 'ts-pattern';import { ProjectionActor, ByTagProjectionOptions } from 'actor-ts';
const cartViewOptions = ByTagProjectionOptions.create<CartEvent>() .withName('view-cart-summary') .withTag('cart') .withQuery(query) .withHandle(async (event) => { await match(event.event) .with({ kind: 'added' }, () => db.execute('INSERT INTO cart_items ...')) // ... .otherwise(() => Promise.resolve()); });const cartView = ProjectionActor.byTag<CartEvent>(system, cartViewOptions);The projection subscribes to events tagged 'cart' from the
journal, processes them in order, persists its own progress
(so a restart resumes from the right offset).
This decouples writes (the PersistentActor’s journal) from
reads (the projection’s view) — the read side can be denormalized
for the query patterns it serves.
See Projections for the full pattern.
Pluggable backends
Section titled “Pluggable backends”The framework defines three interfaces:
type Journal = { // append events, read events, query by tag};
type DurableStateStore = { // load, persist with revision, delete};
type SnapshotStore = { // save snapshot, load latest, delete older};Built-in implementations live under
persistence/journals/*
and persistence/snapshot-stores/*.
Writing your own? Journal.read() is the method replay holds to
its contract: the events it returns must be ascending by sequence
number, contiguous, and inside the requested window. delete()
compacts a prefix, never a hole in the middle, so a gap can
only mean a defect — a missing ORDER BY, a half-written append,
a store someone else can write. Replay raises
JournalIntegrityError instead of folding it; see what replay
refuses to
fold.
For production, the SQLite journal+snapshot combo covers single-node deployments; the Cassandra journal covers multi-node clusters where the journal must be shared.
Every backend stores payloads in the same tagged JSON tree format,
so Date / Map / Set / bigint / Uint8Array round-trip
everywhere — see
what events and state may contain.
When NOT to persist
Section titled “When NOT to persist”Where to next
Section titled “Where to next”- PersistentActor — the event-sourcing API in depth.
- Durable state — the simpler snapshot-style alternative.
- Snapshots — replay-window reduction for event-sourced actors.
- Projections — read-side views built from event streams.
- Journals — In-memory — the tests/dev default.
- Journals — SQLite — single-node production default.
- Migration overview — evolving event/state schemas over time.
The PersistentActor and
DurableStateActor API
references cover the full base-class surface.
