Durable entity as a long-running workflow in TypeScript on Resonate

A user session has state that has to survive a process restart: it's been issued, it has activities logged against it, it must idle out after a timeout, and on expiry it must revoke tokens and clear cache exactly once. Without a durable execution layer this typically requires a K/V store for the session row, a timer service or scheduled job for the idle expiry, and explicit per-step idempotency keys so retries don't double-record activities or double-run cleanup. Resonate's shape is to express the entire session lifecycle as one generator function: each step is a durable checkpoint, the idle wait is a durable sleep, and the workflow ID (the session ID) is the entity identifier. The example-durable-entity-ts repo demonstrates this end-to-end — login, activity loop, idle timeout, expire, cleanup — in ~45 lines of entity logic, with no external state store.

The shape of the solution

export function* sessionLifecycle(
ctx: Context,
sessionId: string,
userId: string,
activities: Array<{ type: string; data: Record<string, unknown> }>,
idleTimeoutMs: number,
crashOnActivity: string | null,
): Generator<any, SessionResult, any> {
// ── LOGIN ─────────────────────────────────────────────────────────────────
// Session created: JWT issued, last-seen timestamp initialized.
let state: SessionState = yield* ctx.run(loginSession, sessionId, userId);

// ── ACTIVITY TRACKING ─────────────────────────────────────────────────────
// Each activity is an independent durable checkpoint.
// On crash: completed activities are served from cache (no double-recording).
// The loop resumes at the first unrecorded activity.
for (const activity of activities) {
const shouldCrash = activity.type === crashOnActivity;
state = yield* ctx.run(
recordActivity,
sessionId,
state,
activity,
shouldCrash,
);
}

// ── IDLE ──────────────────────────────────────────────────────────────────
// No more activity. Mark idle and wait for the timeout.
state = yield* ctx.run(markIdle, sessionId, state);

// ── DURABLE SLEEP: IDLE TIMEOUT ───────────────────────────────────────────
// This is the key capability: a crash during the sleep period does NOT
// restart the timer. The sleep resumes from wherever it was when the
// process crashed. In production this would be minutes or hours.
// For the demo it's milliseconds.
yield* ctx.sleep(idleTimeoutMs);

// ── EXPIRE ────────────────────────────────────────────────────────────────
// Idle timeout elapsed. Session is now expired.
state = yield* ctx.run(expireSession, sessionId, state);

// ── CLEANUP ───────────────────────────────────────────────────────────────
// Revoke tokens, clear session cache, write audit log.
state = yield* ctx.run(cleanupSession, sessionId, state);

return {
// ...
};
}
// from example-durable-entity-ts/src/workflow.ts:49-104

The entity is registered and started with a stable, session-scoped workflow ID:

const resonate = new Resonate();
resonate.register(sessionLifecycle);

// ... (activity setup, demo flags, sessionId, userId)

const result = await resonate.run(
`session/${sessionId}`,
sessionLifecycle,
sessionId,
userId,
activities,
IDLE_TIMEOUT_MS,
crashOnActivity,
);
// from example-durable-entity-ts/src/index.ts:8-9, 44-52

The durable primitives in play

ctx.run(loginSession, ...) — durable checkpoint for session creation; on replay returns the cached SessionState rather than re-issuing a JWT. src/workflow.ts:59.

ctx.run(recordActivity, ...) — one checkpoint per activity inside a normal for loop; completed activities are served from the promise cache on replay, the loop resumes at the first unrecorded entry. src/workflow.ts:67-73.

ctx.run(markIdle, ...) — checkpoint marking the entity transition from active to idle. src/workflow.ts:78.

ctx.sleep(idleTimeoutMs) — durable sleep; a crash during the idle window does not restart the timer. The wait resumes from wherever it was when the process went down. src/workflow.ts:85.

ctx.run(expireSession, ...) and ctx.run(cleanupSession, ...) — checkpointed expiry and cleanup steps; tokens are revoked and cache cleared exactly once across retries. src/workflow.ts:89,93.

resonate.register(sessionLifecycle) — registers the generator with the local runtime so it can replay. src/index.ts:9.

