Ir al contenido
Español

PersistentActor

Esta página aún no está disponible en tu idioma.

PersistentActor<Command, Event, State> is the framework’s event-sourcing base class. The model:

  • Commands come in.
  • The command handler validates and decides what facts happened — those facts are events, persisted to the journal.
  • After persistence, a pure event handler updates state.

On startup, the framework reads the journal and replays every event through the event handler. The resulting state is what the actor sees on its first command — wherever the last instance left off, this one resumes.

import { ActorSystem, ActorSystemOptions, type ActorRef } from 'actor-ts';
import { PersistentActor } from 'actor-ts/persistence';
import { InMemoryJournal, InMemorySnapshotStore } from 'actor-ts/persistence';
import { match } from 'ts-pattern';
type DepositCommand = { kind: 'deposit'; amount: number };
type WithdrawCommand = { kind: 'withdraw'; amount: number };
type GetBalanceCommand = { kind: 'get-balance'; replyTo: ActorRef<number> };
type Command = DepositCommand | WithdrawCommand | GetBalanceCommand;
type DepositedEvent = { kind: 'deposited'; amount: number; ts: number };
type WithdrawnEvent = { kind: 'withdrawn'; amount: number; ts: number };
type Event = DepositedEvent | WithdrawnEvent;
const actorSystemOptions = ActorSystemOptions.create().withPersistence({
journal: new InMemoryJournal(),
snapshotStore: new InMemorySnapshotStore(),
});
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. Runs both on persist + recovery.
onEvent(state: State, e: Event): State {
return match(e)
.with({ kind: 'deposited' }, (ev) => this.onDeposited(state, ev))
.with({ kind: 'withdrawn' }, (ev) => this.onWithdrawn(state, ev))
.exhaustive();
}
private onDeposited(state: State, ev: DepositedEvent): State {
return { balance: state.balance + ev.amount };
}
private onWithdrawn(state: State, ev: WithdrawnEvent): State {
return { balance: state.balance - ev.amount };
}
// Handle a command — validate, persist if valid.
onCommand(state: State, command: Command): void {
match(command)
.with({ kind: 'deposit' }, (c) => this.onDeposit(c))
.with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c))
.with({ kind: 'get-balance' }, (c) => this.onGetBalance(state, c))
.exhaustive();
}
private onDeposit(c: DepositCommand): void {
this.persist(
{ kind: 'deposited', amount: c.amount, ts: Date.now() },
(next) => { /* side effects with the new state */ },
);
}
private onWithdraw(state: State, c: WithdrawCommand): void {
if (state.balance < c.amount) {
// Reject — don't persist anything.
this.log.warn(`insufficient funds`);
return;
}
this.persist(
{ kind: 'withdrawn', amount: c.amount, ts: Date.now() },
() => {},
);
}
private onGetBalance(state: State, c: GetBalanceCommand): void {
c.replyTo.tell(state.balance); // read-only — no persist
}
}
// Setup — wire journal + snapshot store inline via the create option:
const system = ActorSystem.create('demo', actorSystemOptions);
const account = system.spawn(Account, 'account-42');
account.tell({ kind: 'deposit', amount: 100 });
account.tell({ kind: 'withdraw', amount: 30 });
// state is { balance: 70 } — and stays that way across restarts.

Every PersistentActor subclass implements three methods:

The state before any events. Called at the start of recovery, and the result is what onEvent builds on top of.

initialState(): State {
return { balance: 0 };
}

Pure — no side effects, no tells, no awaits. Just state + event → state. This function runs both:

  • On replay — once per persisted event, when the actor starts and the journal is replayed.
  • After persist — once when a new event lands.

Pure-ness matters because the same events will replay many times over the actor’s lifetime. A side effect inside onEvent runs during every recovery, which means duplicate emails / duplicate HTTP calls / duplicated everything.

That is also why onEvent is synchronous while onCommand is async — the asymmetry is the point, not an oversight. A command decides, and deciding does I/O: persist writes to the journal. An event is already a fact, and folding a fact into state is arithmetic. Anything you would want to await here is precisely what must not run again on recovery; put it in the persist callback or onRecoveryComplete, neither of which replay.

Read state from the parameter, never this.state. During replay onEvent runs detached, before this.state has been assigned — and the DevTools time-travel panel borrows it the same way, as a free fold over any point in history. A handler that reaches for this.state works on the persist path and fails only after a restart, which is the most expensive way to find out.

