Skip to content

WrapLegacy

The framework’s envelope format only kicks in when an adapter is configured. Before you set an adapter, events are stored raw:

{ "kind": "deposited", "amount": 100 }

After you set one, new events are wrapped:

{ "_v": 1, "_t": "deposited", "_e": { "kind": "deposited", "amount": 100 } }

This creates a problem when introducing adapters to an existing journal: old raw events lack the envelope, but the adapter expects envelopes. Reading a raw event throws MigrationError.

wrapLegacy bridges:

import { wrapLegacy, MigratingAdapter } from 'actor-ts';
class Account extends PersistentActor<...> {
override eventAdapter() {
return wrapLegacy<EventV2>({
legacyVersion: 1,
baseAdapter: new MigratingAdapter<EventV2>({
currentVersion: 2,
chain: [{ from: 1, to: 2, fn: v1ToV2 }],
}),
});
}
}

What this does:

  • On a raw event (no _v / _t / _e keys): treat it as version: 1. Pass the raw event as the v1 payload into the inner adapter.
  • On an envelope event: pass through to the inner adapter unchanged.

So existing raw events flow through the same migration chain as new envelope-wrapped events.

interface WrapLegacySettings<E> {
legacyVersion: number; // version to assign to raw events
baseAdapter: EventAdapter<E>;
}
FieldWhat
legacyVersionThe version raw events are treated as. Typically 1.
baseAdapterThe “real” adapter that handles the rest.

The legacy version should be the version your code wrote raw events under — usually 1, but if you’ve been on v1 a while and skipped envelope adoption until v3, use legacyVersion: 1 and chain 1→2, 2→3.

Three scenarios:

  1. Adding adapters to an existing journal — the most common case. Wrap once; from this point on, new events are envelopes, old events flow through legacy-wrapping.
  2. Migrating from a different framework — old events written by a previous system are now in this journal. Treat them as legacyVersion and migrate up.
  3. Mixed environments during gradual rollout — some events were written by an old version of your code (raw); some by a new version (envelope). Wrap to handle both.

After all raw events have been read (or replaced by snapshots that contain the migrated state), you could in principle drop the wrapLegacy wrapper. In practice, leave it on — the cost is negligible and the safety net is valuable.

The detection is simple: a stored payload without all of _v, _t, _e is legacy. This is unambiguous in practice — real events virtually never have all three underscore-prefixed fields by coincidence.

If your journal contains raw events from multiple legacy versions, wrapLegacy alone isn’t enough — it gives one legacyVersion to all raw events.

For multi-legacy scenarios, you’d write a custom adapter that inspects the raw event’s shape to decide its version:

class MultiLegacyAdapter implements EventAdapter<EventV3> {
upcast(stored: unknown, version: number): EventV3 {
if (version > 0) {
// Envelope event with known version — chain handles it
return innerChain.upcast(stored, version);
}
// Legacy event — sniff shape to determine version
const e = stored as any;
if ('cents' in e) return innerChain.upcast(e, 2); // looks like v2 shape
if ('amount' in e) return innerChain.upcast(e, 1); // looks like v1 shape
throw new Error('unknown legacy event shape');
}
}

This is rare; most projects have a single pre-envelope shape and wrapLegacy is sufficient.

Snapshots have their own adapter (stateAdapter()), independent of eventAdapter(). If you have legacy raw snapshots (pre- envelope), wrap them similarly:

class Account extends PersistentActor<...> {
override snapshotAdapter() {
return wrapLegacy<StateV2>({
legacyVersion: 1,
baseAdapter: new MigratingAdapter<StateV2>({ ... }),
});
}
}

For most setups, snapshots are written less often than events and were already enveloped when adapters were introduced — but double-check before assuming.