sagaflow-js 0.1.0
0.1.0 — 2026-08-22
First release. The engine is extracted from a production backend where it runs every domain
mutation, and every gap found while reviewing it for extraction is closed here rather than
shipped.
The surface
import { saga, step, emit } from 'sagaflow-js'
const createBooking = saga('booking.create', async (input: { seat: string }) => {
const seat = await step(
'reserve',
() => seats.reserve(input.seat),
(reserved) => seats.release(reserved.id),
)
await step('charge', () => cards.charge(seat.price))
await emit('booking.created', { seatId: seat.id })
return seat
})
await createBooking({ seat: '12A' })saga(name, options?, body)returns a callable definition:await def(input, flow?)runs
it,def.try(...)answers instead of throwing,def.start(...)hands a durable one to a
launcher and exists only whendurable: true.- Ambient verbs —
step,emit,sleep,waitForEvent, and the readsctx,runId,
idempotencyKey,attempt— valid inside a saga body and clear about it when they are not.
A saga body is an async function;if,forandawaitare the control flow, and
Promise.allis the parallel group. action(fn, { undo })binds an undo to an effect where the effect is defined. Inside a
saga it is a step; outside one it is exactly the function it wraps.sagaflow(config?)is configured once and knows where run records and events go, which
launcher starts durable sagas and which sagas exist. With nothing configured it runs in memory
and says so once.flow.for({ tenantId, actor, ...extras })scopes a request.- Two executors, one definition. Inline by default;
durable: truewhen it sleeps, waits,
fans out, touches the outside world or must survive a crash.
What it guarantees
Six promises, each with the test that proves it — see the README, and test/cost-model.test.ts
for what they cost.
- One atomic write closes a run and queues its events; "completed with its audit trail lost"
is unrepresentable. - Undos run in reverse start order, every one is attempted, and the result is recorded as
compensatedorfailed. - Envelope ids are deterministic, so a re-invoked durable body writes its events once.
- Idempotency keys are held by living runs and released by dead ones, per tenant.
- Every run ends in exactly one of four states, and announces itself exactly once.
- Every step carries a stable key for the outside world.
Adapters
sagaflow-js/memory, sagaflow-js/sql (with sagaflow/d1 and sagaflow-js/sqlite),
sagaflow-js/cloudflare, and sagaflow-js/testing — whose journalConformance is the journal
contract as thirty-five executable cases that any adapter, in any test runner, can prove itself
against.
Zero runtime dependencies
Validation is Standard Schema, so Zod, Valibot and ArkType all work
and none is required. @cloudflare/workers-types is an optional peer used only for types.
Fixed, relative to the engine this was extracted from
- A re-invoked durable run wrote its events twice, under ids no consumer could recognise.
Envelope ids are now${runId}:${ordinal}, the finish goes through the step runner so a
platform checkpoints it, and what a step emitted travels home inside its memoised result — so a
replayed step still contributes what it announced. - A failed run held its idempotency key forever. An invoice whose send fell over could never
be sent again, and the caller asking a second time was tolddeduplicated: true, status: 'failed'. Keys are now held by running and completed runs and released by the rest. - An inline run whose process died stayed
runningfor ever, never undone and never flagged.
sweepAbandonedRunscloses it and says why. - A step retried after a provider had already accepted the work had no stable key. Every step
context now carries one. workflow.compensatedwas declared and consumed but never emitted. It is now written into
the failure path's closing batch — and into the two endings that used to close a run in silence:
a run the sweeper closes, and a run whose platform refused to start it.- The instance id was derived from the idempotency key, which made the platform a second dedup
authority beside the run record; the two disagreed the moment a run record was swept away. An
instance is now named after the run, and the regex that guessed whether a launcher's refusal
meant "already running" is gone. - Compensation order under concurrency was unspecified, and completion order — the obvious
choice — is not stable across a durable re-invocation. It is reverse start order, proved by
driving one body twice and requiring the two unwindings to be identical. - A step still running when another failed was orphaned.
Promise.allrejects on the first
failure while the others are still going; the engine now settles every in-flight step before it
unwinds, so no undo is registered with nobody left to run it. - A body that caught the cancellation kept going and completed the run. No further step starts
once a run has been told to stop, and the run closescancelledregardless. - A run that had already been closed could be closed again, re-taking an idempotency key
somebody else now held — a uniqueness violation thrown from inside a step. Whoever closed the
run first decides how it ended. - A step name reused in one run would have been handed the first use's memoised result. Repeat
uses are now numbered —reserve,reserve#2— in call order, which a replay arrives at the
same way. - The published ESM had extensionless relative imports, which Node's own loader and
TypeScript'snode16resolution both reject.