- name
- resonate-basic-durable-world-usage-typescript
- description
- The Resonate TypeScript SDK's Context API reference for durable generator functions — ctx.run, ctx.rpc, ctx.sleep, ctx.promise, determinism rules, and structured concurrency. Use once you've decided to use Resonate and are writing code inside function* bodies. For the conceptual decision of whether to use durable execution at all, see the durable-execution skill.
- license
- Apache-2.0
# Resonate Basic Durable World Usage
> **SDK version:** This skill reflects `@resonatehq/sdk` v0.11.4 (current on npm).
>
> **Two execution engines (v0.11.0+):** The SDK now ships two engines. This skill documents the **generator engine** (imported from `@resonatehq/sdk`), which uses `function*` / `yield*` and is the basis of all existing Resonate examples. An **async/await engine** (imported from `@resonatehq/sdk/async`) was added in v0.11.0 and is documented in `resonate-async-await-engine-typescript`.
## Overview
The **Durable World** is inside generator functions where you write durable, recoverable execution logic using the Context object. Every operation checkpoints progress, enabling automatic recovery after failures.
**Key distinction:** Durable World code uses `ctx` (Context). Ephemeral World code uses `resonate` (Client).
## Mental Model
```
Generator Function (function*)
↓
yield* ctx.run() ← Checkpoint
↓
yield* ctx.sleep() ← Checkpoint + Suspend
↓
yield* ctx.rpc() ← Checkpoint + Cross Process
↓
return result ← Final Checkpoint
```
**Every `yield*` is a durable checkpoint.**
If the process crashes, the execution replays from the last checkpoint using stored results.
## Core Syntax Rules
### 1. Use `function*` (Generator Functions)
```ts
// ✅ CORRECT - Generator function
function* myWorkflow(ctx: Context, arg: string) {
// Durable code here
}
// ❌ WRONG - Async function
async function myWorkflow(ctx: Context, arg: string) {
// Not durable!
}
// ❌ WRONG - Async generator
async function* myWorkflow(ctx: Context, arg: string) {
// Invalid syntax for Resonate
}
```
### 2. Always `yield*` Context Calls
```ts
function* myWorkflow(ctx: Context) {
// ✅ CORRECT
const result = yield* ctx.run(someFunc, "arg");
// ❌ WRONG - Missing yield*
const result = ctx.run(someFunc, "arg");
// ❌ WRONG - Using await
const result = await ctx.run(someFunc, "arg");
}
```
### 3. First Parameter is Always Context
```ts
// ✅ CORRECT
function* myWorkflow(ctx: Context, userId: string, data: any) {
// ...
}
// ❌ WRONG - Missing Context
function* myWorkflow(userId: string) {
// Can't call Context APIs!
}
```
## Context.run() - Local Invocation
**Signature:** `ctx.run(func, ...args) → yield* → result`
**Behavior:**
- Executes in same process
- Synchronous (blocks until complete)
- Arguments can be in-memory (no serialization required)
- Return value must serialize (stored for replay)
### Basic Usage
```ts
function* workflow(ctx: Context, userId: string) {
// Call synchronously, get result immediately
const user = yield* ctx.run(fetchUser, userId);
const orders = yield* ctx.run(fetchOrders, userId);
return { user, orders };
}
async function fetchUser(_ctx: Context, userId: string) {
// Regular async function - can throw, use await, etc.
return { id: userId, name: "Alice" };
}
```
### With Non-Serializable Arguments
```ts
function* workflow(ctx: Context) {
const db = ctx.getDependency("db"); // DB connection object
// ✅ OK - db object doesn't need to serialize
const user = yield* ctx.run(queryDatabase, db, "123");
return user;
}
async function queryDatabase(_ctx: Context, db: any, userId: string) {
return await db.query("SELECT * FROM users WHERE id = $1", [userId]);
}
```
### Wrapping Side Effects
```ts
function* workflow(ctx: Context, email: string) {
// ✅ CORRECT - Wrap side effects in ctx.run
yield* ctx.run(sendEmail, email);
// ❌ WRONG - Side effect outside ctx.run
await sendEmailDirect(email); // Will re-send on replay!
}
async function sendEmail(_ctx: Context, email: string) {
// Side effect happens here, checkpointed result prevents replay
await emailService.send(email);
}
```
## Context.beginRun() - Non-Blocking Local
**Signature:** `ctx.beginRun(func, ...args) → yield* → Future`
**Use when:**
- Starting multiple operations concurrently
- Following fork-join pattern
### Fork-Join Concurrency
```ts
function* workflow(ctx: Context, userId: string) {
// Fork: Start all operations
const userFuture = yield* ctx.beginRun(fetchUser, userId);
const ordersFuture = yield* ctx.beginRun(fetchOrders, userId);
const invoicesFuture = yield* ctx.beginRun(fetchInvoices, userId);
// Join: Await all results
const user = yield* userFuture;
const orders = yield* ordersFuture;
const invoices = yield* invoicesFuture;
return { user, orders, invoices };
}
```
**Pattern:** Always fork first, then join. Don't interleave.
### Anti-Pattern: Interleaved Begin/Await
```ts
// ❌ WRONG - Hard to read, unclear concurrency
function* workflow(ctx: Context, id: string) {
const f1 = yield* ctx.beginRun(step1, id);
const r1 = yield* f1;
const f2 = yield* ctx.beginRun(step2, r1);
const r2 = yield* f2;
return r2;
}
// ✅ CORRECT - Sequential operations should use run
function* workflow(ctx: Context, id: string) {
const r1 = yield* ctx.run(step1, id);
const r2 = yield* ctx.run(step2, r1);
return r2;
}
```
## Context.rpc() - Remote Invocation
**Signature:** `ctx.rpc(funcName, ...args, options?) → yield* → result`
**Behavior:**
- Executes in different process/group
- Blocks until remote completes
- All arguments must serialize (crossing process boundary)
- Return value must serialize
### Basic Usage
```ts
// Process A
function* workflow(ctx: Context, userId: string) {
// Call function in process B
const score = yield* ctx.rpc(
"computeScore",
userId,
{ model: "v2" },
ctx.options({ target: "poll://any@scorers" })
);
return score;
}
// Process B (different worker group "scorers")
function* computeScore(ctx: Context, userId: string, options: any) {
// Expensive computation
return 850;
}
```
### Target Specification
```ts
ctx.options({
target: "poll://any@workers" // Any worker in "workers" group
})
ctx.options({
target: "poll://any@gpu-workers" // Specific worker group
})
```
### Serialization Rules
```ts
function* workflow(ctx: Context) {
const db = ctx.getDependency("db");
// ❌ WRONG - DB connection can't serialize
yield* ctx.rpc("processData", db);
// ✅ CORRECT - Only pass serializable data
const data = yield* ctx.run(fetchData, db);
yield* ctx.rpc("processData", data);
}
```
**Serializable:** strings, numbers, booleans, plain objects, arrays, null
**Not serializable:** functions, class instances, DB connections, file handles
## Context.beginRpc() - Non-Blocking Remote
```ts
function* workflow(ctx: Context, userId: string) {
// Fork: Start remote operations
const scoreFuture = yield* ctx.beginRpc(
"computeScore",
userId,
ctx.options({ target: "poll://any@scorers" })
);
const riskFuture = yield* ctx.beginRpc(
"assessRisk",
userId,
ctx.options({ target: "poll://any@risk-workers" })
);
// Join: Await results
const score = yield* scoreFuture;
const risk = yield* riskFuture;
return { score, risk };
}
```
## Context.detached() - Fire and Forget
```ts
function* workflow(ctx: Context, orderId: string) {
// Start analytics tracking but don't wait
yield* ctx.detached(
"trackOrder",
orderId,
ctx.options({ target: "poll://any@analytics" })
);
// Continue immediately
return processOrder(orderId);
}
```
**Important:** Detached calls are NOT implicitly awaited, even with structured concurrency.
## Context.sleep() - Durable Sleep
**Signature:** `ctx.sleep(ms | options) → yield* → void`
### Sleep for Duration
```ts
function* workflow(ctx: Context) {
yield* ctx.sleep(5000); // Sleep 5 seconds
yield* ctx.sleep({ for: 60_000 }); // Sleep 1 minute
}
```
### Sleep Until Specific Time
```ts
function* workflow(ctx: Context) {
const tomorrow8am = new Date();
tomorrow8am.setDate(tomorrow8am.getDate() + 1);
tomorrow8am.setHours(8, 0, 0, 0);
yield* ctx.sleep({ until: tomorrow8am });
// Resumes at exactly 8am tomorrow
}
```
**Key behaviors:**
- No limit on sleep duration
- Activation terminates during sleep
- Resumes in new activation when time expires
- Sleep is durable - survives crashes
## Context.promise() - External Promises
**Signature:** `ctx.promise(options?) → yield* → Future`
**Use for:** Human-in-the-loop, webhooks, external triggers
### Promise IDs are always auto-generated
**IMPORTANT:** `ctx.promise()` does not take an `id`. Resonate generates a
deterministic ID from the call tree, and that is the only supported mechanism —
there is no way to pin a custom ID from inside a durable function.
The signature accepts `timeout`, `data` and `tags` and nothing else:
```ts
promise<T>(): RFI<T>;
promise<T>({ timeout, data, tags }: {
timeout?: number;
data?: any;
tags?: { [key: string]: string };
}): RFI<T>;
```
Passing `{ id }` is a TypeScript excess-property error, and if you force it
through, the runtime destructures only `{ timeout, data, tags }` and silently
discards it — the promise still gets the sequence-generated ID.
**So how does an external resolver learn the ID?** Read it back off the promise
and send it. That is the pattern below: create the promise first, then hand
`promise.id` to whatever needs to resolve it.
```ts
const promise = yield* ctx.promise<T>();
const result = yield* promise;
```
`ctx.options()` excludes `id` for the same reason —
`options(opts?: Partial<Omit<Options, "id">>)`.
### Basic HITL Pattern
```ts
function* approvalWorkflow(ctx: Context, orderId: string) {
// ID is auto-generated; read it back to give the external resolver
const approvalPromise = yield* ctx.promise<Decision>();
// Send notification with promise ID
yield* ctx.run(sendApprovalEmail, approvalPromise.id);
// Block until human approves (via external resolve)
const decision = yield* approvalPromise;
if (decision.approved) {
yield* ctx.run(processOrder, orderId);
}
return decision;
}
```
### External Resolution (Ephemeral World)
```ts
// Webhook handler or UI callback
app.post("/approve/:promiseId", async (req, res) => {
// Base64 encode data before sending to Resonate server
const response = { approved: true, approver: req.body.userId };
const encodedData = Buffer.from(JSON.stringify(response)).toString('base64');
await resonate.promises.resolve(req.params.promiseId, {
data: encodedData,
});
res.json({ status: "approved" });
});
```
**Promise ID determinism is the SDK's job, not yours**
Replay only works if a promise gets the same ID every time the function runs.
Because `ctx.promise()` takes no `id`, you cannot get this wrong: the ID comes
from a per-context sequence counter that advances in call order, so the same
code path always produces the same IDs.
```ts
function* approvalLoop(ctx: Context, orderId: string) {
while (true) {
// Each iteration gets the next sequence ID — deterministic across replays
const promise = yield* ctx.promise<Decision>();
yield* ctx.run(sendApprovalEmail, orderId, promise.id);
const result = yield* promise;
if (result.approved) break;
}
}
```
**What you still have to get right:** the sequence is positional, so the *code
path* must be deterministic even though the IDs are. Branching on `Date.now()`,
`Math.random()`, or anything else that varies between runs can change how many
promises get created before a given one, which shifts every subsequent ID. Use
`ctx.date.now()` and `ctx.math.random()` — they replay the recorded value —
and keep non-deterministic input out of control flow.
```ts
// ❌ BAD — a wall-clock branch changes the call sequence between replays
if (Date.now() % 2) { yield* ctx.promise<Decision>(); }
// ✅ GOOD — recorded and replayed, so the branch is stable
const now = yield* ctx.date.now();
if (now % 2) { yield* ctx.promise<Decision>(); }
```
Ver en GitHub