コンテンツにスキップ
日本語

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/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 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 adapters — registry.eventAdapter(manifest) writes at the latest version and reads any older version of that same manifest by chaining the registered upcasters forward. A stored frame carrying a different manifest is refused with a MigrationError, even when that manifest is registered too — see One adapter, one manifest.
  • 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/persistence';
// 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 = 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? }) — 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.

An adapter built by eventAdapter(m) or snapshotAdapter(m) is bound to m on both paths. It writes m, and it reads only m: a stored frame whose manifest is anything else raises a MigrationError, even when that other manifest is registered in the same registry.

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 });

Without that check the second read would decode cleanly through Account.Closed’s codec and hand a ClosedV1 back to a caller statically typed DepositedV1 — type confusion the caller cannot detect, because the payload is valid and the static type says it got what it asked for. On a recovery path that means a foreign event folded into onEvent and a state that never existed. MigrationChain.upcast and defaultsAdapter have always refused this; the registry adapter now agrees with them.

The realistic trigger is a mis-wiring, not an attacker: the manifest strings of eventAdapter(...) and snapshotAdapter(...) swapped, or the argument changed between deploys while old rows still carry the old value. Nothing the registry itself wrote can trip it, since toJournal always tags with the adapter’s own manifest.

The consequence worth planning for: one actor whose event union spans several manifests cannot use a single registry-built adapter. That was never really supported — toJournal tags every event with the one manifest and validates it against that manifest’s codec, so such an actor could not persist its other event types anyway — but the read path used to fail silently and now fails loudly. Write a fromJournal that switches on stored.manifest and delegates to a per-type adapter, the same way MigrationChain expects for a multi-type journal.

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/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',
});

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.