Schema-Registry
Für größere Codebases mit vielen Event-Typen wird zu wissen,
was existiert, ein Problem. Jeder PersistentActor
deklariert seine Events; einige haben Adapter; Versionen, Codecs
und Upcaster liegen verstreut in den Adapter-Configs. Kein
zentraler Katalog.
InMemorySchemaRegistry ist der optionale Katalog. Du
registrierst jedes (manifest, version) einmal — mit dem
Codec, der diese Version validiert, und dem Upcaster, der die
vorherige Version nach vorne bringt — und die Registry baut die
Adapter für dich:
import { InMemorySchemaRegistry, zodCodec } from 'actor-ts';import { z } from 'zod';
const DepositedV1 = z.object({ kind: z.literal('deposited'), amount: z.number() });const DepositedV2 = z.object({ kind: z.literal('deposited'), amount: z.number(), currency: z.enum(['USD', 'EUR']),});type DepositedV1 = z.infer<typeof DepositedV1>;type DepositedV2 = z.infer<typeof DepositedV2>;
export const registry = new InMemorySchemaRegistry();
registry.register('Deposited', 1, { codec: zodCodec(DepositedV1) });registry.register('Deposited', 2, { codec: zodCodec(DepositedV2), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }),});register mutiert die Registry an Ort und Stelle und gibt
void zurück; erneutes Registrieren desselben
(manifest, version) überschreibt es. Verwende die Registry, um:
- Den aktuellen Stand jedes Schemas zu dokumentieren.
- Payloads zu validieren — der
codecjeder Version (typischerweise einzodCodec) wird sowohl auf dem Schreib- als auch dem Lesepfad erzwungen. - Adapter zu bauen —
registry.eventAdapter(manifest)schreibt bei der neuesten Version und liest jede ältere Version, indem es die registrierten Upcaster nach vorne verkettet. - Tooling anzutreiben — Admin-Dashboards, Dev-Tools, die Journal-Inhalte zeigen.
Die meisten Projekte brauchen sie nicht. Greif danach, wenn du 10+ Event-Typen hast und dich dabei ertappst, manuell zu verfolgen, welche bei welcher Version ist.
Ein minimales Beispiel
Abschnitt betitelt „Ein minimales Beispiel“import { InMemorySchemaRegistry, zodCodec } from 'actor-ts';
// Geteiltes Modul — `schemas.ts`:export const registry = new InMemorySchemaRegistry();
registry.register('Deposited', 1, { codec: zodCodec(DepositedV1) });registry.register('Deposited', 2, { codec: zodCodec(DepositedV2), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }),});
registry.register('Withdrawn', 1, { codec: zodCodec(WithdrawnV1) });registry.register('Frozen', 1, { codec: zodCodec(FrozenV1) });Dann in Actors — keine handgeschriebene Chain; die Registry baut den Adapter:
import { registry } from './schemas.js';
class Account extends PersistentActor<...> { override eventAdapter() { return registry.eventAdapter<DepositedV2>('Deposited'); }}Der Adapter schreibt neue Events bei der neuesten
registrierten Version von 'Deposited' und dekodiert beim Lesen
jedes gespeicherte Event mit dem Codec seiner eigenen Version,
bevor er die Upcaster nach vorne zur neuesten Form verkettet.
Die API
Abschnitt betitelt „Die API“interface SchemaRegistry { register<Wire, Upcasted>( manifest: string, version: number, registration: SchemaRegistration<Wire, Upcasted>, ): void; get(manifest: string, version: number): SchemaDescriptor | undefined; latestVersion(manifest: string): number | undefined; list(): ReadonlyArray<SchemaDescriptor>; eventAdapter<E>(manifest: string): EventAdapter<E, unknown>; snapshotAdapter<S>(manifest: string): SnapshotAdapter<S, unknown>;}
type SchemaRegistration<Wire, Upcasted> = { readonly codec: Codec<Wire>; readonly upcastFromPrev?: (prev: unknown) => Upcasted; readonly compatibility?: 'none' | 'backward' | 'sample'; readonly sample?: unknown;};register(manifest, version, { codec, upcastFromPrev?, compatibility?, sample? })— eine Version hinzufügen oder ersetzen. Mutiert an Ort und Stelle, gibtvoidzurück.get(manifest, version)— derSchemaDescriptorfür eine Version, oderundefined.latestVersion(manifest)— höchste registrierte Version, oderundefined, wenn das Manifest unbekannt ist.list()— jede Registrierung alsReadonlyArray<SchemaDescriptor>({ manifest, version, codec, … }).eventAdapter(manifest)/snapshotAdapter(manifest)— den migrierenden Adapter für Events / State bauen.
SchemaRegistry ist ein Interface; die ausgelieferte
Implementierung ist InMemorySchemaRegistry
(new InMemorySchemaRegistry()), die den gesamten State in einem
Prozess hält. Implementiere das Interface selbst, wenn du einen
anderen Backing-Store brauchst.
Kompatibilitäts-Checks
Abschnitt betitelt „Kompatibilitäts-Checks“Die Registry kann zur Register-Zeit prüfen, dass eine neue Version die vorherige noch lesen kann — und so einen fehlenden oder kaputten Upcaster erwischen, bevor du deployst:
registry.register('Deposited', 2, { codec: zodCodec(DepositedV2), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }), compatibility: 'backward',});'none'(Default) — kein Check.'backward'— verlangt, dass die vorherige Version registriert ist und dass diese Registrierung einupcastFromPrevliefert. Das strukturelle Minimum, das den Lesepfad für alte Daten funktionsfähig hält.'sample'— alles, was'backward'prüft, plus ein Round-Trip eines geliefertensample: mit dem vorherigen Codec dekodieren, upcasten, mit dem neuen Codec neu enkodieren. Erwischt latente Upcaster-Bugs zur Register-Zeit statt zur Deploy-Zeit.
registry.register('Deposited', 2, { codec: zodCodec(DepositedV2), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }), compatibility: 'sample', sample: { kind: 'deposited', amount: 100 },});// → wirft zur Register-Zeit, wenn der v1 → v2 Round-Trip fehlschlägtEine inkompatible Registrierung wirft sofort, daher ist es dein Sicherheitsnetz, die Registry beim Startup zu verdrahten: eine Version hochziehen, aber den Upcaster vergessen, und die Registrierung schlägt laut fehl.
Ein binäres Wire-Format pro Version
Abschnitt betitelt „Ein binäres Wire-Format pro Version“Ein codec muss kein Validator sein. serializerCodec verpackt
jeden Serializer — auch die
mitgelieferten AvroSerializer und ProtobufSerializer —, sodass
jede Version ein anderes Wire-Format nutzen kann:
import { serializerCodec } from 'actor-ts';
registry.register('BankAccount.Deposited', 1, { codec: serializerCodec(avroSerializer),});
registry.register('BankAccount.Deposited', 2, { codec: serializerCodec(protobufSerializer), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }), compatibility: 'backward',});Neue Events werden als v2 in Protobuf geschrieben; die Avro-Zeilen, die schon auf der Platte liegen, dekodieren weiter über den v1-Codec und werden beim Lesen nach vorne gezogen. Beide Formate koexistieren in einem Stream.
Das ist die einzige Stelle, an der das geht. Das
withSerializer(...) eines Stores gilt storeweit — ein Format für
jede Payload, die er schreibt —, ein Wechsel pro Version muss also
auf der Codec-Ebene passieren.
Was auf der Platte landet. serializerCodec kodiert zu
{ serializerId, manifest, bytes } und hört da auf: der
Payload-Codec des Journals trägt ein Uint8Array bereits als
getaggtes JSON, die Bytes werden also genau einmal base64-kodiert.
Die serializerId reist mit der Zeile mit, und genau das macht
eine Verwechslung laut —
serializer:avro: payload was written by serializer id 101(manifest '.bank.DepositedV2'), but this codec holds 'avro' (id 100)— statt still zu Unsinn zu dekodieren, was bei Avro (keine Feld-Tags) und Protobuf (kein Nachrichtenname) der Normalfall ist, wenn man ihnen fremde Bytes gibt.
In Kombination mit compatibility: 'sample' läuft der
Register-Aufruf den ganzen Weg Avro → Upcast → Protobuf beim
Startup durch: ein v2-Schema, das dein Upcaster gar nicht erfüllen
kann, scheitert beim Deploy statt beim ersten Replay.
Tooling
Abschnitt betitelt „Tooling“Ein paar häufige Tooling-Formen, die von der Registry profitieren:
// Admin-Panel: jedes registrierte Schema und seine Version auflistenconst all = registry.list(); // → [{ manifest: 'Deposited', version: 2, codec, ... }, ...]
// Dev-Skript: warnen, wenn ein Event im Journal eine Version hat,// die HÖHER ist als das, was die Registry kennt (ein Deploy-Issue)for await (const event of query.eventsByPersistenceId(...)) { const envelopeVersion = event.event._v; const latest = registry.latestVersion(event.event._t); if (latest !== undefined && envelopeVersion > latest) { console.warn(`event ${event.event._t} has version ${envelopeVersion}, registry knows up to ${latest}`); }}Wann du sie nicht brauchst
Abschnitt betitelt „Wann du sie nicht brauchst“Wie geht’s weiter
Abschnitt betitelt „Wie geht’s weiter“- Migration im Überblick — das größere Bild.
- defaultsAdapter — die typisierten Adapter, die die Registry konsumieren.
- migratingAdapter — die verkettete Alternative.
- Envelope-Format —
die On-Disk-Form mit
_v/_t/_e. - Eigene Serializer — die Avro-
und Protobuf-Serializer, die
serializerCodecverpackt.
Die SchemaRegistry-API-Referenz
deckt die vollständige Oberfläche ab.
