fix: a saga sequences with flatTap, not a nesting ladder - #78
Merged
Conversation
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.
There was a problem hiding this comment.
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
fulfillOrderandchargeOrderto sequence steps withflatTap(preserving prior error triage and compensation behavior). - Document the sequencing rule (“
flatTap/DoAsync, never siblingconsts”) in Temporal reference/how-to docs and the Temporal example page. - Fix the Temporal example docs sample to include
tenantIdon 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #68. Files the lint half upstream as btravstack/unthrown#247.
The trap, measured
An
AsyncResultis eager: constructing it starts the work. So the readablespelling of a sequence — one
constper step, then chained — is a race, anda silent one: it type-checks and it returns a
Result.The repo's answer was to construct every later step inside the previous one's
flatMap. Correct, and one level of indentation per step —fulfillOrderwasfive steps deep with compensations deeper still.
The answer already existed
unthrownships two non-nesting sequential forms and this repo used neither:flatTap— runs a failable step, discards its value, passes theoriginal 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 whenit depends on the earlier step's value.
flatTapis the fit here: both sagas only ever needed the success, soDoAsyncwould have forced bind names for values nobody reads. Both sagas also lost their
.map(() => value)tail, sinceflatTapalready preserves it.What changed
fulfillOrder's three steps andchargeOrder's two are now siblings at oneindent level. The remaining nesting is inside the compensations, which nest
because
releaseStock→cancelPlacement→ re-Errgenuinely is nested.Error triage, compensation order and declared error types are untouched.
Verification
"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.
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.mdconventions,docs/how-to/split-a-worker-into-slices.md,docs/reference/temporal.md, and the example page's "One subtlety worthstealing" 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 isbtravstack/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 sequentialconsumption, not sibling construction.
Found while there
docs/examples/order-temporal-worker.md's workflow sample was missingtenantIdon three activity calls — pre-existing drift from when the Temporalexample 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·typecheck31/31 ·knip·test29/29 ·build10/10 · docs build — all green.