Sagas
Multi-step work that cannot be a transaction, made safe to retry.
Work that crosses storage authorities — or that commits more than once — cannot be a transaction. A saga makes it survivable instead: each step has a stable ID, a durable record of its output, and an undo.
export const signUp = action({
name: "auth.signUp",
args: signUpArgs,
returns: accountId,
auth: false,
handler: async (ctx, args) => ctx.saga(`signup:${safeKey(args.email)}`, async (step) => {
const userId = await step(
"create-global-user:v1",
() => ctx.global.insert("users", { email: args.email }),
(id) => ctx.global.delete("users", id).then(() => undefined), // the undo
);
return step("create-tenant-account:v1", () => ctx.run(provisionAccount, { userId }));
}),
});The third argument to step is its compensation. If a later step fails,
completed steps are compensated in reverse order and the original error is
rethrown.
What it guarantees
| Idempotent replay | Re-running a saga with the same ID replays recorded outputs instead of repeating effects, so a retry after a partial failure resumes rather than duplicating |
| Complete rollback | A step replayed from a previous attempt is still compensated, so a resumed saga rolls back fully rather than leaving the earlier attempt's effects behind |
| Visible compensation failures | Compensation is itself non-atomic, so it is best effort — its failures are collected into a SagaCompensationError keeping the original failure as its cause, never swallowed |
Rules
- Step IDs must be unique within a run and stable across releases. A
duplicate makes replay ambiguous and is rejected. Version them (
:v1) so a changed step is a new step. - The saga ID is a durable key, so it must be a bounded safe token.
Raw user input such as an email address must be hashed or sanitized before it
becomes a saga ID — idempotencyKey rejects anything outside
[a-zA-Z0-9._:-].
Records must be durable
Saga records in process memory vanish when a Durable Object hibernates or a
server restarts, and the retry then repeats the effect they were supposed to
replay. Use SqlSagaStore, which keeps them in the tenant's own SQLite. It is
the same store on either host — createTenantObject wires it for you on
Cloudflare, and createSqliteTenantStores does on a Node or Bun server.
When you need one
| Situation | |
|---|---|
An action writes to ctx.global and ctx.db | Saga |
An action calls ctx.run and can fail afterwards | Saga, or an idempotency key |
| An action only reads, or only calls one mutation last | Neither |
| A single mutation | Neither — it is already a transaction |
This is the one part of HighX that feels like extra work compared to a single-database framework. It is deliberate: the alternative is an API that looks atomic and is not.