State & persistence#

Persistence is explicit by default: only ctx.save() writes. A method that returns success has persisted what it acknowledged.

Explicit saves#

TypeScript
methods: (ctx) => ({
    async addItem(item: Item) {
        ctx.state.items.push(item);
        await ctx.save();            // durable before we ack
        return ctx.state.items.length;
    },
})

ctx.state is a deep reactive proxy — mutate it directly, no setter and no immutable update needed. ctx.save() writes the whole state through the ActorStorage seam with an etag, so a conflicting writer is detected rather than overwritten.

Forget the await and you have told the caller a thing is durable when it is not. That is the one mistake this API can't stop you making, which is why the default is explicit.

Deferring a save#

TypeScript
await ctx.save({ durability: 'eventual' });

ctx.save() takes SaveOptions (exported from @sigx/actors). 'immediate', the default, resolves once the record is stored. 'eventual' marks the state for the activation's write-behind debounce and resolves at once, so a burst of eventual saves costs one write. The actor stays explicit: no write happens that was not asked for. A later immediate save() or the deactivation flush picks up whatever is still pending.

The write stores the state as it stands when it runs, not as it stood at the call. What an eventual save gives up is the acknowledgement: a host death inside the debounce window loses the eventual saves since the last durable one, and a failed write reports through onStateError rather than to a caller. ctx.clearState() drops an eventual save that is still pending, so a deleted record is not written back.

Write-behind#

TypeScript
persistence: { mode: 'write-behind', debounceMs: 200 }

Saves automatically after mutating turns, debounced. Pending writes flush on deactivation and on shutdown.

Acked ≠ persisted under write-behind. A method can return success for state that is still only in memory. Use it for state you can afford to lose — a presence marker, a scroll position, a hit counter — and not for anything a user believes they have saved.

When a deferred save fails#

A write-behind save, or an explicit actor's ctx.save({ durability: 'eventual' }), has no caller to throw to. onStateError on the definition hears about it instead:

TypeScript
import { defineActor, type StateErrorPhase } from '@sigx/actors';

defineActor({
    type: 'Presence',
    persistence: { mode: 'write-behind', debounceMs: 200 },
    async onStateError(ctx, error, phase: StateErrorPhase) {
        if (phase === 'final-flush') await deadLetter(ctx.ref, ctx.snapshot(), error);
        else reportSaveFailure(ctx.ref, error);
    },
    // …
});

phase is a StateErrorPhase — 'flush' | 'final-flush':

PhaseWhich saveWhat it means for the state
'flush'the debounced save between turns; the hook runs inside that flush's own turn, so ctx.state is the frame that failed to saveA transient storage error leaves the state dirty and schedules no retry of its own: the next write, or the final flush at deactivation, carries it — a non-mutating turn does not. An etag conflict (ActorStateConflictError) discards it — see below.
'final-flush'the save at deactivation, after the queue drainsThe last chance for those writes, so a failure here is lost data. Dead-letter ctx.snapshot() from here.

An etag conflict in the 'flush' phase means another activation wrote first. Once the hook returns, the activation is torn down with no further call: onDeactivate(ctx, 'conflict') fires, the final flush is skipped so the stale state never overwrites the winner, and the next call activates against the winning state. The hook is the last time that state is observable, which makes dead-lettering ctx.snapshot() there the right move.

The hook is awaited in both phases, and a throw from it is dev-logged and ignored. It only observes — it cannot retry the save for you. An explicit ctx.save() that fails still rejects the turn that called it and never reaches the hook; a ctx.save({ durability: 'eventual' }), which resolves before its write, reports through it like a write-behind flush. Without the hook, a dev build logs the failure.

Rich types survive#

Date, Map, Set, bigint, URL, RegExp and your own registered handlers round-trip through storage and the wire, because both use the same @sigx/serialize vocabulary.

TypeScript
state: () => ({ lastVisit: null as Date | null, tags: new Set<string>() }),

lastVisit is a real Date when it comes back out of storage, and a real Date in the browser. Register your own types with defineTypeHandler and add them via the app's types: option or a plugin's addTypeHandlers.

Conflicts#

Every save is compare-and-set on the etag. If another activation wrote first, yours raises ActorStateConflictError and the stale activation is faulted — the next call activates fresh against the winning state.

You do not normally handle this. It means the single-activation invariant was briefly violated — a network partition, a directory hiccup, a migration — and the recovery is automatic. What it is good for is alerting: a steady trickle of conflicts says something in your cluster is wrong. See Metrics.

Re-running queued turns#

By default a conflict fails more than the losing turn: every turn queued behind it rejects with ActorStateConflictError too, and the activation is discarded (onDeactivate(ctx, 'conflict')). For an actor whose methods are safe to re-run, opt out of that:

TypeScript
defineActor({
    type: 'Tally',
    retryQueuedOnConflict: true,
    // …
});

The losing turn still rejects — its writes were computed on stale state and are discarded, and its caller retries. But the activation stays. Before the next turn runs, the winning record is reloaded in place (through migrateState, as on activation) and the queued turns run in their original order against it, as if they had arrived after the other writer. There is no deactivation, no second onActivate and no re-dispatch. Change-feed subscribers never see the losing turn's writes; the winning state is their next boundary.

