4 min readResonate HQJust published

Write Last, Read First account creation in TypeScript with TigerBeetle

How the Write Last, Read First state machine collapses to a short generator when each step is a durable checkpoint.

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

Creating an account that has to exist in two stores at once is hard when neither store participates in a shared transaction. Resonate's durable execution lets you write the two writes as ordinary function calls inside a generator, with each context.run step recorded as a durable promise so that a crash between writes resumes from the next step rather than the start. This example registers one orchestrating function, createAccount, that runs three durable steps — generate id, write to SQLite (System of Reference), write to TigerBeetle (System of Record) — and uses context.panic to surface the few states an operator must look at.

The shape of the solution

function* createAccount(
  context: Context,
  uuid: string,
): Generator<any, { uuid: string; guid: string }, any> {
  // Generate a random account id
  const guid = yield* context.run(function (context: Context) {
    return generateId().toString();
  });
 
  // Create account in SQLite
  const sqResult = yield* context.run(sqCreateAccount, uuid, guid);
 
  // Panic and alert the operator if the account exists
  // but with different values
  yield* context.panic(sqResult.type == "exists_diff");
 
  // Create account in TigerBeetle
  const tbResult = yield* context.run(tbCreateAccount, guid);
 
  // Panic and alert the operator if the account exists
  // but with different values
  yield* context.panic(tbResult.type == "exists_diff");
 
  // Panic and alert the operator if ordering was violated
  yield* context.panic(
    sqResult.type == "created" && tbResult.type == "exists_same",
  );
 
  return { uuid, guid };
}
// from example-tigerbeetle-account-creation-ts/create-account.ts:150

The orchestrator is invoked from main with a deterministic id so that retries share durable state:

const result = await resonate.run(
  `create-account-${uuid}`,
  createAccount,
  uuid,
);
// from example-tigerbeetle-account-creation-ts/create-account.ts:192-196

The durable primitives in play

  • resonate.register("createAccount", createAccount) — registers the generator so it can be re-invoked by id on replay. create-account.ts:33.
  • resonate.setDependency("tbClient", tbClient) and resonate.setDependency("sqClient", sqClient) — injects the two database clients so the generator stays pure and replay-safe. create-account.ts:29-30.
  • resonate.run(id, createAccount, uuid) — top-level invocation that returns the final result. Reusing the same id on a retry resumes the same durable promise instead of starting over. create-account.ts:192-196.
  • yield* context.run(fn, ...args) — each call creates a durable promise (an LFC backed by a PromiseCreateReq). On replay, completed calls return their stored value without re-executing the function body. create-account.ts:155, 160, 167; SDK at resonate-sdk-ts/src/context.ts:276 aliases run to lfc.
  • context.getDependency<T>(name) — pulls the injected client inside each step function so the step is replay-pure. create-account.ts:47, 89.
  • yield* context.panic(condition) — if condition is true, terminates the workflow with a ResonateError of type "Panic" (code "98"). Used to halt on real conflicts and on ordering violations. create-account.ts:164, 171, 174-176; SDK at resonate-sdk-ts/src/context.ts:557-560 and resonate-sdk-ts/src/exceptions.ts:111-117.

What the SDK handles vs. what you write

The SDK handlesYou write
Creating a durable promise for the top-level call keyed by create-account-${uuid}The id formula
Creating a durable promise for each yield* context.run(...) stepThe step functions (sqCreateAccount, tbCreateAccount)
Persisting each step's return value before the next step startsThe decision to model the result as { type: "created" | "exists_same" | "exists_diff" }
Re-driving the generator after a crash, skipping completed stepsThe panic conditions that decide which states are unrecoverable
Dependency injection of tbClient / sqClient via setDependency / getDependencyThe client construction outside the generator
Surfacing panics as a typed ResonateError for the callerThe application-level error classification inside each step

The generator body reads top-to-bottom and contains no retry loops, no idempotency-key handling, and no resume logic. Those concerns are handled by the durable promise the SDK creates for each yield* context.run(...).

Failure modes covered

  • Process crashes after SQLite write, before TigerBeetle write. On restart, calling resonate.run with the same create-account-${uuid} id resumes the same root durable promise. The guid and sqResult steps are already complete, so their stored values are returned without re-executing. Execution continues at tbCreateAccount. create-account.ts:155-167.
  • Process crashes after TigerBeetle write. Same mechanism: on replay, the durable promises for all three steps are resolved, and the generator returns { uuid, guid } without touching either store. create-account.ts:167-178.
  • Same uuid retried after a successful run with the durable state intact. Because the top-level invocation id is create-account-${uuid}, a rerun reuses the same root durable promise. The guid, sqResult, and tbResult steps are already resolved, so sqCreateAccount and tbCreateAccount never re-execute and the generator returns the cached { uuid, guid }. The SQLite UNIQUE branch is not hit on this path. create-account.ts:155-167, 192-196.
  • Same uuid retried after the durable promise store was wiped. If the Resonate server's state was cleared (or the orchestrator is invoked under a different Resonate id) but the SQLite row from the prior run survived, sqCreateAccount re-executes, the UNIQUE constraint fires, the existing guid is read back, and since the freshly generated guid differs it returns exists_diff. context.panic(sqResult.type == "exists_diff") then halts the workflow so an operator can reconcile. create-account.ts:56-72, 164.
  • Account exists in SQLite with matching guid but missing in TigerBeetle. SQLite returns exists_same; the TigerBeetle write proceeds and completes the original Write-Last-Read-First sequence. This is the explicit recovery path for "wrote to SoR last and crashed before commit". create-account.ts:67-69, 167.
  • Account exists in TigerBeetle with different flags / ledger / code. tbCreateAccount maps exists_with_different_* to exists_diff; the next context.panic halts. create-account.ts:122-132, 171.
  • Ordering violation (SQLite says created but TigerBeetle says exists_same). This contradicts "write SoR last" and indicates the SoR was written by some path other than this orchestrator. The third panic halts. create-account.ts:174-176.

When to reach for this pattern

  • If you need to keep two stores consistent and neither participates in a shared transaction, and one of them can be designated as the source of truth.
  • If you can adopt "Write Last, Read First" — write to the System of Record last, read from the System of Record first — and want the cross-store sequence expressed as straight-line code.
  • If your steps are naturally idempotent on retry (TigerBeetle's exists result, SQLite's UNIQUE on a deterministic id) so replay reuses the same effect.
  • If the conflict states ("exists with different values", "ordering violated") should halt the workflow and page an operator rather than auto-resolve.
  • If the orchestrator must survive process restarts between writes without losing in-flight intent.

Sources