Pular para o conteúdo
Português (BR)

Migration overview

Este conteúdo não está disponível em sua língua ainda.

Event-sourced systems keep events forever. An event written in v1 of your code stays in the journal when v3 ships. When v3 reads that event, the shape may be wrong: a field was renamed, an enum gained a variant, a value was split into two.

The framework’s migration toolkit answers: “How do I evolve event / state shapes without breaking recovery?”

Four cooperating tools:

ToolWhen
Envelope formatEvery persisted event carries a version tag. Enables migrating.
Schema registryOptional — declares known schemas + their versions.
defaultsAdapterAuto-fill defaults for fields added in a newer version.
migratingAdapterChain transformations from v1 → v2 → v3.
Wrap legacyBulk-wrap un-envelope’d legacy events into versioned envelopes.

Plus a more focused page: Recipes — the cookbook of common migrations.

Without migration, persisted events are raw payloads:

{ "kind": "deposited", "amount": 100 }

When you attach an adapter to the actor, the framework wraps the event in an envelope at persist time:

{
"_v": 1, // version
"_t": "deposited", // type tag
"_e": { "kind": "deposited", "amount": 100 } // payload
}

On read, the adapter sees the version + payload and upcasts to the current shape before the actor’s onEvent sees it.

See Envelope format for the details.

You ship v1:

type EventV1 = { kind: 'deposited'; amount: number };

The journal accumulates V1 events. In v2, you add a currency:

type EventV2 = { kind: 'deposited'; amount: number; currency: string };

Adding currency to the type breaks recovery for V1 events (which don’t have the field). Three options:

Set up a defaultsAdapter that fills missing fields:

class Account extends PersistentActor<...> {
override eventAdapter() {
return defaultsAdapter<EventV2>({
manifest: 'deposited',
currentVersion: 2,
defaults: { 1: { currency: 'USD' } },
});
}
}

V1 events read back as { kind: 'deposited', amount: 100, currency: 'USD' }. Cheap, automatic, only works for additive changes.

class Account extends PersistentActor<...> {
override eventAdapter() {
const chain = MigrationChain.for<EventV2>('deposited', 2)
.add({ fromVersion: 1, toVersion: 2,
upcast: (v1: EventV1): EventV2 => ({ ...v1, currency: lookupCurrency(v1) }) });
return migratingAdapter(chain);
}
}

The chain runs sequentially. V1 events flow through the 1 → 2 step; V2 events skip it.

For complex migrations (renaming, restructuring, splitting), implement EventAdapter<E> directly:

const adapter: EventAdapter<EventV2> = {
manifest: () => 'deposited',
toJournal: (e) => ({ manifest: 'deposited', version: 2, payload: e }),
fromJournal: (stored) => stored.version === 1
? migrateV1ToV2(stored.payload as EventV1)
: stored.payload as EventV2,
};

Full control; no constraints on shape transformations.

yes

no

yes

no

single-step

multi-step

Is the change ADDITIVE?

(new fields only, sensible defaults)

Transformable from old → new?

Single-step or multi-step?

defaultsAdapter

zero-effort

migratingAdapter

migratingAdapter

chain

Custom EventAdapter

restructured: renames, splits, joins

DurableStateActor has the same machinery via StateAdapter:

class Cart extends DurableStateActor<...> {
protected stateAdapter() {
return defaultsSnapshotAdapter<StateV2>({
manifest: 'CartState', currentVersion: 2, defaults: { 1: { /* ... */ } },
});
}
}

Persisted states are wrapped in the same envelope (_v / _t / _e). The same migration tools work for either kind of persistence.

For larger codebases with many event types, the schema registry gives a typed registry of all known event shapes + their versions:

import { InMemorySchemaRegistry, zodCodec } from 'actor-ts';
const registry = new InMemorySchemaRegistry();
registry.register('Deposited', 2, { codec: zodCodec(DepositedV2) });
registry.register('Withdrawn', 1, { codec: zodCodec(WithdrawnV1) });
registry.register('AccountClosed', 1, { codec: zodCodec(AccountClosedV1) });

Optional — adapters work without it. Useful when:

  • You want a single source of truth for “what versions exist.”
  • You want runtime validation that events match a registered schema.
  • You’re building tooling that introspects schemas (admin dashboards, migration scripts).

See Schema registry.

If your journal has events from before adapters were enabled, they don’t have envelopes — they’re raw payloads. The WrapLegacy helper bridges:

import { migrateInMemoryJournal } from 'actor-ts';
// One-shot bulk rewrite — wrap every raw event as a v1 envelope
// BEFORE the actor with the new adapter recovers.
await migrateInMemoryJournal(journal, (e) => `${e.kind}`);

This is a one-shot rewrite of the stored data; afterwards your normal adapter replays the now-enveloped v1 events. See Wrap legacy.

Migrations are usually rolled out in phases:

  1. Code change — add the adapter, deploy with the new version able to read old + new.
  2. Verify — recover several persistenceIds; ensure no errors on either old or new events.
  3. Start writing v2 events — your onCommand produces V2-shaped events (the adapter doesn’t get involved on the write path).
  4. (Optional) Schema cleanup — once enough V2 events accumulate and snapshots cover the V1 events, you can simplify the chain by removing very-old version steps if you’ve confirmed no V1 events remain.

For rolling deployments without downtime, see Rolling migration and Recipes.