Schema registry
이 콘텐츠는 아직 번역되지 않았습니다.
For larger codebases with many event types, knowing what
exists becomes a problem. Each PersistentActor declares its
events; some have adapters; versions, codecs and upcasters live
scattered across the adapter configs. No central catalog.
InMemorySchemaRegistry is the optional catalog. You register
each (manifest, version) once — with the codec that
validates that version and the upcaster that brings the previous
version forward — and the registry builds the adapters for you:
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 mutates the registry in place and returns void;
re-registering the same (manifest, version) overwrites it. Use
the registry to:
- Document the current state of every schema.
- Validate payloads — each version’s
codec(typically azodCodec) is enforced on both the write and read paths. - Build adapters —
registry.eventAdapter(manifest)writes at the latest version and reads any older version by chaining the registered upcasters forward. - Drive tooling — admin dashboards, dev-tools showing journal contents.
Most projects don’t need it. Reach for it when you have 10+ event types and find yourself manually tracking which is at which version.
A minimal example
Section titled “A minimal example”import { InMemorySchemaRegistry, zodCodec } from 'actor-ts';
// Shared module — `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) });Then in actors — no hand-rolled chain; the registry builds the adapter:
import { registry } from './schemas.js';
class Account extends PersistentActor<...> { override eventAdapter() { return registry.eventAdapter<DepositedV2>('Deposited'); }}The adapter writes new events at the latest registered version
of 'Deposited' and, on read, decodes each stored event with its
own version’s codec before chaining upcasters forward to the
latest shape.
The API
Section titled “The 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? })— add or replace one version. Mutates in place, returnsvoid.get(manifest, version)— theSchemaDescriptorfor one version, orundefined.latestVersion(manifest)— highest registered version, orundefinedwhen the manifest is unknown.list()— every registration as aReadonlyArray<SchemaDescriptor>({ manifest, version, codec, … }).eventAdapter(manifest)/snapshotAdapter(manifest)— build the migrating adapter for events / state.
SchemaRegistry is an interface; the shipped implementation
is InMemorySchemaRegistry (new InMemorySchemaRegistry()),
which keeps all state in one process. Implement the interface
yourself if you need a different backing store.
Compatibility checks
Section titled “Compatibility checks”The registry can verify, at register time, that a new version can still read the previous one — catching a missing or broken upcaster before you deploy:
registry.register('Deposited', 2, { codec: zodCodec(DepositedV2), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }), compatibility: 'backward',});'none'(default) — no check.'backward'— require that the previous version is registered and that this registration supplies anupcastFromPrev. The structural minimum that keeps the read path working for old data.'sample'— everything'backward'checks, plus a round-trip of a suppliedsample: decode with the previous codec, upcast, re-encode with the new codec. Catches latent upcaster bugs at register time rather than at deployment time.
registry.register('Deposited', 2, { codec: zodCodec(DepositedV2), upcastFromPrev: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }), compatibility: 'sample', sample: { kind: 'deposited', amount: 100 },});// → throws at register time if the v1 → v2 round-trip failsAn incompatible registration throws immediately, so wiring the registry up at startup is your safety net: bump a version but forget the upcaster and registration fails loud.
A binary wire format per version
Section titled “A binary wire format per version”A codec does not have to be a validator. serializerCodec
wraps any Serializer — including the
shipped AvroSerializer and ProtobufSerializer — so each
version can use a different wire format:
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',});New events are written at v2 as Protobuf; the Avro rows already on disk keep decoding through the v1 codec and are upcast forward on read. Both formats coexist in one stream.
This is the only place that works. A store’s
withSerializer(...) is store-wide — one format for every payload
it writes — so a per-version switch has to happen at the codec
layer.
What goes on disk. serializerCodec encodes to
{ serializerId, manifest, bytes } and stops there: the journal’s
own payload codec already carries a Uint8Array as tagged JSON, so
the bytes are base64’d exactly once. The serializerId travels
with the row, which is what makes a mismatch loud —
serializer:avro: payload was written by serializer id 101(manifest '.bank.DepositedV2'), but this codec holds 'avro' (id 100)— rather than silently decoding into nonsense, which is the normal outcome for Avro (no field tags) and Protobuf (no message name) when they are handed foreign bytes.
Pair it with compatibility: 'sample' and the register call runs
the whole Avro → upcast → Protobuf hop at startup, so a v2 schema
your upcaster cannot actually satisfy fails on deploy instead of
on the first replay.
Tooling
Section titled “Tooling”A few common tooling shapes that benefit from the registry:
// Admin panel: list every registered schema and its versionconst all = registry.list(); // → [{ manifest: 'Deposited', version: 2, codec, ... }, ...]
// Dev script: warn when an event in the journal has a version// HIGHER than what the registry knows (a 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}`); }}When you don’t need it
Section titled “When you don’t need it”Where to next
Section titled “Where to next”- Migration overview — the bigger picture.
- defaultsAdapter — the typed adapters that consume the registry.
- migratingAdapter — the chained alternative.
- Envelope format —
the on-disk shape with
_v/_t/_e. - Custom serializers — the Avro and
Protobuf serializers
serializerCodecwraps.
The SchemaRegistry API
reference covers the full surface.
