Zum Inhalt springen
Deutsch

Migration im Überblick

Event-sourced Systeme behalten Events für immer. Ein Event, das in v1 deines Codes geschrieben wurde, bleibt im Journal, wenn v3 ausgeliefert wird. Wenn v3 dieses Event liest, kann die Form falsch sein: ein Feld wurde umbenannt, ein Enum hat eine Variante bekommen, ein Wert wurde in zwei gesplittet.

Das Migrations-Toolkit des Frameworks beantwortet: “Wie entwickle ich Event- / State-Formen weiter, ohne Recovery zu brechen?”

Vier kooperierende Werkzeuge:

WerkzeugWann
Envelope-FormatJedes persistierte Event trägt einen Versions-Tag. Ermöglicht Migration.
Schema-RegistryOptional — deklariert bekannte Schemas + ihre Versionen.
defaultsAdapterDefaults automatisch füllen für Felder, die in einer neueren Version hinzugefügt wurden.
migratingAdapterTransformationen von v1 → v2 → v3 verketten.
Legacy wrappenUn-envelope’te Legacy-Events per Bulk in versionierte Envelopes wickeln.

Plus eine fokussiertere Seite: Rezepte — das Kochbuch häufiger Migrationen.

Ohne Migration sind persistierte Events rohe Payloads:

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

Wenn du einen Adapter an den Actor hängst, verpackt das Framework das Event zur Persist-Zeit in einen Envelope:

{
"_v": 1, // Version
"_t": "deposited", // Typ-Tag
"_e": { "kind": "deposited", "amount": 100 } // Payload
}

Beim Lesen sieht der Adapter die Version + Payload und upcasted auf die aktuelle Form, bevor das onEvent des Actors sie sieht.

Siehe Envelope-Format für die Details.

Du lieferst v1 aus:

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

Das Journal akkumuliert V1-Events. In v2 fügst du currency hinzu:

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

currency zum Typ hinzuzufügen bricht die Recovery für V1-Events (die das Feld nicht haben). Drei Optionen:

Richte einen defaultsAdapter ein, der fehlende Felder füllt:

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

V1-Events werden zurückgelesen als { kind: 'deposited', amount: 100, currency: 'USD' }. Billig, automatisch, funktioniert nur für additive Änderungen.

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);
}
}

Die Chain läuft sequenziell. V1-Events fließen durch den 1 → 2-Schritt; V2-Events überspringen ihn.

Für komplexe Migrationen (Umbenennungen, Restrukturierung, Splitting) implementiere EventAdapter<E> direkt:

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,
};

Volle Kontrolle; keine Einschränkungen bei Formtransformationen.

ja

nein

ja

nein

Single-Step

Multi-Step

Ist die Änderung ADDITIV?

(nur neue Felder, sinnvolle Defaults)

Transformierbar von alt → neu?

Single-Step oder Multi-Step?

defaultsAdapter

kein Aufwand

migratingAdapter

migratingAdapter

Chain

Benutzerdefinierter EventAdapter

restrukturiert: Umbenennungen, Splits, Joins

DurableStateActor hat die gleiche Maschinerie über StateAdapter:

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

Persistierte States werden in den gleichen Envelope (_v / _t / _e) verpackt. Die gleichen Migrations-Werkzeuge funktionieren für beide Persistenz-Arten.

Für größere Codebases mit vielen Event-Typen gibt die Schema-Registry ein typisiertes Register aller bekannten Event-Formen + ihrer Versionen:

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 — Adapter funktionieren ohne sie. Nützlich, wenn:

  • Du eine einzige Source of Truth für “welche Versionen existieren” willst.
  • Du Laufzeit-Validierung willst, dass Events zu einem registrierten Schema passen.
  • Du Tooling baust, das Schemas introspectet (Admin-Dashboards, Migrations-Skripte).

Siehe Schema-Registry.

Wenn dein Journal Events aus der Zeit vor der Adapter-Aktivierung hat, haben sie keine Envelopes — sie sind rohe Payloads. Der WrapLegacy-Helper überbrückt:

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}`);

Das ist ein einmaliges Umschreiben der gespeicherten Daten; danach replayt dein normaler Adapter die nun eingewickelten v1-Events. Siehe Legacy wrappen.

Migrationen werden normalerweise in Phasen ausgerollt:

  1. Code-Änderung — den Adapter hinzufügen, mit der neuen Version deployen, die alt + neu lesen kann.
  2. Verifizieren — mehrere persistenceIds wiederherstellen; sicherstellen, dass weder alte noch neue Events Fehler produzieren.
  3. V2-Events zu schreiben beginnen — dein onCommand produziert V2-förmige Events (der Adapter wird auf dem Write-Pfad nicht involviert).
  4. (Optional) Schema-Cleanup — sobald genug V2-Events akkumulieren und Snapshots die V1-Events abdecken, kannst du die Chain vereinfachen, indem du sehr alte Versionsschritte entfernst, wenn du bestätigt hast, dass keine V1-Events mehr verbleiben.

Für Rolling Deployments ohne Downtime siehe Rolling Migration und Rezepte.