5 min readResonate HQJust published

Saga workflow behind an HTTP API in TypeScript on Resonate

How a two-step money-transfer saga is exposed over HTTP with two layers of idempotency and crash-recoverable replay.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

A money transfer needs the debit of the source account and the credit of the target account to either both apply or neither apply, even if the worker crashes between them. Resonate runs the workflow as a generator and records each step as a durable promise, so a replay after a crash skips the steps that already completed. This example wires that workflow behind an Express HTTP API, with the URL path acting as the external idempotency key.

The shape of the solution

export function* transferMoney(
  ctx: Context,
  transferId: string,
  request: TransferRequest
): Generator<any, TransferResult, any> {
  const { source, target, amount } = request;
  const db = ctx.getDependency('db');
 
  // ...
 
  try {
    // Deduct from source account
    const sourceConfirmation = yield* ctx.run(
      updateAccount,
      db,
      `${transferId}-source`,
      source,
      -amount
    );
 
    // Credit target account
    const targetConfirmation = yield* ctx.run(
      updateAccount,
      db,
      `${transferId}-target`,
      target,
      amount
    );
 
    // ...
 
    return {
      success: true,
      transferId: transferId,
      source,
      target,
      amount,
      sourceConfirmation,
      targetConfirmation,
    };
  } catch (error) {
    // ...
 
    return {
      success: false,
      transferId: transferId,
      source,
      target,
      amount,
      error: error instanceof Error ? error.message : String(error),
    };
  }
}
// from example-money-transfer-application-ts/src/workflow.ts:33-85

The HTTP handler triggers this workflow with the path param as the execution id:

app.post('/transfer/:id', async (req, res) => {
  const { id } = req.params;
  // ...validation elided...
  const result = await transferMoneyR.run(`transfer/${id}`, id, request);
  // ...
});
// from example-money-transfer-application-ts/src/index.ts:36-61

transferMoneyR is the registered handle returned by resonate.register(transferMoney) at src/index.ts:19. Two calls with the same :id hit the same execution and receive the same result — the second call returns the cached output instead of re-running the workflow.

The durable primitives in play

  • resonate.register(transferMoney) — turns the generator into a Resonate-managed function with a .run(id, ...args) entry point. src/index.ts:19.
  • resonate.setDependency('db', db) — injects the SQLite handle into the runtime so the workflow doesn't capture it from closure (which would break replay-time determinism for a remote worker). src/index.ts:16.
  • transferMoneyR.run(`transfer/${id}`, id, request) — external trigger. The first arg is the durable execution id; reusing it returns the cached result. src/index.ts:49.
  • ctx.run(updateAccount, db, `${transferId}-source`, source, -amount) — a durable in-process function call. Each call writes a durable promise; on replay, completed calls return the recorded value without re-invoking the function. src/workflow.ts:45-51 and :54-60.
  • ctx.getDependency('db') — pulls the injected dep at workflow start. src/workflow.ts:39.

The workflow does not use ctx.sleep, ctx.detached, resonate.schedule, or ctx.promise. The README's "Test 1" walks a crash-recovery test by asking the reader to add a ctx.sleep(5000) between the two steps — that line is not in the shipped workflow.

What the SDK handles vs. what you write

What the SDK handles:

  • Recording each ctx.run invocation, its args, and its return value as a durable promise keyed by the execution id plus a sequence number.
  • On a worker restart or replay, walking the recorded log: completed ctx.run calls return their cached values without re-invoking the underlying function. The generator runs deterministically from the top each time, so the source debit is skipped on replay if it already completed.
  • Caching the top-level return value against the execution id. Calling transferMoneyR.run('transfer/tx-001', ...) a second time returns the recorded TransferResult without entering the generator.
  • Dependency resolution via setDependency / getDependency, so the workflow body doesn't need to import or close over external handles.

What you write:

  • The generator function with the business sequence (debit, then credit).
  • The deterministic per-step operation id (${transferId}-source, ${transferId}-target) — used by updateAccount to key the ledger row. This is the second idempotency layer: even if Resonate's replay didn't skip the step (for example, if the durable promise was lost), the SQL INSERT OR IGNORE on the uuid primary key would still prevent double-application.
  • The INSERT OR IGNORE statement and the uuid column on the transfers table. src/database.ts:19-26, 49-50.
  • The HTTP framing — Express routes, input validation (Joi schema at src/index.ts:23-27), and mapping the path param to a Resonate execution id.

The two layers are independent and protect against different failure modes. The SDK's execution-id cache protects against retried HTTP calls and against re-entering the generator on replay. The uuid-keyed INSERT OR IGNORE protects the ledger when the durable promise state is lost or when a fresh execution id is pointed at the same business identifier by mistake.

Failure modes covered

  • Worker crashes between the debit and the credit. On restart, the workflow replays. The first ctx.run at src/workflow.ts:45 returns the recorded confirmation without re-invoking updateAccount. The second ctx.run at src/workflow.ts:54 runs and completes. Final ledger state: one debit row, one credit row.
  • The same HTTP request arrives twice (client retry). Both calls hit POST /transfer/:id with the same :id. The second transferMoneyR.run('transfer/${id}', ...) at src/index.ts:49 matches the existing execution and returns the cached TransferResult without entering the generator. The ledger sees no second pair of rows.
  • The same transfer is replayed but the durable promise was lost. Resonate would re-invoke updateAccount. The INSERT OR IGNORE at src/database.ts:50 matches on the uuid primary key (${transferId}-source or ${transferId}-target), the insert is a no-op, the function returns the same uuid. The ledger remains correct.
  • The credit step throws. The generator's try/catch at src/workflow.ts:43, 73 catches the error and returns a failure-shaped TransferResult. The HTTP handler at src/index.ts:51-55 maps success: false to a 500. Important: there is no compensating reversal in the shipped workflow — the debit row stays in the ledger. The README acknowledges this at README.md:278 as a deferred production consideration. The Python and Rust siblings (example-money-transfer-py, example-money-transfer-rs) do implement an explicit reversal step; if an agent is looking for the canonical saga-with-compensation shape, those are the references, not this one.
  • Bad input. The Joi schema at src/index.ts:23-27, 40-43 rejects the request before any Resonate work starts. The execution id is never created.

When to reach for this pattern

  • If you need to expose a multi-step business operation behind an HTTP API and the same logical operation must be safe to retry from the client side.
  • If you want execution-level deduplication keyed on a stable identifier from the URL (path param, header, payload field) and you can shape that identifier into a Resonate execution id.
  • If your worker process can crash mid-operation and you don't want to write the recovery walker yourself.
  • If your storage layer can enforce row-level idempotency (uuid primary key, unique constraint, dedup table) — the combination of Resonate's checkpoint cache and a uuid-keyed insert is what makes the saga safe across both replay and storage-state mismatches.
  • If you need the saga's compensating action implemented, do not start from this example — start from example-money-transfer-py or example-money-transfer-rs and translate to TypeScript, or extend this example with a try/catch that runs a third ctx.run for the reversal.

Sources