跳转到内容
简体中文

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 a zodCodec) is enforced on both the write and read paths.
  • Build adaptersregistry.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.

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.

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, returns void.
  • get(manifest, version) — the SchemaDescriptor for one version, or undefined.
  • latestVersion(manifest) — highest registered version, or undefined when the manifest is unknown.
  • list() — every registration as a ReadonlyArray<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.

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 an upcastFromPrev. The structural minimum that keeps the read path working for old data.
  • 'sample' — everything 'backward' checks, plus a round-trip of a supplied sample: 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 fails

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

A few common tooling shapes that benefit from the registry:

// Admin panel: list every registered schema and its version
const 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}`);
}
}

The SchemaRegistry API reference covers the full surface.