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:150The 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-196The 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)andresonate.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 aPromiseCreateReq). On replay, completed calls return their stored value without re-executing the function body.create-account.ts:155, 160, 167; SDK atresonate-sdk-ts/src/context.ts:276aliasesruntolfc.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)— ifconditionis true, terminates the workflow with aResonateErrorof type"Panic"(code"98"). Used to halt on real conflicts and on ordering violations.create-account.ts:164, 171, 174-176; SDK atresonate-sdk-ts/src/context.ts:557-560andresonate-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.runwith the samecreate-account-${uuid}id resumes the same root durable promise. TheguidandsqResultsteps are already complete, so their stored values are returned without re-executing. Execution continues attbCreateAccount.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
uuidretried after a successful run with the durable state intact. Because the top-level invocation id iscreate-account-${uuid}, a rerun reuses the same root durable promise. Theguid,sqResult, andtbResultsteps are already resolved, sosqCreateAccountandtbCreateAccountnever 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
uuidretried 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,sqCreateAccountre-executes, the UNIQUE constraint fires, the existingguidis read back, and since the freshly generatedguiddiffers it returnsexists_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
guidbut missing in TigerBeetle. SQLite returnsexists_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.
tbCreateAccountmapsexists_with_different_*toexists_diff; the nextcontext.panichalts.create-account.ts:122-132, 171. - Ordering violation (SQLite says
createdbut TigerBeetle saysexists_same). This contradicts "write SoR last" and indicates the SoR was written by some path other than this orchestrator. The thirdpanichalts.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
existsresult, 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/lfcdefinition: https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/context.tscontext.panicandDIEdefinition: https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/context.tsResonate.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/