onCommand(state, command) → void | Promise<void>

Section titled “onCommand(state, command) → void | Promise<void>”

Validates the command against the current state and decides what events to persist. Three valid outcomes:

  • this.persist(event, afterPersist) — persist an event. The callback runs with the new state once the event is appended and applied via onEvent. Side effects go here.
  • Reply without persisting — for read-only commands (e.g. { kind: 'get-balance' }), reach into state and tell the reply directly.
  • Reject — log, ignore, or reply with an error. No events written.
onCommand(state: State, command: Command): void {
match(command)
.with({ kind: 'get-balance' }, (c) => this.onGetBalance(state, c))
.with({ kind: 'withdraw' }, (c) => this.onWithdraw(state, c))
.exhaustive();
}
private onGetBalance(state: State, command: GetBalanceCommand): void {
command.replyTo.tell(state.balance); // read-only — no persist
}
private onWithdraw(state: State, command: WithdrawCommand): void {
if (state.balance < command.amount) return; // reject
this.persist({ kind: 'withdrawn', amount: command.amount, ts: Date.now() },
() => {});
}
this.persist(event, (newState) => {
// 1. event has been written to the journal
// 2. onEvent has been called; this.state and `newState` reflect it
// 3. it's safe to do side effects here
this.sender.forEach(s => s.tell({ ok: true, balance: newState.balance }));
});

Three guarantees the callback gives you:

  1. The event is durable — if the process crashes after this, the journal still has it. The next recovery picks it up.
  2. The state reflects it — onEvent has run; this.state is the new state.
  3. Commands are stashed during the persist — incoming commands wait until the persist completes and its callback fires. No interleaving.

The third point is what makes persist safe to use as the “transaction boundary” for command processing. Side effects (replies, notifications, follow-up tells) belong in the callback.

When the actor starts:

preStart() runs:
→ load latest snapshot (if any) → set state, seqNr
→ read events from journal starting at seqNr+1
→ for each event, state = onEvent(state, event)
→ onRecoveryComplete(state)
→ ready to process the first command

While recovery is running, no commands are processed. The mailbox piles up; once recovery finishes, the actor drains them in order against the recovered state.

onRecoveryComplete(state) is an optional hook fired after the last event is replayed. Use it for one-time post-recovery setup (register watchers, fetch related actors, etc.) — but not for side effects per event, which would duplicate on every restart. A throw from this hook is an ordinary actor failure and goes to supervision; it is not a recovery failure, because the state recovered fine.

A corrupt event, a missing upcaster, a snapshot the integrity check refuses, a journal handing back its events out of order — recovery can throw, and then onRecoveryFailure is called.

It is a notification, not a decision. Recovery failure is terminal either way; the hook only chooses who hears about it:

// Default — rethrows, so the failure reaches supervision as an
// ActorInitializationError and the strategy decides.
onRecoveryFailure(reason: Error): void { throw reason; }
// Override that returns — you have taken the failure as handled,
// and the actor is then stopped.
override onRecoveryFailure(reason: Error): void {
this.metrics.recoveryFailed.inc();
}

The two integrity failures are exported classes, so the hook can tell them apart. Which of the two stores broke its contract is the first thing an operator needs, and both carry the persistenceId and the offending sequenceNr:

import { JournalIntegrityError, SnapshotIntegrityError } from 'actor-ts/persistence';
override onRecoveryFailure(reason: Error): void {
if (reason instanceof JournalIntegrityError) {
this.log.error(`journal broke at ${reason.sequenceNr} for ${reason.persistenceId}`);
} else if (reason instanceof SnapshotIntegrityError) {
this.log.error(`snapshot ${reason.sequenceNr} of ${reason.persistenceId} is not trustworthy`);
}
}

The actor cannot carry on either way: state was never assigned and lastSequenceNr is unknown, so there is no state in which it could answer a command. Stopping is what keeps that visible — commands already queued become dead letters instead of disappearing into the stash, and the stop is published on the event stream.

Before a single event reaches onEvent, replay checks that what journal.read() returned matches the Journal contract: sequence numbers ascending, contiguous, and inside the window that was asked for. Anything else raises JournalIntegrityError.

Fatal rather than a warning, because the fold is recovery. A shuffled stream replays history in an order it never happened in — a disableAccount landing before the setPassword it was meant to follow — and it leaves the actor’s sequence on the last event delivered rather than the highest one, after which every persist fails with a JournalConcurrencyError pointing at a perfectly healthy journal, one restart away from its cause.

