sagaflow-js 0.1.1
0.1.1 — 2026-08-23
Published under the unscoped name sagaflow-js. The first release went out briefly under a
scope; the scoped package is unpublished and every import specifier here reads sagaflow-js,
with subpaths like sagaflow-js/cloudflare. The library, the repository and everything you type
in a body are still sagaflow.
A formal model of the run lifecycle was written and model-checked in this cycle. It found five
defects. All five are fixed below, each with a test that reproduces the counterexample against
the real engine and an ablation in formal/ that removes the fix and watches the invariant fall
again. If you are running 0.1.0 durably, upgrade.
Added
- Batched durable starts.
startDurableWorkflowsandsaga.startAll(inputs, flow)create
instances through the platform'screateBatchin batches of a hundred, falling back to one
call each on a binding that cannot batch. A fan-out — one instance per tenant, per recipient,
per chunk — was a hundred round trips against a rate limit counted per second. Every input is
validated before any run record is opened, and a refused batch closes every run it opened with
the announcement every other closed run gets. - A size guard for step outputs.
sizeGuard()warns about a step whose output is too large
for a durable platform to checkpoint, by name and with a size, before the run that finally has
a large enough import finds out in production. It is an observer, and the engine measures only
when itsonStepOutputhook exists, so a serialisation per step is paid for by the people who
asked for it. The zero-configuration instance installs it. docs/cheatsheet.md— one screen: declaring, calling, the verbs and which to await,
binding undos to effects, configuring, operating, journals, Cloudflare, the four outcomes,
testing, and the short list of things never to do.docs/positioning.md,docs/integrations.md,docs/migrating-from-medusa.mdand
docs/launch/— where it sits, how it plugs into Hono, tRPC, Next.js, Express and Elysia,
how to arrive from Medusa's workflow SDK, and what to say on the day.
Fixed — the five the model found
Every one of these needs a durable run, an unlucky crash window, and a re-invocation. None of
them can happen to an inline run, which lives inside one request and is never invoked twice.
-
A fully unwound run could close
completed, with every one of its effects reversed. The
worst answer this engine could give, and no invariant in the study caught it before it was
written down. A run unwinds, the instance dies before the write that would have recorded it,
and the run row still saysrunning. Every step is memoised, so the re-invocation reaches the
end of the body without running anything fresh — and closes the run as a success. The caller is
told the work succeeded and nothing it did is standing. A run that has begun unwinding may no
longer finish. -
A re-invoked body walked past the point at which the run was closed. Cooperative
cancellation is read from whatrecordStepreturns, and a memoised step does not call
recordStep. A replay therefore did not notice a cancellation the first invocation noticed,
and ran steps for real against a run already recorded as fully undone. A durable invocation now
reads the run once before it runs anything and stops if it has already ended. -
A closed run could announce itself twice, under two different ids. The lifecycle
announcement was minted at the next ordinal of the walk, so a longer walk gave it a different
id, andon conflict do nothingcould not recognise the repeat. A closure is now identified by
the run:${runId}:completed,${runId}:compensated,${runId}:swept,
${runId}:start-refused. A run closes once, so its closure has one id, however far anybody
walked.lifecycleEnvelopeIdis exported for anybody reasoning about their own outbox. -
A refused undo was retried by a later invocation, out of order. A refusal is not
checkpointed, so the next invocation tried it again — after the undos that came later in
reverse order had already succeeded. The retry could then succeed and the run be written down
compensated, which reads as "unwound in reverse start order" and was not. A recorded refusal
is now final: the undo is not attempted again, the run closesfailed, and the refused step is
named in the error. -
A run that had begun unwinding was carried forward into steps that never ran. Same blind
spot, with the run still open: the replay does not re-read the flag that started the unwind, so
the body reached a fresh step and ran it, and its undo landed after an undo that started
earlier had already succeeded. A trail holding any compensation now stops the body advancing. -
The abandoned-run sweeper could displace an event a live run really emitted. It minted its
announcement at ordinal 0 — sound about a run that is dead, and unsound as an identity, because
ordinal 0 is also the id of a run's own first emission. With the sweep window set shorter than
a request, the conflicting insert silently dropped the run's first event, which no amount of
re-delivery can repair.
Fixed — the rest
- The reference journal left
replayOfundefined where a table storesnull. An assertion
could pass against the in-memory adapter and fail against a real database, which is precisely
backwards — the reference adapter is meant to be the strict one. flow.replaywould start a durable instance for an inline saga. An inline run has no
instance to replay, and creating one for a definition that was never durable is a stranger
failure than being told no.
Changed
- One count of the conformance suite, and it cannot drift. Three documents claimed three
different numbers of cases; the suite has thirty-seven. The number now appears once, where
somebody is deciding how much they are signing up for, and a test fails if it stops matching
the suite.
Evidence
formal/— a TLA+ model of the run lifecycle, the transactional outbox and both sweepers,
with eight TLC configurations. Two are the shipped design: every invariant holds, exhaustively,
at three steps and three invocations and again at four and four. Six are ablations, each
removing one thing the engine relies on — an optional journal read, deterministic envelope ids,
a sweep window set correctly, deterministic replay of a failed step — so that every finding can
be reproduced on demand.formal/check.shcompares all eight against whatformal/RESULTS.md
records and fails when a model stops behaving as recorded, including when one that is supposed
to find a counterexample stops finding it.test/properties/— four generative properties: compensation completeness, outbox
atomicity, re-invocation idempotency, at-least-once delivery with dedupe. Two hundred scenarios
each by default, on a fresh printed seed, each one stating the situations it must reach and
failing if a run never reached them.docs/guarantees.md— the six promises as theorems, the nine TLA+ invariants with their
verdicts, the five findings with what closed each, and a section on what is not proven that is
worth reading before the theorems.bench/anddocs/benchmarks.md— engine overhead per run and per step against three
journals, absolute numbers with the machine stated, and the same mutation implemented three
times to count what you must write yourself.
Note for journal authors
getRun and listRunSteps are still optional in the journal contract, and the durable executor
skips its entry reads when they are absent. A journal without listRunSteps gives up three of
the five fixes above; a journal without either gives up four. Every journal shipped here
implements both. If you have written your own, implement both.