A stateful reducer over a stream of events (state = events.reduce(applyEvent, initial)) loses progress whenever the worker crashes mid-stream — without external machinery you either restart from event 0 or maintain offset tracking, checkpoint tables, and an idempotency layer to avoid double-applying events. Resonate makes each event application a durable checkpoint via ctx.run, so the generator can replay from the top after a crash and every already-applied event returns its cached projection immediately. The example processes 8 domain events into an account projection, with a --crash mode that fails the projection step at event 5 and shows the resume picking up exactly there.
The shape of the solution
// imports: Context (@resonatehq/sdk); applyEvent, initialProjection, UserEvent (./events); ProjectionResult defined locally in workflow.ts
export function* processEventStream(
ctx: Context,
userId: string,
events: UserEvent[],
crashAtIndex: number,
): Generator<any, ProjectionResult, any> {
// Start with empty projection
let projection = initialProjection(userId);
// ...
for (let i = 0; i < events.length; i++) {
const event = events[i]!;
projection = yield* ctx.run(applyEvent, i, event, projection, crashAtIndex);
}
return {
userId,
eventsProcessed: projection.eventsProcessed,
finalProjection: projection,
};
}
// from example-event-sourcing-ts/src/workflow.ts:41-69 (comment block at 50-58 elided)The reducer is a generator. Each yield* ctx.run(applyEvent, ...) is one durable step keyed by its position in the call tree. On a replay, completed steps short-circuit to their cached return value (the projection at that event), and execution resumes at the first step that has no cached result.
applyEvent (src/events.ts:85-115) is an async wrapper: it bumps a per-process attemptMap counter, simulates 50ms of latency, optionally throws on the --crash index, then calls the pure projection function project(state, event) (src/events.ts:121-183) — a switch over event types that returns the next projection. Wrapping applyEvent in ctx.run is what makes the step durable; project itself is plain (state, event) → nextState.
The durable primitives in play
ctx.run(applyEvent, i, event, projection, crashAtIndex)— local durable function call (LFC). Registers a durable promise keyed by the step id, executesapplyEvent, stores the result. A second invocation with the same id (i.e. after a crash + replay) returns the cached projection without re-executing. Used atsrc/workflow.ts:61. SDK definition:resonate-sdk-ts/src/context.ts:208-209(overloads on theContextinterface) and:276(run = this.lfc.bind(this)onInnerContext), at tagv0.10.0.resonate.register(processEventStream)— registers the generator as a workflow function the runtime knows how to invoke and replay. Used atsrc/index.ts:10.resonate.run(`projection/${userId}`, processEventStream, userId, events, CRASH_AT_INDEX)— kicks off the durable workflow under a stable id and awaits its terminal result. Used atsrc/index.ts:34-40. SDK definition:resonate-sdk-ts/src/resonate.ts:296-300(run=beginRun(...).result()), at tagv0.10.0.
No ctx.sleep, no ctx.detached, no ctx.promise, no resonate.schedule. The entire pattern is one workflow function + one durable step type.
What the SDK handles vs. what you write
| You write | The SDK handles |
|---|---|
The generator processEventStream with a for-loop over events. | Replaying the generator from the top on restart. |
The pure project(state, event) switch — (state, event) → nextState. | Keying each ctx.run call to a durable promise, persisting its result, and serving the cached result on replay. |
The top-level resonate.run("projection/<userId>", ...) invocation. | Per-step retries when applyEvent throws (the README's Runtime. Function 'applyEvent' failed ... (retrying in 2 secs) line at README:121 is runtime output, not application code). |
| The shape of the projection and what counts as one event. | "Where did we leave off" — there is no offset variable in the application code. The replay-plus-cache mechanism is the resume mechanism. |
There is no application-level checkpoint table, no idempotency key threaded through applyEvent, no offset persisted alongside the projection. The durable promise behind each ctx.run call IS the checkpoint, and the generator's deterministic replay IS the resume.
Failure modes covered
- Worker process crashes between event
iand eventi+1. On restart, the runtime invokesprocessEventStreamagain. The for-loop iterates fromi = 0. For eachi < failedIndex,yield* ctx.run(applyEvent, i, ...)resolves immediately from the durable promise store —applyEventdoes not run. Ati = failedIndex, execution continues live. Seesrc/workflow.ts:50-58for the in-code comment; the cache behaviour is the documented contract ofctx.run. applyEventthrows on a single event. Demonstrated by--crash: whencrashAtIndex === eventIndex && attempt === 1,applyEventthrowsError("Projection store write failed for event <eventId>")atsrc/events.ts:99-106. The SDK retries that singlectx.runstep in place — the generator does not replay fromi = 0, so events 0-4 are simply not revisited. The runtime emitsRuntime. Function 'applyEvent' failed with 'Error: ...' (retrying in 2 secs)(README:121). On the retry the in-processattemptMapcounter has incremented past 1 (src/events.ts:92-94), the throw branch is skipped,projectruns, and the workflow continues to event 6.- Re-applying the same event would corrupt the projection (double order counts, double renewals). Exactly-once at the checkpoint boundary: each
ctx.runcall is keyed by its position in the generator's call tree, so the same logical step on replay returns its previously-stored result rather than runningapplyEventagain.
What this example does NOT cover, and does not claim to cover: concurrent workers projecting the same stream, persistence of the projection to an external store, broker-side delivery guarantees, or out-of-order arrival. The README explicitly defers Kafka-routed events to a separate example (README.md:59).
When to reach for this pattern
- If you are reducing a sequence of events into a state projection and need crash recovery without writing offset-tracking or checkpoint-table code yourself.
- If your projection step is pure (
(state, event) → nextState) but you want each step to survive a process crash with its result intact. - If you want exactly-once application of each event in the stream, keyed off the workflow's structure rather than a separate idempotency table.
- If your event stream length is variable (8 events, 10K events) and you do not want the resume cost to grow with stream length — the cache lookup per cached step is constant per step.
- If you need a read-model projection over an immutable event stream and you want the recompute pipeline itself to be durable.
- Not the right fit if the events are arriving from an external broker that already owns offsets and per-key routing — for that, see the per-message Kafka worker example.
Sources
- Example repo: https://github.com/resonatehq-examples/example-event-sourcing-ts
- TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts (this example pins
@resonatehq/sdk ^0.10.0perpackage.json:11) ctx.run(LFC) definition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/context.ts (lines 208-209 for theContextinterface overloads, line 276 for theInnerContextbindingrun = this.lfc.bind(this))resonate.rundefinition: https://github.com/resonatehq/resonate-sdk-ts/blob/v0.10.0/src/resonate.ts (lines 296-300; the body delegates tobeginRun(...).result())- Resonate documentation: https://docs.resonatehq.io