The same check refuses a history whose beginning is missing: events compacted away with no snapshot covering them. That state cannot be reconstructed, and folding the surviving tail onto initialState() would invent one. Compact only past a snapshot — deleteHistory(seq) keeps the snapshot at seq for exactly this reason.

The DevTools time-travel panel shares this replay code but opts out of that last part. It asks what state looked like in the past, reaching for the newest snapshot before the target, so it routinely lands on a window whose covering snapshot has since been pruned. There it shows a partial fold — with the sequence it reached and the number of events it applied — rather than refusing to open.

Every PersistentActor declares a persistenceId:

class Account extends PersistentActor<...> {
readonly persistenceId = 'account-42';
}

The ID identifies the event stream in the journal. Two actors with the same persistenceId would share the same event log — usually a bug.

For per-entity actors (one account per user, one cart per user), make the ID dependent on the entity:

class Account extends PersistentActor<...> {
constructor(public readonly userId: string) { super(); }
readonly persistenceId = `account-${this.userId}`;
}

For sharded entities, the shard region typically passes the entity ID via constructor, and the persistence ID derives from it.

An ID is rarely a constant — it is usually built from user input (`account-${request.params.id}`, a sharding entity ID). It is always bound as a query parameter, so SQL injection is not reachable, but the ID is more than a value: it names a stream, and several backends build a structured storage key out of it. preStart therefore refuses an ID that cannot be one, before the journal is ever touched:

RejectedWhy
emptyevery backend would happily write under '', so an ID some code path forgot to fill in silently shares one stream with every other actor that forgot the same thing
longer than 255 charactersthe width of the persistence_id column in every relational dialect’s DDL, and part of its primary key — a longer ID is truncated (two IDs collapsing onto one stream) or rejected deep inside a driver
/ or \the object-storage stores lay an ID out as a directory (<prefix><persistenceId>/<seq>.json) and read it back by listing that prefix, so a/b nests inside a — a’s loadLatest then returns a/b’s snapshot, and its delete prunes it
. or .. (the whole ID)traversal meaning, one level up, with the same consequence
control charactersIDs are interpolated into a log line on every recovery and every persist, so a newline lets a caller forge log records

Everything else is allowed, deliberately. A comma is fine — the comma-separated column in the SQLite journal carries tags, and the ID is a separate bound column. A | is fine too, and the chat example relies on it: a DM channel is dm-channel-alice|bob. The projection offset store joins keys as <projection>|seq|<persistenceId> with the ID last, so a | inside it cannot split off an extra field, and nothing anywhere splits an ID back apart.

The same rules run again inside journal.append() — defence in depth for a journal reached without an actor — and, for DurableStateActor, in the options validator, where a violation is an OptionsError on the persistenceId field.

Check your own IDs against them without starting an actor:

import { assertValidPersistenceId } from 'actor-ts/persistence';
assertValidPersistenceId(`account-${userId}`);

Reading is never refused. Only writes are validated, so events already stored under an ID that these rules reject stay reachable via journal.read(oldId, 1) — copy them to a corrected ID and delete the old stream.

Replaying 100 000 events at startup is slow. Configure a snapshot policy:

import { everyNEvents } from 'actor-ts/persistence';
class Account extends PersistentActor<...> {
override snapshotPolicy() { return everyNEvents(100); }
// After every 100 events, the current state is snapshotted.
}

The framework writes a snapshot via the snapshot store; on recovery, it loads the snapshot first and only replays events after that snapshot’s seqNr.

everyNEvents(N) is the common case. For custom policies (snapshot on a specific event kind, time-based), implement:

override snapshotPolicy() {
return (seqNr, state, event) => event.kind === 'finalized';
}

See Snapshots for the full configuration.

class Account extends PersistentActor<...> {
override tagsFor(event: Event): ReadonlyArray<string> | undefined {
return ['account']; // or based on event kind
}
}

Projections read events from the journal by tag. Tagging an event makes it discoverable to a read-side view that subscribes to 'account' events.

Returning undefined (the default) means “no tags” — fine if you don’t have projections yet.

tagsFor is asked once per event, including inside a persistAll. So a batch that mixes event kinds is tagged per kind, and a projection filtering on one of them sees exactly its own events:

