Skip to content

fix: a saga sequences with flatTap, not a nesting ladder - #78

Merged
btravers merged 1 commit into
mainfrom
fix/sequences-without-the-ladder
Aug 20, 2026
Merged

fix: a saga sequences with flatTap, not a nesting ladder#78
btravers merged 1 commit into
mainfrom
fix/sequences-without-the-ladder

Conversation

@btravers

Copy link
Copy Markdown
Contributor

Closes #68. Files the lint half upstream as btravstack/unthrown#247.

The trap, measured

An AsyncResult is eager: constructing it starts the work. So the readable
spelling of a sequence — one const per step, then chained — is a race, and
a silent one: it type-checks and it returns a Result.

sibling consts : start:a start:b end:b end:a     ← both started at once
flatTap        : start:a end:a start:b end:b     ← sequential

The repo's answer was to construct every later step inside the previous one's
flatMap. Correct, and one level of indentation per step — fulfillOrder was
five steps deep with compensations deeper still.

The answer already existed

unthrown ships two non-nesting sequential forms and this repo used neither:

  • flatTap — runs a failable step, discards its value, passes the
    original one through. What a saga needs when a later step depends on an
    earlier step's success.
  • DoAsync().bind(...) — the same idea with an accumulating scope, for when
    it depends on the earlier step's value.

flatTap is the fit here: both sagas only ever needed the success, so DoAsync
would have forced bind names for values nobody reads. Both sagas also lost their
.map(() => value) tail, since flatTap already preserves it.

What changed

fulfillOrder's three steps and chargeOrder's two are now siblings at one
indent level
. The remaining nesting is inside the compensations, which nest
because releaseStockcancelPlacement → re-Err genuinely is nested.

Error triage, compensation order and declared error types are untouched.

Verification

  • The five existing specs pass, including the two that assert ordering
    "place, reserve, ship, in order" and "compensates a shipping refusal in
    reverse order: release, then cancel"
    . Those predate this change, so a
    regression to the racing spelling fails a test rather than shipping.
  • The rewrite was spiked against the real contract before being written, and the
    race/no-race behaviour above was measured, not reasoned about.

Documented where a saga author lands

The trap was previously stated in one place: a comment inside the workflow
file, which you only read once you are already writing one. It is now in the
root CLAUDE.md conventions, docs/how-to/split-a-worker-into-slices.md,
docs/reference/temporal.md, and the example page's "One subtlety worth
stealing"
section — which had been teaching the ladder as the answer.

The gate: filed upstream, not built here

Acceptance 3 wants a gate that makes the racing spelling fail. That is a lint
rule and it belongs in @unthrown/oxlint, so it is
btravstack/unthrown#247.

The issue records the part that makes it non-trivial: sibling construction is
not itself wrong
— it is exactly how deliberate concurrency is spelled for
allAsync. The rule has to flag sibling construction followed by sequential
consumption
, not sibling construction.

Found while there

docs/examples/order-temporal-worker.md's workflow sample was missing
tenantId on three activity calls — pre-existing drift from when the Temporal
example became multi-tenant, the same class as #75 but on the Temporal side.
Surfaced only because the fence was extracted and compiled; fixed in place.

Gate

format --check · lint · typecheck 31/31 · knip · test 29/29 ·
build 10/10 · docs build — all green.

An AsyncResult is eager, so the readable spelling of a sequence is a race —
silent, because it still type-checks and still returns a Result. The answer
this repo reached for was to construct every step inside the previous one's
flatMap, which is correct and costs a level of indentation per step.

flatTap runs a failable step, discards its value and passes the original
through, so both sagas are flat: their three and two steps are siblings at one
level, and only the compensations nest, because release-then-cancel-then-Err
genuinely does. Both lost their .map(() => value) tail with it.

Measured: the sibling spelling logs 'start:a start:b end:b end:a', flatTap
logs 'start:a end:a start:b end:b'. The specs asserting order already existed,
so a regression to the racing spelling fails a test.

The convention is recorded in CLAUDE.md and on three documentation pages, so
the trap is met before a saga is written rather than in a comment inside one.
The example page's workflow sample also regains the tenantId it lost when the
Temporal example became multi-tenant — found by compiling the fence.
Copilot AI lite review requested due to automatic review settings August 20, 2026 20:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Refactors the Temporal example’s saga workflows to stay sequential without deep nesting by using flatTap, and updates the documentation/conventions to teach and reinforce the “AsyncResult eagerness” trap (including fixing a docs drift where tenantId was missing in the workflow sample).

Changes:

  • Rewrite fulfillOrder and chargeOrder to sequence steps with flatTap (preserving prior error triage and compensation behavior).
  • Document the sequencing rule (“flatTap / DoAsync, never sibling consts”) in Temporal reference/how-to docs and the Temporal example page.
  • Fix the Temporal example docs sample to include tenantId on activity calls.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
File Description
examples/order-temporal-worker/src/workflows.ts Replace nesting-ladder sequencing with flatTap while preserving ordering, triage, and compensation.
docs/reference/temporal.md Add a reference section explaining the eagerness trap and recommending flatTap / DoAsync.
docs/how-to/split-a-worker-into-slices.md Add the same sequencing guidance where saga authors are likely to land.
docs/examples/order-temporal-worker.md Update the workflow sample for multi-tenancy (tenantId) and explain flatTap-based sequencing.
CLAUDE.md Codify the repo convention: sequence AsyncResult work with flatTap / DoAsync, not sibling consts.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@btravers
btravers merged commit bbad4a1 into main Aug 20, 2026
14 checks passed
@btravers
btravers deleted the fix/sequences-without-the-ladder branch August 20, 2026 20:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

AsyncResult eagerness: the readable spelling of a sequence is a silent race

2 participants