Envelope format
Ce contenu n’est pas encore disponible dans votre langue.
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 into a stored frame
{ manifest, version, payload } and routes it through the
adapter’s fromJournal(stored). The adapter sees the
manifest, version, and 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<DomainEvent, JournalShape = DomainEvent> { manifest(event: DomainEvent): string; toJournal(event: DomainEvent): OutboundFrame<JournalShape>; fromJournal(stored: StoredFrame): DomainEvent;}
// what the adapter sees on the read pathtype StoredFrame = { manifest: string; version: number; payload: unknown;};
// what the adapter emits on the write path (wrapped into an envelope)type OutboundFrame<JournalShape = unknown> = { manifest: string; version: number; payload: JournalShape;};On read, the framework unwraps the envelope into a StoredFrame
and calls the adapter’s fromJournal(stored). stored.payload
is the _e payload — without the envelope; stored.version
is the _v value; stored.manifest is the _t tag.
The adapter’s read-path job:
- Inspect
stored.version. - If it’s the current version, cast
stored.payloadtoDomainEvent(the current shape) and return. - If it’s older, transform
stored.payloadto the current shape, then return.
manifest and toJournal cover the write path — they tag an
outgoing event and package it as the triple the framework wraps
into an envelope.
The framework’s built-in adapters (defaultsAdapter, 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() { const chain = MigrationChain.for<EventV2>('deposited', 2) .add({ fromVersion: 1, toVersion: 2, upcast: v1ToV2 }); return migratingAdapter(chain); }}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:
import { migrateInMemoryJournal } from 'actor-ts';
// One-shot: wrap every raw event as a v1 envelope before recovery.await migrateInMemoryJournal(journal, (e) => `${e.kind}`);This rewrites the raw events into v1 envelopes; from then on your
normal adapter upcasts them like any other v1 event. See
Wrap legacy for details
(and the per-row wrapEventAsEnvelope primitive for non-in-memory
backends).
Persistence formats
Section titled “Persistence formats”The envelope is a regular JSON object — stored through the tagged JSON tree format every store writes (or through a per-store custom serializer, when configured).
This means:
- Rich types round-trip inside
_e—Date,Map,Set,bigintandUint8Arraysurvive via type tags; functions, symbols and circular references throw at persist time. See what events and state may contain. - Deeply-nested objects work — the envelope is one level
deep;
_eitself can be arbitrarily complex. - Binary-friendly via a custom serializer — a store configured
with
withSerializer(...)frames the whole envelope through that serializer (more compact, binary-friendly).
Pitfalls
Section titled “Pitfalls”Where to next
Section titled “Where to next”- Migration overview — the bigger picture.
- defaultsAdapter — zero-config additive migrations.
- migratingAdapter — chained transformations.
- Wrap legacy — for mixing with non-envelope legacy events.
