Envelope format
When an actor has an eventAdapter() (or stateAdapter()), every
persisted payload is wrapped in a versioned envelope before
write:
{ "_v": 2, // version "_t": "deposited", // type tag "_e": { "kind": "deposited", "amount": 100, "currency": "USD" }}On read, the framework unwraps this and routes through the
adapter’s upcast(payload, version). The adapter sees the
version + the payload; it returns the current-shape
event.
The fields
Section titled “The fields”| Field | Type | Purpose |
|---|---|---|
_v | number | The version this payload was written under. |
_t | string | The type tag — typically the event/state name. Optional but useful for tooling. |
_e | object | The actual payload. |
Underscores so they don’t collide with user fields. The framework considers any object with these three keys an envelope.
When envelopes are applied
Section titled “When envelopes are applied”class Account extends PersistentActor<...> { override eventAdapter() { return new SomeAdapter(); }}Setting an adapter makes the framework wrap events on write + unwrap on read. Without an adapter, events are written raw — no envelope.
This is strict on read: once you set an adapter, reading a
non-envelope event throws MigrationError. You can’t mix
enveloped + raw events for the same pid without WrapLegacy
(see below).
For first-time deployments (fresh journal), you can either start
with adapters from the beginning (every event has _v: 1) or
defer adapter introduction until you actually need migration.
Most teams add adapters only when the first breaking change
arrives.
What the adapter sees
Section titled “What the adapter sees”interface EventAdapter<E> { upcast(stored: unknown, version: number): E;}stored is the _e payload — without the envelope.
version is the _v value. The adapter’s job:
- Inspect
version. - If it’s the current version, cast
storedtoE(the current shape) and return. - If it’s older, transform
storedto the current shape, then return.
The framework’s built-in adapters (DefaultAdapter, MigratingAdapter) encapsulate this pattern. Custom adapters implement the same.
Versioning your events
Section titled “Versioning your events”When you want to bump the version:
class Account extends PersistentActor<...> { override eventAdapter() { return new MigratingAdapter<EventV2>({ currentVersion: 2, chain: [ { from: 1, to: 2, fn: v1ToV2 }, ], }); }}The adapter declares the current version. New events written
by onCommand now get _v: 2 envelopes. Old _v: 1 events get
upcast through the chain.
This works regardless of what version a given event was written under — the migration chain handles each.
Mixing with legacy non-envelope events
Section titled “Mixing with legacy non-envelope events”If you’re adding an adapter to an existing journal that has already-persisted raw events:
class Account extends PersistentActor<...> { override eventAdapter() { return wrapLegacy<EventV2>({ legacyVersion: 1, baseAdapter: new MigratingAdapter<EventV2>({ currentVersion: 2, chain: [{ from: 1, to: 2, fn: v1ToV2 }], }), }); }}wrapLegacy treats any non-envelope event as version 1 and
feeds it through the rest of the chain. See
WrapLegacy for
details.
Persistence formats
Section titled “Persistence formats”The envelope is a regular JSON object — serialized by whatever serializer your journal uses (JSON for the SQLite + in-memory journals, customizable for Cassandra).
This means:
- JSON-safe types only in
_e— no functions, noSymbols, noBigInts without a custom serializer. - Deeply-nested objects work — the envelope is one level
deep;
_eitself can be arbitrarily complex. - Binary-friendly via CBOR — if you configure CBOR serialization, the envelope serializes as CBOR (more compact, binary-friendly).
Pitfalls
Section titled “Pitfalls”Where to next
Section titled “Where to next”- Migration overview — the bigger picture.
- DefaultAdapter — zero-config additive migrations.
- MigratingAdapter — chained transformations.
- WrapLegacy — for mixing with non-envelope legacy events.