Storage#

ActorStorage is three methods and an etag. Everything the runtime promises about persistence — conflict detection, migration write-back, crash-resumable tasks — rides that one seam.

The shipped providers#

ProviderFromUse
memoryStorage()@sigx/actors/hosttests and dev
fileStorage({ dir })@sigx/actors/nodedev — one cat-able JSON file per actor
redisStorage@sigx/actors-redisproduction
pgStorage@sigx/actors-pgproduction
surrealStorage@sigx/actors-surrealproduction
durableObjectStorage@sigx/actors-cloudflareCloudflare
TypeScript
defineActorApp({ storage: fileStorage({ dir: '.actors' }) });

memoryStorage() is the default when you configure nothing, which is fine for a first look and wrong for anything else — including dev, where it means every actor-file edit resets your state.

The seam#

TypeScript
interface ActorStorage {
    load(type: string, key: string): Promise<ActorStorageRecord | undefined>;
    save(type: string, key: string, record: ActorStorageRecord): Promise<void>;
    clear(type: string, key: string): Promise<void>;
}

A record carries the encoded state and an etag. save must reject with ActorStorageConflict when the etag it was handed is not the one currently stored — that compare-and-set is what makes ActorStateConflictError possible, and without it a partition silently loses writes.

Implementing one is a small job for any database that can do a conditional update. What it must get right:

  • Compare-and-set on the etag, atomically. A read-then-write is not enough.
  • Round-trip the encoded record unchanged. The codec has already run; do not re-serialize.
  • undefined for a missing record, not an empty one — that is what selects the fresh state(key) path and skips migrateState.

The ownership contract#

Both halves of the seam say who owns the tree, and they say opposite things:

contract
save(state)takes ownership. The caller must not mutate the tree afterwards; an implementation may store it by reference rather than copying.
load()hands ownership over. The returned record is the caller's to mutate freely — never a shared, frozen or cached object.

The host always passes save a codec-fresh tree, so a provider that keeps the reference is safe and skips a clone. The load side is the one to be careful about: returning a cached record means the next activation mutates the copy your provider is still holding.

This is what makes memoryStorage correct without cloning on save while still cloning on load — a stored value never aliases live activation state.

saveText — the one-walk durable save#

TypeScript
interface ActorStorage {
    // …
    saveText?(type: string, key: string, json: string, expectedEtag: string | null): Promise<string>;
}

A durable save is otherwise two full walks of the same state: the host encodes to a JSON-safe tree, then the adapter runs JSON.stringify over that tree. When storage implements the optional saveText, the host emits the JSON text in one walk via @sigx/serialize/stringify and hands the adapter the string — measured at −38 to −44% on a 500-row state against the two-walk pair (@sigx/serialize ≥ 0.15.6, the peer floor).

Implementing it is a promise of equivalence, not merely of validity: saveText(type, key, json, etag) must be observably identical to save(type, key, JSON.parse(json), etag) — same compare-and-set, same ActorStorageConflict brand on a mismatch, and a later load() returns the same record either way. Implement save in terms of saveText so the two cannot drift.

ProvidersaveText
pgStorage, redisStorage, surrealStorageimplemented — they want a string anyway
memoryStorageabsent, deliberately — it stores the tree by reference
fileStorageabsent, deliberately — the record is pretty-printed JSON
durableObjectStorageabsent, deliberately — storage.put takes a structured value and the platform serializes it; a string would have to be parsed back on load

Absent is the right answer for an adapter that genuinely wants the tree. The host is correct either way; it keeps its encoded-tree path for exactly that case.

Choosing one in production#

Two questions decide it.

Do you already run one of these? If Postgres is in your stack, @sigx/actors-pg gives you storage, membership, the directory and reminders on one pool, with the expiry running on the database clock. If Redis is, @sigx/actors-redis gives you the same minus reminders, on one client.

Are you on Cloudflare? Then the question does not arise — the Durable Object is the storage, and the platform is the cluster. See Cloudflare Workers.

Storage and the cluster providers are independent choices. Kubernetes membership plus a Redis directory plus Postgres storage is a perfectly ordinary configuration:

TypeScript
cluster({
    providers: { membership: k8sMembership(), directory: redisDirectory(client) },
    advertise,
    secret: process.env.HOST_SECRET,
});

Decorating storage#

A plugin can wrap the configured storage rather than replace it:

TypeScript
registry.decorateStorage((inner) => ({
    async load(type, key) {
        const t = performance.now();
        try { return await inner.load(type, key); }
        finally { record(performance.now() - t); }
    },
    save: inner.save,
    clear: inner.clear,
    ...(inner.saveText ? { saveText: inner.saveText.bind(inner) } : {}),
}));

Decorators chain, and the last registered is outermost. Encryption at rest, tenant prefixing and audit logging all fit here without the actor code knowing.

Forward every member you do not deliberately replace — saveText included, and forward it conditionally, so an inner storage without it does not appear to have it. A decorator that returns a fixed three-method literal silently drops the one-walk save path: the host reverts to encoding a tree the adapter then re-walks, correct but slower, with nothing to say it happened. The built-in metrics() storage decoration forwards it this way.

A decorator that caches a load() result is wrong. load hands the record to the caller to mutate, so serving the same object twice means the second activation is mutating the first one's state. Cache the encoded bytes and rebuild, or do not cache at all.

Next steps#