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
| Provider | From | Use |
|---|---|---|
memoryStorage() | @sigx/actors/host | tests and dev |
fileStorage({ dir }) | @sigx/actors/node | dev — one cat-able JSON file per actor |
sqliteStorage | @sigx/actors-sqlite | a single-node host — one file on node:sqlite |
redisStorage | @sigx/actors-redis | production |
pgStorage | @sigx/actors-pg | production |
surrealStorage | @sigx/actors-surreal | production |
durableObjectStorage | @sigx/actors-cloudflare | Cloudflare |
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
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.
undefinedfor a missing record, not an empty one — that is what selects the freshstate(key)path and skipsmigrateState.
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
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.
| Provider | saveText |
|---|---|
pgStorage, redisStorage, surrealStorage | implemented — they want a string anyway |
memoryStorage | absent, deliberately — it stores the tree by reference |
fileStorage | absent, deliberately — the record is pretty-printed JSON |
durableObjectStorage | absent, 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.
sqliteStorage implements it too.
appendText — the O(entry) append
interface ActorStorage {
// …
appendText?(type: string, key: string, json: string, expectedEtag: string): Promise<string>;
}
interface ActorStorageRecord {
// …
log?: unknown[];
}
A record is a snapshot — the last full save — plus a log of the entries appended since.
appendText adds one JSON entry to the log without rewriting the snapshot, which is what makes
ctx.append and job.append cost
O(entry) where a save costs O(state). The contract is four rules:
- Same compare-and-set as
save. An append checksexpectedEtag, mints a new etag and returns it; a mismatch throws theActorStorageConflictbrand and appends nothing. - A missing record is a conflict. There is nothing to append to, so
expectedEtagis nevernull. - A full save truncates the log.
saveandsaveTextstore the snapshot and empty the log in the same write — a full save is the compaction.clearremoves both, and a re-created record starts with an empty log. loadreports the log aslog— parsed entries, oldest first — and a storage that implements the seam reports it on every record, an empty array included. A storage without it never setslog.
It is optional, and the host is correct without it: where storage has no appendText, or there
is no record yet, an append is a full save — same result, O(state) cost.
| Provider | appendText |
|---|---|
memoryStorage, redisStorage, pgStorage, sqliteStorage, surrealStorage | implemented |
durableObjectStorage | implemented — one storage key per entry; a custom DurableStorage needs the optional list({ prefix }) to get it, and the real DurableObjectStorage has it |
fileStorage | absent, deliberately — its pretty-printed envelope would be rewritten whole on every append |
What each implementing adapter adds to its store:
| Adapter | Where the log lives |
|---|---|
pgStorage | a log text[] column on {schema}.state. ensurePgSchema() adds it to an existing schema (ALTER TABLE … ADD COLUMN IF NOT EXISTS); a migration tool carrying pgSchemaSql() must run the new DDL |
sqliteStorage | a {table}_log table, created on open |
redisStorage | a LIST at {ns}:sl:{type}<NUL>{key}, beside each state HASH |
surrealStorage | an l field on the state table; ensureSurrealSchema() adds it |
durableObjectStorage | a head key holding the current etag, plus one key per entry under the record |
Adapter authors in the actors repo run the shared storageConformance cases with
appendText: true next to saveText: true, which turns a missing append path into a failure
rather than a skip.
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.
One host, and no database to run?
@sigx/actors-sqlite keeps state in one file on
Node's built-in node:sqlite. It is not a cluster store: two hosts on one file serialize on
SQLite's write lock.
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:
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:
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) } : {}),
...(inner.appendText ? { appendText: inner.appendText.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 and appendText
included, each conditionally, so an inner storage without one does not appear to have it. A
decorator that returns a fixed three-method literal silently drops both fast paths: the host
reverts to encoding a tree the adapter then re-walks, and every append becomes a full save —
correct but slower, with nothing to say it happened. The built-in metrics() storage
decoration forwards both 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
- State & persistence — what the runtime does with it.
- Clustering — membership and the directory.
- The app —
decorateStorageand the other hooks.