override tagsFor(event: Event): ReadonlyArray<string> | undefined {
return event.kind === 'paymentCaptured' ? ['payment'] : ['order'];
}
await this.persistAll([
{ kind: 'orderPlaced' }, // → ['order']
{ kind: 'paymentCaptured' }, // → ['payment']
]);

The batch is still written atomically — per-event tags do not split it into several appends.

Like a persistence ID, a tag is rarely a constant — it is usually built from the event (`tenant-${event.tenantId}`, an optional label). And like an ID it is more than a value: every backend turns the list into a storage shape, and they do not all pick the same one. journal.append() therefore refuses a list that cannot mean the same thing everywhere, before a single row is written:

RejectedWhy
an empty tagit means something different on every store — dropped from the tag table but kept in the comma-separated column the SQL journals read back from, indexed as a queryable '' bucket on MongoDB, one hot tag = '' partition on the Cassandra tag index, and a rejected item on DynamoDB, whose string sets may not hold an empty member. The usual source is a [category, subCategory ?? ''] shape with the second slot unfilled
the same tag twice on one eventthe same spread: Cassandra’s set<text> and the tag table’s idempotent insert collapse it, MongoDB and the CSV column keep it, and DynamoDB fails the whole append, since a string set may not repeat a member
longer than 255 charactersa tag is indexed, and an unbounded one bloats both the event row and every index built over it
more than 64 tags on one eventthe same bound, counted per event — a persistAll of ten events may carry 64 each, not 64 between them
a commathe SQLite and relational journals store tags as a comma-separated column, so a , inside one would split into extra tags on read — a caller could append entries to a peer event’s tag list
control characterstags are interpolated into log lines, so a newline lets a caller forge log records

Everything else is allowed. Tags are compared byte for byte, so 'Order' and 'order' are two tags and not a repeat, and a trailing space makes a third.

Check your own tags against these rules without starting an actor:

import { assertValidTags } from 'actor-ts/persistence';
assertValidTags(tagsFor(event));

Reading is never refused. Only append validates, so a stream written before these rules — one that already carries an empty or repeated tag — replays unchanged, and journal.read(id, 1) hands back exactly the list it stored. What changes is that new appends with such a list now throw, so filter them in tagsFor rather than letting the journal decide.

Copying such a stream counts as a write. Read and append are the two halves the rule above splits, and a journal-to-journal copy is both at once: migrateBetweenJournals reads a historical tag list and offers it straight to the target’s append. So a legacy stream replays forever but cannot be copied as it stands — the one case the split does not cover on its own.

The copy says so before it writes anything. It walks the source in a read-only preflight and raises MigrationTagError, naming the persistence ID and the sequence number, with the target and the progress store untouched; a run that gets as far as its first append goes on to finish. Two ways forward, both on the copy:

import { migrateBetweenJournals } from 'actor-ts/persistence';
// Decide for yourself what the legacy list should have been …
await migrateBetweenJournals(oldJournal, newJournal, {
eventTransform: (e) => ({ ...e, tags: e.tags?.filter((tag) => tag.length > 0) }),
});
// … or take the two repairs that need no judgement. Empty members
// are dropped and repeats collapsed.
const result = await migrateBetweenJournals(oldJournal, newJournal, {
invalidTags: 'sanitize',
});
console.log(`${result.eventsWithSanitizedTags} tag lists rewritten`);

'sanitize' fixes only those two shapes. A comma, a control character, an over-long tag or too many tags still refuses under it, because repairing those means inventing a tag or discarding one the caller meant — eventTransform is where that decision belongs. See Migration recipes for the full copy story.

When event shapes change over time (a field is renamed, a value is split, an enum is added), old events stay in the journal forever. The event adapter upgrades them on read:

import { EventAdapter } from 'actor-ts/persistence';
const v1ToV2Adapter: EventAdapter<EventV2> = {
manifest: () => 'deposited',
toJournal: (event) => ({ manifest: 'deposited', version: 2, payload: event }),
fromJournal: (stored) => stored.version === 1
? migrate(stored.payload as EventV1)
: stored.payload as EventV2,
};
class Account extends PersistentActor<...> {
override eventAdapter() { return v1ToV2Adapter; }
}

With an adapter configured, every event is wrapped in a { _v, _t, _e } envelope (version + type + payload) at persist time, and unwrapped through adapter.fromJournal(stored) at read time. Backward compatibility is the actor’s responsibility.

See Migration overview for the full migration story.

