跳转到内容
简体中文

Persistence overview

此内容尚不支持你的语言。

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:

ModelWhat you persistWhen
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.

DurableStateActor

PersistentActor

onCommand(command)

persist(event)

onEvent → state mutates

Journal

append-only event log

Snapshot Store

periodic state

onCommand(command)

persist(newState)

revision++

Durable State Store

single value

The journal and the durable-state store are pluggable. The framework ships:

BackendJournalDurable stateSnapshot storeIndexed tag queryShared across nodesRuntimes
In-memory✓✓✓in-process scan✗ (per process)all
SQLite✓✓✓✓ (join table)✗ (per node)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/aS3 ✓ / filesystem ✗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.

import { match } from 'ts-pattern';
import { Actor, ActorSystem } from 'actor-ts';
import { PersistentActor } from 'actor-ts/persistence';
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 via this.persist(event, afterPersist). Side effects go in afterPersist.
  • 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.

import { match } from 'ts-pattern';
import { type ActorRef } from 'actor-ts';
import { DurableStateActor, DurableStateOptions } from 'actor-ts/persistence';
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:

yes

no

yes

no

Need a history of state changes?

(audit, undo, projections)

Is state shape simple

and volume small enough that

rewriting the whole thing

on every change is fine?

PersistentActor

PersistentActor

(only changes get appended)

DurableStateActor

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.

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:

  1. Loads the latest snapshot (if any).
  2. 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.

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/persistence';
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.

A handler that throws blocks every event behind it, because the cursor only advances on success. recoveryStrategy bounds that: retry with backoff and then stop (the default), skip the event onto the dead-letter stream, or do either immediately.

See Projections for the full pattern.

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; a shared backend — Cassandra, PostgreSQL, or any other row with ✓ in the matrix’s “shared across nodes” column — covers multi-node clusters, where every node must replay the same journal. What “the same” means, and how the cluster checks it, is the next section.

A cluster’s persistent state has one hard prerequisite the type system cannot express: every node must read and write the same database. Sharding moves entities between nodes, and the moved entity replays whatever journal its new node opens. With per-node storage — a SQLite file, the in-memory defaults — each node opens its own database, and nothing ever fails: the optimistic head check compares against the local file, so two divergent histories accumulate under one persistence id with no error on either node. Two nodes, two histories, and the answer you get depends on which node you ask.

The framework guards this along two independent lines:

Locality — what the store is. Every in-repo store declares storageLocality: 'node-local' | 'shared' — a SQLite file or an in-memory map can never be shared, a database service can. When a 'node-local' store backs a persistent actor while the cluster has (or later gains) remote peers, the node logs one warning per store kind, greppable by the needle node-local storage. One combination refuses outright instead of warning: rememberEntities: true on its default auto-wired path throws StorageLocalityError, because the sharding registry is leader-hosted and provably forks on failover — see remember entities.

Identity — which database it actually is. A 'shared' declaration says the backend could be shared, not that two nodes actually reached the same instance of it: two nodes each pointing at their own PostgreSQL, a stale connection string, a restored backup — same technology, different database, identical silent divergence. So every store mints a random identity on first contact and persists it in the database itself (storageIdentity()); nodes gossip the identities of the stores they actually use on their member records, and any node that sees a peer claim a different identity for the same store kind says so — once per kind, greppable by the needle storage identity differs.

Both checks are conservative by construction. A store that declares neither member is unknown and stays silent — a third-party backend is never misjudged by a default it did not choose — and replicated event sourcing, whose per-node journals are the intended design, triggers neither. Writing your own store? Declare storageLocality, and implement storageIdentity() as: mint a random value on first contact, persist it in the database, return the stored one ever after — the in-repo stores keep it in a one-row storage_identity table (or an object under the root key storage-identity).

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.

The PersistentActor and DurableStateActor API references cover the full base-class surface.