Write Last, Read First account creation in TypeScript with TigerBeetle

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 handles | You 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(...) step | The step functions (sqCreateAccount, tbCreateAccount)
Persisting each step's return value before the next step starts | The decision to model the result as { type: "created" | "exists_same" | "exists_diff" }
Re-driving the generator after a crash, skipping completed steps | The panic conditions that decide which states are unrecoverable
Dependency injection of tbClient / sqClient via setDependency / getDependency | The client construction outside the generator
Surfacing panics as a typed ResonateError for the caller | The 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

Example repo: https://github.com/resonatehq-examples/example-tigerbeetle-account-creation-ts

Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts (v0.10.0)

context.run / lfc definition: https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/context.ts

context.panic and DIE definition: https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/context.ts

Resonate.register / Resonate.run / setDependency: https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/resonate.ts

Resonate documentation: https://docs.resonatehq.io/

TigerBeetle account creation API: https://docs.tigerbeetle.com/reference/account/