HighX
Server

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 replayRe-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 rollbackA 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 failuresCompensation 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.dbSaga
An action calls ctx.run and can fail afterwardsSaga, or an idempotency key
An action only reads, or only calls one mutation lastNeither
A single mutationNeither — 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.

On this page