Zum Inhalt springen
Deutsch

Envelope-Format

Wenn ein Actor einen eventAdapter() (oder stateAdapter()) hat, wird jede persistierte Payload vor dem Write in einen versionierten Envelope verpackt:

{
"_v": 2, // Version
"_t": "deposited", // Typ-Tag
"_e": { "kind": "deposited", "amount": 100, "currency": "USD" }
}

Beim Lesen entpackt das Framework das und routet es durch das upcast(payload, version) des Adapters. Der Adapter sieht die Version + die Payload; er gibt das aktuelle-Form-Event zurück.

FeldTypZweck
_vnumberDie Version, unter der diese Payload geschrieben wurde.
_tstringDer Typ-Tag — typischerweise der Event-/State-Name. Optional, aber nützlich für Tooling.
_eobjectDie tatsächliche Payload.

Unterstriche, damit sie nicht mit User-Feldern kollidieren. Das Framework betrachtet jedes Objekt mit diesen drei Keys als Envelope.

class Account extends PersistentActor<...> {
override eventAdapter() {
return new SomeAdapter();
}
}

Einen Adapter zu setzen, lässt das Framework Events beim Write einwickeln + beim Read auspacken. Ohne Adapter werden Events roh geschrieben — kein Envelope.

Das ist strikt beim Lesen: sobald du einen Adapter setzt, wirft das Lesen eines Nicht-Envelope-Events MigrationError. Du kannst eingewickelte + rohe Events nicht für dieselbe pid mischen ohne WrapLegacy (siehe unten).

Für Erst-Deployments (frisches Journal) kannst du entweder von Anfang an mit Adaptern starten (jedes Event hat _v: 1) oder die Adapter-Einführung verzögern, bis du Migration wirklich brauchst. Die meisten Teams fügen Adapter erst hinzu, wenn die erste Breaking Change kommt.

interface EventAdapter<E> {
upcast(stored: unknown, version: number): E;
}

stored ist die _e-Payload — ohne den Envelope. version ist der _v-Wert. Der Job des Adapters:

  • version inspizieren.
  • Wenn es die aktuelle Version ist, stored zu E (die aktuelle Form) casten und zurückgeben.
  • Wenn es älter ist, stored in die aktuelle Form transformieren, dann zurückgeben.

Die eingebauten Adapter des Frameworks (DefaultAdapter, MigratingAdapter) kapseln dieses Pattern. Benutzerdefinierte Adapter implementieren dasselbe.

Wenn du die Version hochziehen willst:

class Account extends PersistentActor<...> {
override eventAdapter() {
return new MigratingAdapter<EventV2>({
currentVersion: 2,
chain: [
{ from: 1, to: 2, fn: v1ToV2 },
],
});
}
}

Der Adapter deklariert die aktuelle Version. Neue Events, die von onCommand geschrieben werden, bekommen jetzt _v: 2-Envelopes. Alte _v: 1-Events werden über die Chain hochgekastet.

Das funktioniert unabhängig davon, unter welcher Version ein gegebenes Event geschrieben wurde — die Migrations-Chain handhabt jedes.

Wenn du einen Adapter zu einem existierenden Journal hinzufügst, das bereits persistierte rohe Events hat:

class Account extends PersistentActor<...> {
override eventAdapter() {
return wrapLegacy<EventV2>({
legacyVersion: 1,
baseAdapter: new MigratingAdapter<EventV2>({
currentVersion: 2,
chain: [{ from: 1, to: 2, fn: v1ToV2 }],
}),
});
}
}

wrapLegacy behandelt jedes Nicht-Envelope-Event als Version 1 und füttert es durch den Rest der Chain. Siehe WrapLegacy für Details.

Der Envelope ist ein reguläres JSON-Objekt — serialisiert von welchem Serializer auch immer dein Journal verwendet (JSON für die SQLite- + In-Memory-Journals, anpassbar für Cassandra).

Das bedeutet:

  • Nur JSON-sichere Typen in _e — keine Funktionen, keine Symbols, keine BigInts ohne benutzerdefinierten Serializer.
  • Tief verschachtelte Objekte funktionieren — der Envelope ist eine Ebene tief; _e selbst kann beliebig komplex sein.
  • Binary-freundlich über CBOR — wenn du CBOR-Serialisierung konfigurierst, serialisiert der Envelope als CBOR (kompakter, binary-freundlich).