resonate.run("session/${sessionId}", sessionLifecycle, ...) — the workflow ID is the entity identifier. Any process reaching the same ID joins the same durable execution. src/index.ts:44-52.

What the SDK handles vs. what you write

You write the lifecycle as ordinary sequential TypeScript: a let state = ... accumulator, a for loop over activities, a sleep call, two more steps. There is no ctx.get("activities") / ctx.set("activities"), no separate session table, no scheduled job for the idle timeout, no retry block around the database write inside recordActivity, no idempotency-key plumbing on cleanup.

The SDK handles: persisting the result of every ctx.run to the promise store so replays return cached values; restarting the generator from the top on crash and fast-forwarding through completed yield* points; persisting the deadline of ctx.sleep so a crash mid-wait resumes the remaining time rather than restarting it; applying a retry policy when an activity throws (the runtime emits Function 'recordActivity' failed with '...' (retrying in N secs) — see resonate-sdk-ts/src/util.ts:116-119); and treating the workflow ID as the deduplication key so two callers that race to start the same session ID join the same execution and receive the same result.

The key distinction: your code reads like a single linear function. The durability — checkpointing, replay, retry, durable timers, idempotent re-entry — is in the runtime around ctx.run and ctx.sleep, not in the user code.

Failure modes covered

A recordActivity call throws (e.g., the database write times out — src/session.ts:76-81). The SDK applies its retry policy and re-invokes only that activity; the activities that succeeded before it remain checkpointed and are not re-run. The runtime logs the retry. This is the path the bundled --crash demo exercises: the README's crash-mode run shows the four pre-crash activities each appearing exactly once and only checkout_started retried (README.md:103-115). No user-written try/catch or retry loop is needed.

The process crashes between two activities in the loop. Not directly exercised by the bundled demo, but backed by the same promise-cache mechanism that handles the throw-retry path above. On restart, the generator replays from the top; yield* ctx.run calls for already-recorded activities return cached results without re-executing the side effect. The loop resumes at the first unrecorded activity. Code path: src/workflow.ts:65-74 plus the SDK's promise-store cache semantics.

The process crashes during the idle wait. ctx.sleep(idleTimeoutMs) is a durable promise with a stored deadline; on resume the remaining time is honored. The clock does not restart. See the inline comment at src/workflow.ts:80-84 and the sleep implementation in resonate-sdk-ts/src/context.ts:538-554.

Two callers race to start the same session ID. Both invocations of resonate.run("session/${sessionId}", ...) join the same durable workflow and receive the same final result. The workflow ID acts as a deduplication key — this is not per-key mutual exclusion. For true serialized concurrent mutation across independent callers, the README points to example-distributed-mutex-ts (README.md:139-141).

Cleanup runs more than once on retry. cleanupSession is wrapped in ctx.run, so once it succeeds its result is cached and replay returns the cached value rather than revoking tokens or clearing cache a second time (src/workflow.ts:93, src/session.ts:116-124).

When to reach for this pattern

If you have a stateful object with a finite lifecycle (login → activity → idle → expire → cleanup, or analogous stages) and want the lifecycle stored as code position rather than a status column.

If you need a long, durable idle window — minutes to hours — that must survive process crashes without resetting.

If you'd otherwise stand up a K/V store plus a scheduled job plus per-step idempotency keys to express the same flow.

If your "entity state" is small and fits naturally in local variables; you don't need a separate query API over the state.

If the access pattern is one durable execution per entity ID rather than many independent callers racing to mutate a shared row under a lock — in which case a distributed mutex is the right primitive instead.

Sources

Example repo: https://github.com/resonatehq-examples/example-durable-entity-ts

TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts (@resonatehq/sdk ^0.10.0)

Context.run and Context.sleep signatures: resonate-sdk-ts/src/context.ts:208,224-226

Context.sleep implementation: resonate-sdk-ts/src/context.ts:538-554

Resonate.register and Resonate.run: resonate-sdk-ts/src/resonate.ts:253,299

Runtime retry log line: resonate-sdk-ts/src/util.ts:116-119

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