Skip to content
English

migratingAdapter

migratingAdapter handles non-additive migrations — renames, restructures, computed-from-other-fields — by running a MigrationChain: a sequence of typed upcasters. You build the chain, then wrap it:

import { migratingAdapter, MigrationChain, type EventAdapter } from 'actor-ts';
type DepositedV1 = { kind: 'deposited'; amount: number };
type DepositedV2 = { kind: 'deposited'; cents: number; currency: string };
type DepositedV3 = { kind: 'deposited'; cents: number; currency: string; tenantId: string };
class Account extends PersistentActor<Command, DepositedV3, State> {
constructor(public readonly tenantId: string) { super(); }
override eventAdapter(): EventAdapter<DepositedV3> {
const chain = MigrationChain.for<DepositedV3>('BankAccount.Deposited', 3)
.add({ fromVersion: 1, toVersion: 2,
upcast: (v1: DepositedV1): DepositedV2 => ({
kind: v1.kind, cents: v1.amount * 100, currency: 'USD',
}) })
.add({ fromVersion: 2, toVersion: 3,
upcast: (v2: DepositedV2): DepositedV3 => ({
...v2, tenantId: this.tenantId,
}) });
return migratingAdapter(chain);
}
}

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.

migratingAdapter is a function over a MigrationChain. The currentVersion and manifest live on the chain; the adapter’s options control only the write version:

function migratingAdapter<E>(
chain: MigrationChain<E>,
options?: { writeVersion?: number }, // default: chain.currentVersion
): EventAdapter<E, unknown>;
// Steps added to the chain:
interface MigrationStep<From, To> {
fromVersion: number;
toVersion: number;
upcast(from: From): To;
}

Build the chain with MigrationChain.for<E>(manifest, currentVersion) and .add(step). Steps must move forward (fromVersion < toVersion) — consecutive is normal, larger jumps allowed but rare.

migratingAdapter is the right tool when:

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

For pure additions, defaultsAdapter is simpler. For arbitrarily-shaped migrations, write a custom EventAdapter.

class Account extends PersistentActor<Command, EventV3, State> {
constructor(public readonly tenantId: string) { super(); }
override eventAdapter(): EventAdapter<EventV3> {
// The upcaster closes over `this.tenantId`:
const chain = MigrationChain.for<EventV3>('BankAccount.Deposited', 3)
.add({ fromVersion: 2, toVersion: 3,
upcast: (v2: EventV2): EventV3 => ({ ...v2, tenantId: this.tenantId }) });
return migratingAdapter(chain);
}
}

Upcasters 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 a v1 event + a chain with steps 1 → 2 and 2 → 3:

storedV1 → upcast₁→₂(storedV1) = intermediateV2 → upcast₂→₃(intermediateV2) = finalV3

The intermediate types don’t need to match any historical event shape — they’re just stepping stones. The chain walks from the stored version’s cursor forward until it reaches currentVersion.

During a rolling deploy, v2 nodes may need to keep emitting v1 events while v1 readers are still in the cluster. Add downcasters to the chain and set writeVersion on the adapter:

const chain = MigrationChain.for<DepositedV2>('BankAccount.Deposited', 2)
.add({ fromVersion: 1, toVersion: 2,
upcast: (v1: DepositedV1): DepositedV2 => ({ ...v1, currency: 'USD' }) })
.addDown({ fromVersion: 2, toVersion: 1,
downcast: (v2: DepositedV2): DepositedV1 => {
const { currency: _c, ...rest } = v2; void _c; return rest;
} });
// Phase 1 — read both, still WRITE v1:
const phase1 = migratingAdapter(chain, { writeVersion: 1 });
// Phase 2 — every reader upgraded; write the current version:
const phase2 = migratingAdapter(chain); // writeVersion = currentVersion = 2

writeVersion must be ≤ currentVersion, and the chain must have downcasters covering every step on the path currentVersion → writeVersion — otherwise toJournal throws.

.add({ fromVersion: 1, toVersion: 2,
upcast: (v1: DepositedV1): DepositedV2 => {
if (v1.amount < 0) throw new Error('invalid v1 event');
return { kind: v1.kind, cents: v1.amount * 100, currency: 'USD' };
} })

If a step throws, recovery fails with the error surfaced through the actor’s onRecoveryFailure — and the actor then stops unless that hook rethrows into supervision (see When recovery fails). A chain gap — no upcaster registered for the cursor before currentVersion is reached — throws a MigrationError naming the missing fromVersion.

// v2 in production; now you want v3. Add a step and bump the chain version:
const chain = MigrationChain.for<EventV3>('BankAccount.Deposited', 3)
.add({ fromVersion: 1, toVersion: 2, upcast: v1To2 })
.add({ fromVersion: 2, toVersion: 3, upcast: v2To3 }); // NEW

Add the v2 → v3 step, keep the existing v1 → v2 step, and bump MigrationChain.for(..., 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 a MigrationChain that governs a snapshot / durable-state record, use migratingSnapshotAdapter — same chain, returns a SnapshotAdapter:

import { migratingSnapshotAdapter } from 'actor-ts';
const adapter = migratingSnapshotAdapter(chain);