Zum Inhalt springen
Deutsch

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 codec jeder Version (typischerweise ein zodCodec) wird sowohl auf dem Schreib- als auch dem Lesepfad erzwungen.
  • Adapter zu bauenregistry.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.

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.

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, gibt void zurück.
  • get(manifest, version) — der SchemaDescriptor für eine Version, oder undefined.
  • latestVersion(manifest) — höchste registrierte Version, oder undefined, wenn das Manifest unbekannt ist.
  • list() — jede Registrierung als ReadonlyArray<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.

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 ein upcastFromPrev liefert. 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 gelieferten sample: 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ägt

Eine 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 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.

Ein paar häufige Tooling-Formen, die von der Registry profitieren:

// Admin-Panel: jedes registrierte Schema und seine Version auflisten
const 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}`);
}
}

Die SchemaRegistry-API-Referenz deckt die vollständige Oberfläche ab.