Every store — journal, snapshot store, durable-state store, on every backend — writes payloads in the tagged JSON tree format (the same tree JsonSerializer uses), so rich types survive the round-trip without any configuration. The in-memory stores apply the same round-trip, so what works in a test works in production and vice versa.

Payload contentRound-trip behavior
Plain objects, arrays, strings, finite numbers, booleans, nullStored as plain JSON — byte-identical to before.
Date, Map, Set, bigint, Uint8ArrayRound-trip as real instances via type tags.
BidirectionalMapRound-trips as a real instance — one of two framework classes with a tag of its own (#1035). Only the forward pairs are stored; the inverse is rebuilt on decode.
BidirectionalMultiMapRound-trips as a real instance (#1037). Stored as a forward adjacency list; the inverse is rebuilt on decode, and a participant left with no partners is not stored at all.
NaN, Infinity, -Infinity, -0Round-trip exactly — no more null / 0 (#889).
RegExp (source + flags), URLRound-trip as real instances.
Error (incl. subclasses, cause, AggregateError)Round-trips name + message + cause — deliberately without the stack: a persisted stack would leak filesystem paths into long-lived rows.
Typed arrays (Int8Array … BigUint64Array), DataView, ArrayBufferRound-trip byte-exactly.
undefined object propertyDropped, like JSON.stringify — reads back as undefined.
undefined in arrays / Set / Map entriesPreserved as undefined (#889) — plain JSON’s null there would be a different value.
new Number/String/Boolean wrappersUnwrap to their primitive, like JSON.stringify.
Class instancestoJSON() is honoured; otherwise stored as plain { ...fields } — methods and instanceof are gone after recovery. Use plain data, an event adapter, or a per-store serializer (below).
Functions, symbols, circular references, Promise, WeakMap / WeakSetThrow at persist time (SerializationError — loud, instead of corrupting the stored row).

Every store options builder takes withSerializer(serializer) — the way to preserve class identity or use a binary format for rows. See using a custom serializer for persistence for the framing, mixed-history behavior, and the in-memory exception.

CborSerializer carries the whole table above, so swapping it in for row size costs you nothing from this list. Any other serializer carries whatever it carries — a schema-driven one (Avro, Protobuf) only knows the fields in its schema.

Rows written by earlier actor-ts versions used bare JSON.stringify; they keep decoding unchanged — the tags only appear in newly written rows where plain JSON would corrupt the value.

Sharding gives you one entity instance per persistence ID — until it doesn’t. A network partition plus a rebalance, or an orchestration mistake that spawns the same entity twice, and two instances both recover and both accept commands.

The journal survives that: appends are conditional on the sequence number, so one writer loses with JournalConcurrencyError. One prerequisite carries all of it: both writers must be appending to the same journal. Two nodes each on their own database cannot conflict — each head check runs against its own file — which is why per-node storage in a cluster forks history silently instead of losing a race loudly, and why the framework warns about it (storage locality & identity). The damage is outside the journal. By the time the loser finds out it has already run onCommand for the command that triggered the write — and if that handler charged a card, sent a mail or called a downstream service, nothing rolls it back. Until its next persist it also answers reads from state the other writer has moved past.

A JournalConcurrencyError is treated as what it is — evidence that this instance lost an ownership race — and the actor stops. It does not restart. That distinction is the point: the default supervision answer to an error is a restart, and a restarted loser recovers the now-foreign journal head and carries on serving as though it owned the entity.

This costs nothing and needs no configuration. What it cannot do is undo the side effect that already ran.

For entities whose commands have external effects, bind writes to one holder by overriding lease():

class PaymentEntity extends PersistentActor<Command, Event, State> {
readonly persistenceId: string;
constructor(entityId: string, private readonly entityLease: Lease) {
super();
this.persistenceId = `payment-${entityId}`;
}
override lease(): Lease | null { return this.entityLease; }
// ...
}

The lease is acquired in preStart, before recovery — an instance that does not own the entity never even reads its history, so it cannot serve stale answers in the meantime. A non-holder’s persist throws immediately instead of discovering the conflict at the journal, which is what keeps the duplicate side effect from firing at all.

Gate on this.isLeaseHolder if you would rather branch than catch. Losing a held lease flips the actor to non-holder and calls onLeaseLost(reason), whose default is to stop — a PersistentActor that may not write is rarely usefully alive. Override it to keep serving reads.

The PersistentActor API reference covers the full base-class surface.