4 min readResonate HQJust published

Event sourcing with per-event durable checkpoints in TypeScript

How a generator with one ctx.run per event becomes a crash-resumable event sourcing pipeline without an external event store.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

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, executes applyEvent, stores the result. A second invocation with the same id (i.e. after a crash + replay) returns the cached projection without re-executing. Used at src/workflow.ts:61. SDK definition: resonate-sdk-ts/src/context.ts:208-209 (overloads on the Context interface) and :276 (run = this.lfc.bind(this) on InnerContext), at tag v0.10.0.
  • resonate.register(processEventStream) — registers the generator as a workflow function the runtime knows how to invoke and replay. Used at src/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 at src/index.ts:34-40. SDK definition: resonate-sdk-ts/src/resonate.ts:296-300 (run = beginRun(...).result()), at tag v0.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 writeThe 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 i and event i+1. On restart, the runtime invokes processEventStream again. The for-loop iterates from i = 0. For each i < failedIndex, yield* ctx.run(applyEvent, i, ...) resolves immediately from the durable promise store — applyEvent does not run. At i = failedIndex, execution continues live. See src/workflow.ts:50-58 for the in-code comment; the cache behaviour is the documented contract of ctx.run.
  • applyEvent throws on a single event. Demonstrated by --crash: when crashAtIndex === eventIndex && attempt === 1, applyEvent throws Error("Projection store write failed for event <eventId>") at src/events.ts:99-106. The SDK retries that single ctx.run step in place — the generator does not replay from i = 0, so events 0-4 are simply not revisited. The runtime emits Runtime. Function 'applyEvent' failed with 'Error: ...' (retrying in 2 secs) (README:121). On the retry the in-process attemptMap counter has incremented past 1 (src/events.ts:92-94), the throw branch is skipped, project runs, 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.run call 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 running applyEvent again.

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