Zum Inhalt springen
Deutsch

migratingAdapter

migratingAdapter handhabt nicht-additive Migrationen — Umbenennungen, Restrukturierungen, aus-anderen-Feldern-berechnet — indem es eine MigrationChain ausführt: eine Sequenz typisierter Upcaster. Du baust die Chain und wickelst sie dann ein:

import { migratingAdapter, MigrationChain, type EventAdapter } from 'actor-ts';
type DepositedV1 = { kind: 'deposited'; amount: number };
type DepositedV2 = { kind: 'deposited'; cents: number; currency: string };
type DepositedV3 = { kind: 'deposited'; cents: number; currency: string; tenantId: string };
class Account extends PersistentActor<Command, DepositedV3, State> {
constructor(public readonly tenantId: string) { super(); }
override eventAdapter(): EventAdapter<DepositedV3> {
const chain = MigrationChain.for<DepositedV3>('BankAccount.Deposited', 3)
.add({ fromVersion: 1, toVersion: 2,
upcast: (v1: DepositedV1): DepositedV2 => ({
kind: v1.kind, cents: v1.amount * 100, currency: 'USD',
}) })
.add({ fromVersion: 2, toVersion: 3,
upcast: (v2: DepositedV2): DepositedV3 => ({
...v2, tenantId: this.tenantId,
}) });
return migratingAdapter(chain);
}
}

Die Chain:

  • v1 → v2 restrukturiert: amount in cents umbenennen, mal 100, currency hartkodieren.
  • v2 → v3 ergänzt: tenantId aus der Actor-Instanz ziehen.

Der Adapter wendet an, welche Schritte nötig sind:

  • v1-Events: beide Schritte laufen → v3-Form.
  • v2-Events: nur der v2 → v3-Schritt läuft → v3-Form.
  • v3-Events: keine Schritte → bereits aktuell.

migratingAdapter ist eine Funktion über einer MigrationChain. currentVersion und Manifest liegen auf der Chain; die Optionen des Adapters steuern nur die Schreib-Version:

function migratingAdapter<E>(
chain: MigrationChain<E>,
options?: { writeVersion?: number }, // default: chain.currentVersion
): EventAdapter<E, unknown>;
// Steps added to the chain:
interface MigrationStep<From, To> {
fromVersion: number;
toVersion: number;
upcast(from: From): To;
}

Baue die Chain mit MigrationChain.for<E>(manifest, currentVersion) und .add(step). Schritte müssen vorwärts laufen (fromVersion < toVersion) — konsekutiv ist normal, größere Sprünge sind erlaubt, aber selten.

migratingAdapter ist das richtige Werkzeug, wenn:

  • Umbenennungenamountcents.
  • Restrukturierungen — flache Felder → verschachtelte Objekte oder umgekehrt.
  • Berechnete Felder — ein neues Feld, abgeleitet aus alten Feldern.
  • Mehrstufige Evolution — v1 → v2 → v3 → v4, jeder Schritt baut auf dem letzten auf.

Für reine Ergänzungen ist defaultsAdapter einfacher. Für beliebig geformte Migrationen schreib einen eigenen EventAdapter.

class Account extends PersistentActor<Command, EventV3, State> {
constructor(public readonly tenantId: string) { super(); }
override eventAdapter(): EventAdapter<EventV3> {
// The upcaster closes over `this.tenantId`:
const chain = MigrationChain.for<EventV3>('BankAccount.Deposited', 3)
.add({ fromVersion: 2, toVersion: 3,
upcast: (v2: EventV2): EventV3 => ({ ...v2, tenantId: this.tenantId }) });
return migratingAdapter(chain);
}
}

Upcaster sind einfache Funktionen — sie können über die Konstruktor-Argumente des Actors schließen. Nützlich für Per-Instanz-Migrationskontext (Per-Tenant-Defaults, Per-Region-Währung).

Gegeben ein v1-Event + eine Chain mit den Schritten 1 → 2 und 2 → 3:

storedV1 → upcast₁→₂(storedV1) = intermediateV2 → upcast₂→₃(intermediateV2) = finalV3

Die Zwischentypen müssen keiner historischen Event-Form entsprechen — sie sind nur Trittsteine. Die Chain läuft vom Cursor der gespeicherten Version vorwärts, bis sie currentVersion erreicht.

Während eines Rolling-Deploys müssen v2-Nodes womöglich weiterhin v1-Events emittieren, solange v1-Reader noch im Cluster sind. Füge der Chain Downcaster hinzu und setze writeVersion am Adapter:

const chain = MigrationChain.for<DepositedV2>('BankAccount.Deposited', 2)
.add({ fromVersion: 1, toVersion: 2,
upcast: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }) })
.addDown({ fromVersion: 2, toVersion: 1,
downcast: (v2: DepositedV2): DepositedV1 => {
const { currency: _c, ...rest } = v2; void _c; return rest;
} });
// Phase 1 — read both, still WRITE v1:
const phase1 = migratingAdapter(chain, { writeVersion: 1 });
// Phase 2 — every reader upgraded; write the current version:
const phase2 = migratingAdapter(chain); // writeVersion = currentVersion = 2

writeVersion muss ≤ currentVersion sein, und die Chain muss Downcaster für jeden Schritt auf dem Pfad currentVersion → writeVersion haben — sonst wirft toJournal.

.add({ fromVersion: 1, toVersion: 2,
upcast: (v1: DepositedV1): DepositedV2 => {
if (v1.amount < 0) throw new Error('invalid v1 event');
return { kind: v1.kind, cents: v1.amount * 100, currency: 'USD' };
} })

Wenn ein Schritt wirft, schlägt die Recovery fehl, und der Fehler kommt über das onRecoveryFailure des Actors zum Vorschein — und der Actor stoppt anschließend, sofern dieser Hook nicht in die Supervision rethrowt (siehe Wenn die Recovery fehlschlägt). Eine Chain-Lücke — kein Upcaster für den Cursor registriert, bevor currentVersion erreicht ist — wirft ein MigrationError, das die fehlende fromVersion nennt.

// v2 in production; now you want v3. Add a step and bump the chain version:
const chain = MigrationChain.for<EventV3>('BankAccount.Deposited', 3)
.add({ fromVersion: 1, toVersion: 2, upcast: v1To2 })
.add({ fromVersion: 2, toVersion: 3, upcast: v2To3 }); // NEW

Füge den v2 → v3-Schritt hinzu, behalte den bestehenden v1 → v2-Schritt und bump MigrationChain.for(..., 3). Bestehende v2-Events werden durch den neuen Schritt hochgecastet; v3-Events werden direkt geschrieben.

Ändere nie bestehende Schritte, die Events behandelt haben, die noch im Journal liegen — sie sind tragend.

Für eine MigrationChain, die einen Snapshot- / Durable-State-Datensatz regiert, nimm migratingSnapshotAdapter — dieselbe Chain, gibt einen SnapshotAdapter zurück:

import { migratingSnapshotAdapter } from 'actor-ts';
const adapter = migratingSnapshotAdapter(chain);