Pular para o conteúdo
Português (BR)

Envelope format

Este conteúdo não está disponível em sua língua ainda.

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.

FieldTypePurpose
_vnumberThe version this payload was written under.
_tstringThe type tag — typically the event/state name. Optional but useful for tooling.
_eobjectThe actual payload.

Underscores so they don’t collide with user fields. The framework considers any object with these three keys an envelope.

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.

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 path
type 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.payload to DomainEvent (the current shape) and return.
  • If it’s older, transform stored.payload to 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.

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.

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).

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 _eDate, Map, Set, bigint and Uint8Array survive 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; _e itself 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).