Only for methods that are idempotent or commutative. A queued turn is re-run against state it did not see when it was queued, so a method that reads, decides and then writes can act twice or act on the wrong premise.

The scope is deliberately narrow:

  • Turn-path saves only. A write-behind or eventual-save flush conflict still deactivates, as above.
  • Serial actors only. With reentrant: 'always', or a methodReentrancy map naming any method, the first activation fails — interleaved turns are never queued, so there is nothing to re-run. reentrant: 'call-chain' is fine.
  • A reload that fails faults the activation exactly as without the option.
  • defineActor only — defineWorker has no state to reload.

Migrating state#

migrateState evolves a record whose shape predates this deploy — the answer to "the state: shape changed and the stored records didn't."

TypeScript
defineActor({
    type: 'Cart',
    state: () => ({ v: 2, items: [], coupons: [] }),
    migrateState: (stored) => {
        const s = stored as CartV1 | CartV2;
        if ('v' in s) return s;                              // fast path
        return { v: 2, items: s.items ?? [], coupons: [] };  // v1 → v2
    },
});

When it runs. Between the storage read and activation, and only on a load that found a record — never on the fresh state(key) path, never after ctx.clearState(). Always before onActivate, which therefore always sees migrated state.

What it receives. The codec-revived state, so Date and Map are already real objects. unknown here means unknown shape, not raw JSON. A second argument carries { raw, key } when the revived view cannot tell two stored versions apart — raw is the encoded record as storage holds it.

Returning the input unchanged is the fast path, and identity is how that is detected. To migrate, return a new object.

Write-back is lazy, and that is the contract. The migrated shape rides the next save the actor would have made anyway, so a read-only activation still issues zero writes and a rolling deploy costs no extra ones. This holds in both persistence modes — migrateState never causes a write by itself, so a write-behind actor that is only ever read after a migration does not persist it.

For a record that would otherwise never be saved at all — and so would be re-migrated on every activation forever — opt into one CAS write-back at activation:

TypeScript
migrateState: { persist: 'eager', migrate: (stored) => /* … */ },

Concurrent migration is safe by design. A fleet mid-deploy can migrate the same record on several hosts. The hook is a pure function of the stored value and every write is etag-CAS'd, so first save wins and the loser either adopts the winner (eager) or takes ActorStateConflictError and re-activates against it (lazy).

Failure is loud. The hook is synchronous; a throw fails activation with ActorActivationError and leaves the stored record untouched. Corrupt state is never silently reset. Malformed declarations throw at definition time, and an async hook is a type error.

Non-goals. Version-field bookkeeping is your convention — the runtime neither reads nor writes one. And this is not a scheme for versioning an actor's interface across a mixed-version fleet.

defineJob does not accept migrateState. A job's stored record is the job envelope — status, progress, checkpoint and so on, with your own state under extra — so a hook over it would hand you a runtime shape you do not own. Migrating the extra half wants its own option, and is not part of this. See Jobs.

Appending entries#

ctx.save() writes the whole state, so its cost grows with the state. ctx.append(entry) writes one entry instead. Declare the reducer that folds an entry into the state, in place:

TypeScript
defineActor({
    type: 'Ledger',
    state: () => ({ balance: 0, entries: [] as Entry[] }),
    applyEntry(state, entry) {
        const e = entry as Entry;
        state.balance += e.amount;
        state.entries.push(e);
    },
    methods: (ctx) => ({
        async post(entry: Entry) {
            await ctx.append(entry);     // folds it into ctx.state, then stores the entry alone
            return ctx.state.balance;
        },
    }),
});

ctx.append runs applyEntry on the live state and appends the entry to the record's log under the record's etag — O(entry) where ctx.save() is O(state). It is durable when it resolves, and it is a write like any other: a conflict behaves exactly as a save's does, including the retryQueuedOnConflict reload.

The record is a snapshot plus a log. On activation the log is replayed through applyEntry onto the stored snapshot, in append order — after migrateState and before onActivate. A full save — ctx.save(), a write-behind or eventual flush, the deactivation flush — stores the folded state and truncates the log. A full save is the compaction; there is no other.

applyEntry must be pure. It runs twice per entry over the record's life: once at append and once at every replay, possibly on another host. Same state and entry, same result.

What an append makes durable is the entry. State written directly, not through the reducer, since the last full save is not in the log; the next full save carries it.

It works on every storage. Where the storage has no appendText (fileStorage), or there is no record yet, an append is a full save: same result, O(state) cost.

The rules at the edges:

  • ctx.append without applyEntry throws.
  • A record carrying a non-empty log fails activation under a definition without applyEntry, naming the type — it was written by one that had it.
  • It is not available on a tasks: body's context (use ctx.turn) or on a stateless worker.

Jobs have the same seam as job.append(entry).

Providers#

ProviderUse
memoryStorage()tests and dev
fileStorage({ dir })dev — one cat-able JSON file per actor
redisStorageproduction, with the cluster providers
pgStorageproduction, text rows with etag CAS
sqliteStoragea single-node host — one file on node:sqlite

Implement ActorStorage — load / save / clear with etags — for anything else. See Storage.

Next steps#

  • Storage — the seam and how to implement it.
  • Lifecycle — when state is loaded and flushed.
  • Reentrancy — how interleaving changes save semantics.