Skip to content

MigratingAdapter

MigratingAdapter handles non-additive migrations — renames, restructures, computed-from-other-fields — through a chain of version transformations.

import { MigratingAdapter } from 'actor-ts';
type EventV1 = { kind: 'deposited'; amount: number };
type EventV2 = { kind: 'deposited'; cents: number; currency: string };
type EventV3 = { kind: 'deposited'; cents: number; currency: string; tenantId: string };
class Account extends PersistentActor<Cmd, EventV3, State> {
constructor(public readonly tenantId: string) { super(); }
override eventAdapter() {
return new MigratingAdapter<EventV3>({
currentVersion: 3,
chain: [
{ from: 1, to: 2, fn: (v1: EventV1) => ({
kind: v1.kind,
cents: v1.amount * 100,
currency: 'USD',
}) },
{ from: 2, to: 3, fn: (v2: EventV2) => ({
...v2,
tenantId: this.tenantId,
}) },
],
});
}
}

The chain:

  • V1 → V2 restructures: rename amount to cents, multiply by 100, hardcode currency.
  • V2 → V3 adds: pull tenantId from the actor instance.

The adapter applies whichever steps are needed:

  • V1 events: run both steps → V3 shape.
  • V2 events: run only the V2→V3 step → V3 shape.
  • V3 events: no steps → already current.
interface MigratingAdapterSettings<E> {
currentVersion: number;
chain: Array<MigrationStep<unknown, unknown>>;
}
interface MigrationStep<From, To> {
from: number;
to: number;
fn: (from: From) => To;
}

Steps must be contiguous1 → 2, 2 → 3, etc. No skipping versions; no parallel paths.

MigratingAdapter is the right tool when:

  • Renamesamountcents.
  • Restructures — flat fields → nested objects, or vice versa.
  • Computed fields — new field derived from old fields.
  • Multi-step evolution — v1 → v2 → v3 → v4, each step building on the last.

For pure additions, DefaultAdapter is simpler. For arbitrarily-shaped migrations, use a custom EventAdapter.

class Account extends PersistentActor<...> {
constructor(public readonly tenantId: string) { super(); }
override eventAdapter() {
return new MigratingAdapter<EventV3>({
chain: [
// The migration function closes over `this.tenantId`:
{ from: 2, to: 3, fn: (v2) => ({ ...v2, tenantId: this.tenantId }) },
],
});
}
}

Steps are plain functions — they can close over the actor’s constructor arguments. Useful for per-instance migration context (per-tenant defaults, per-region currency).

Given V1 event + chain [1→2, 2→3]:

storedV1 → fn1→2(storedV1) = intermediateV2 → fn2→3(intermediateV2) = finalV3

The intermediate types don’t need to match any historical event shape — they’re just stepping stones. This is sometimes helpful: the v1→v2 step might produce a temporary shape that’s neither v1 nor canonical v2, and the v2→v3 step normalizes.

{ from: 1, to: 2, fn: (v1) => {
if (v1.amount < 0) throw new Error('invalid v1 event');
return { kind: v1.kind, cents: v1.amount * 100 };
}}

If a step throws, recovery fails with the error wrapped in MigrationError. The actor’s onRecoveryFailure is called.

Default: this propagates to the actor’s supervisor. Override onRecoveryFailure if you want to skip problematic events (rare; usually you want to find them, not skip them).

// Scenario: V2 is in production, all events V1 and V2.
// Now you want V3. Add a step:
chain: [
{ from: 1, to: 2, fn: v1To2 },
{ from: 2, to: 3, fn: v2To3 }, // NEW
]

Add the V2→V3 step; keep the existing V1→V2 step. Set currentVersion: 3. Existing V2 events get upcast through the new step; V3 events get written directly.

Never modify existing steps that handled events still in the journal — they’re load-bearing.

For projects using the schema registry, you can validate that the chain’s source/destination versions align with the registry’s declared versions. Optional but useful as a sanity check in tests.