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/persistence';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 desselben Manifests, indem es die registrierten Upcaster nach vorne verkettet. Ein gespeicherter Frame mit einem anderen Manifest wird mit einemMigrationErrorabgewiesen, selbst wenn dieses Manifest ebenfalls registriert ist — siehe Ein Adapter, ein Manifest. - 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/persistence';
// 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 = unknown, Upcasted = unknown, Previous = unknown>( manifest: string, version: number, registration: SchemaRegistration<Wire, Upcasted, Previous>, ): void; get(manifest: string, version: number): SchemaDescriptor | undefined; latestVersion(manifest: string): number | undefined; list(): ReadonlyArray<SchemaDescriptor>; eventAdapter<E, JournalShape = E>(manifest: string): EventAdapter<E, JournalShape>; snapshotAdapter<S, StoredShape = S>(manifest: string): SnapshotAdapter<S, StoredShape>;}
type SchemaRegistration<Wire = unknown, Upcasted = unknown, Previous = unknown> = { readonly codec: Codec<Wire>; readonly upcastFromPrev?: (prev: Previous) => 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.
Ein Adapter, ein Manifest
Abschnitt betitelt „Ein Adapter, ein Manifest“Ein von eventAdapter(m) oder snapshotAdapter(m) gebauter Adapter
ist auf beiden Pfaden an m gebunden. Er schreibt m, und er
liest ausschließlich m: ein gespeicherter Frame mit einem anderen
Manifest löst einen MigrationError aus, selbst wenn dieses andere
Manifest in derselben Registry registriert ist.
const registry = new InMemorySchemaRegistry();registry.register('Account.Deposited', 1, { codec: zodCodec(DepositedV1) });registry.register('Account.Closed', 1, { codec: zodCodec(ClosedV1) });
const adapter = registry.eventAdapter<DepositedV1>('Account.Deposited');
// Reads its own manifest, at any registered version.adapter.fromJournal({ manifest: 'Account.Deposited', version: 1, payload });
// Refused — MigrationError: manifest mismatch: schema-registry adapter// is for 'Account.Deposited', got 'Account.Closed'adapter.fromJournal({ manifest: 'Account.Closed', version: 1, payload });Ohne diese Prüfung würde der zweite Read sauber durch den Codec von
Account.Closed dekodieren und einem statisch als DepositedV1
typisierten Aufrufer ein ClosedV1 zurückgeben — eine
Typverwechslung, die der Aufrufer nicht erkennen kann, weil die
Payload gültig ist und der statische Typ behauptet, er habe das
Erwartete bekommen. Auf einem Recovery-Pfad heißt das: ein fremdes
Event landet in onEvent und erzeugt einen State, den es nie gab.
MigrationChain.upcast und defaultsAdapter haben das immer schon
abgewiesen; der Registry-Adapter stimmt jetzt mit ihnen überein.
Der realistische Auslöser ist eine Fehlverdrahtung, kein
Angreifer: vertauschte Manifest-Strings von eventAdapter(...) und
snapshotAdapter(...), oder ein zwischen zwei Deploys geändertes
Argument, während alte Rows noch den alten Wert tragen. Was die
Registry selbst geschrieben hat, kann die Prüfung nie auslösen, denn
toJournal taggt immer mit dem eigenen Manifest des Adapters.
Die Konsequenz, die du einplanen solltest: ein Actor, dessen
Event-Union mehrere Manifeste umfasst, kann keinen einzelnen von der
Registry gebauten Adapter verwenden. Wirklich unterstützt war das
nie — toJournal taggt jedes Event mit dem einen Manifest und
validiert es gegen den Codec dieses Manifests, ein solcher Actor
konnte seine anderen Event-Typen also ohnehin nicht persistieren —
aber der Lesepfad ist früher stillschweigend fehlgeschlagen und
schlägt jetzt laut fehl. Schreib ein fromJournal, das auf
stored.manifest verzweigt und an einen Adapter pro Typ delegiert,
so wie es
MigrationChain für
ein Journal mit mehreren Typen erwartet.
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/persistence';
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.
