콘텐츠로 이동
한국어

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 queryRuntimes
In-memoryin-process scanall
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/aall

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, 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 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 { 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:

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

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.

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