From f3ad8e2a51cbd333f632f22a500754db63f88218 Mon Sep 17 00:00:00 2001 From: Benoit TRAVERS Date: Thu, 20 Aug 2026 22:36:14 +0200 Subject: [PATCH] fix(example): a saga sequences with flatTap, not a nesting ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CLAUDE.md | 16 ++++++ docs/examples/order-temporal-worker.md | 33 +++++++++--- docs/how-to/split-a-worker-into-slices.md | 30 +++++++++++ docs/reference/temporal.md | 30 +++++++++++ .../order-temporal-worker/src/workflows.ts | 51 ++++++++++--------- 5 files changed, 127 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b03e23a..06c1494 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -845,6 +845,22 @@ CustomersSlice, observability()], exports: [Logger] })`** is the whole `.toAsync()` survives only where it lifts a `Result` that already exists — `examples/order-application`'s `placeOrder(id, quantity).toAsync()` is the one such site. +- **A sequence is `flatTap` or `DoAsync`, never sibling `const`s.** An + `AsyncResult` is **eager**: constructing it starts the work. So the readable + spelling of a sequence — each step in its own `const`, then chained — is a + **race**, and a silent one: it still type-checks and still returns a `Result`, + it just runs the steps concurrently. Nothing in the gate catches it, which is + the one place this repo's "mistakes are compile errors" thesis does not hold. + `flatTap` is the answer where a later step needs only the earlier one's + _success_: it runs a failable step, discards its value and passes the original + through, so a five-step saga stays flat instead of becoming five levels of + indentation. `DoAsync().bind(...)` is the same idea where a later step needs + an earlier step's _value_, with an accumulating scope. + `examples/order-temporal-worker`'s `fulfillOrder` and `chargeOrder` are the + worked examples, and their specs assert the ordering (_"place, reserve, ship, + in order"_) so a regression to the racing spelling fails a test rather than + shipping. Measured: the sibling spelling logs `start:a start:b end:b end:a`, + `flatTap` logs `start:a end:a start:b end:b`. - Comment density: **sparse**. No comments in JSON files. Rationale belongs here, not inline — except where a comment is guarding a specific line against a plausible "simplification" (the `teardownErrors` aliasing, the `ready()` diff --git a/docs/examples/order-temporal-worker.md b/docs/examples/order-temporal-worker.md index e3cadab..30f2592 100644 --- a/docs/examples/order-temporal-worker.md +++ b/docs/examples/order-temporal-worker.md @@ -144,7 +144,11 @@ export const chargeOrder = declareWorkflow({ implementation: (context, args) => propagateActivityFailure( context.activities - .authorizePayment({ orderId: args.orderId, amount: args.amount }) + .authorizePayment({ + tenantId: args.tenantId, + orderId: args.orderId, + amount: args.amount, + }) .mapErrCases((matcher) => matcher .with({ errorName: "PaymentDeclined" }, (error) => @@ -156,9 +160,12 @@ export const chargeOrder = declareWorkflow({ (error) => error, ), ) - .flatMap((authorized) => + .flatTap((authorized) => context.activities - .capturePayment({ authorizationId: authorized.authorizationId }) + .capturePayment({ + tenantId: args.tenantId, + authorizationId: authorized.authorizationId, + }) .flatMapErrCases((matcher) => matcher.with( P.tag(ACTIVITY_ERROR_TAG), @@ -166,12 +173,12 @@ export const chargeOrder = declareWorkflow({ (error) => context.activities .refundPayment({ + tenantId: args.tenantId, authorizationId: authorized.authorizationId, }) .flatMap(() => ErrAsync(error)), ), - ) - .map(() => authorized), + ), ), ), }); @@ -188,9 +195,19 @@ which is exactly when the money needs to go back. ## One subtlety worth stealing -An `AsyncResult` is **eager** — building a step starts its activity — so every -later step in `workflows.ts` is constructed inside the `flatMap` of the one -before it. Hoist them into `const`s and the "sequence" runs as a race. +An `AsyncResult` is **eager**: building a step starts its activity. So a +sequence must never construct two steps as siblings — hoist them into `const`s +and the "sequence" runs as a race, silently, with the types still checking out. + +The spelling that avoids it is `flatTap`, which is why `workflows.ts` reads as a +flat chain rather than a nesting ladder. It runs a failable step, discards its +value and passes the **original** one through, so each step's error triage and +compensation sit at one level of indentation instead of accumulating — and the +next step is a callback, which cannot start before the previous one settles. + +`chargeOrder` above shows it at two steps; `fulfillOrder` runs three the same +way. Where a later step needs an earlier step's _value_ rather than just its +success, `DoAsync().bind(...)` is the same idea with an accumulating scope. ## The external services diff --git a/docs/how-to/split-a-worker-into-slices.md b/docs/how-to/split-a-worker-into-slices.md index 773d554..d1ec734 100644 --- a/docs/how-to/split-a-worker-into-slices.md +++ b/docs/how-to/split-a-worker-into-slices.md @@ -169,6 +169,36 @@ Dropping a slice's import here would leave its piece's port unmet — a runtime `TemporalActivities` cannot see what a slice's module has or has not been imported by; only `start` can. +## Sequencing a saga: `flatTap`, never sibling `const`s + +An `AsyncResult` is **eager** — constructing it starts the work. So the +readable spelling of a sequence, each step in its own `const` and then chained, +is a **race**: it type-checks, it returns a `Result`, and it runs the steps +concurrently. Nothing catches it. + +Sequence with [`flatTap`](https://github.com/btravstack/unthrown) instead. It +runs a failable step, discards its value and passes the **original** one +through, so the next step is a callback that cannot start before the previous +settles — and each step's error triage and compensation stay at one level of +indentation rather than accumulating: + +```ts +context.activities + .place(order) + .mapErrCases(/* triage */) + .flatTap(() => + context.activities.reserveStock(order).flatMapErrCases(/* compensate */), + ) + .flatTap(() => + context.activities.arrangeShipping(order).flatMapErrCases(/* compensate */), + ); +``` + +Where a later step needs an earlier step's _value_ rather than just its +success, `DoAsync().bind("name", (scope) => …)` is the same idea with an +accumulating scope. See +[Order Temporal worker](/examples/order-temporal-worker) for both at full size. + ## What a mistake looks like at compile time Two mistakes are caught before the array is ever composed, both inside diff --git a/docs/reference/temporal.md b/docs/reference/temporal.md index 45e83ae..b016dfe 100644 --- a/docs/reference/temporal.md +++ b/docs/reference/temporal.md @@ -341,6 +341,36 @@ activities, `shutdownGraceTime` and `shutdownForceTime`. Once `run()` has started polling it publishes `TemporalInfo`, `{ taskQueue, namespace }`, on `Serving.info`. +## Sequencing a saga: `flatTap`, never sibling `const`s + +An `AsyncResult` is **eager** — constructing it starts the work. So the +readable spelling of a sequence, each step in its own `const` and then chained, +is a **race**: it type-checks, it returns a `Result`, and it runs the steps +concurrently. Nothing catches it. + +Sequence with [`flatTap`](https://github.com/btravstack/unthrown) instead. It +runs a failable step, discards its value and passes the **original** one +through, so the next step is a callback that cannot start before the previous +settles — and each step's error triage and compensation stay at one level of +indentation rather than accumulating: + +```ts +context.activities + .place(order) + .mapErrCases(/* triage */) + .flatTap(() => + context.activities.reserveStock(order).flatMapErrCases(/* compensate */), + ) + .flatTap(() => + context.activities.arrangeShipping(order).flatMapErrCases(/* compensate */), + ); +``` + +Where a later step needs an earlier step's _value_ rather than just its +success, `DoAsync().bind("name", (scope) => …)` is the same idea with an +accumulating scope. See +[Order Temporal worker](/examples/order-temporal-worker) for both at full size. + ## The unit One unit per activity **attempt**, `kind: "activity"`, opened by the diff --git a/examples/order-temporal-worker/src/workflows.ts b/examples/order-temporal-worker/src/workflows.ts index 2404e91..95fff21 100644 --- a/examples/order-temporal-worker/src/workflows.ts +++ b/examples/order-temporal-worker/src/workflows.ts @@ -48,9 +48,12 @@ export const fulfillOrder = declareWorkflow({ workflowName: "fulfillOrder", contract: orderContract, implementation: (context, args) => { - // An `AsyncResult` is eager — building a step IS starting its activity — - // so every later step is constructed inside the `flatMap` of the one - // before it, or the "sequence" would run as a race. + // Flat, and sequential because of it. An `AsyncResult` is eager — building + // a step IS starting its activity — so a sequence must never construct two + // steps as siblings. `flatTap` is what keeps that from needing a nesting + // ladder: it runs a failable step, discards its value, and passes the + // original one through, so each step's own error triage and compensation + // stay at one level of indentation instead of accumulating. const order = { tenantId: args.tenantId, orderId: args.orderId }; return propagateActivityFailure( @@ -66,7 +69,7 @@ export const fulfillOrder = declareWorkflow({ ) .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => error), ) - .flatMap((placed) => + .flatTap(() => context.activities .reserveStock({ tenantId: args.tenantId, @@ -85,26 +88,25 @@ export const fulfillOrder = declareWorkflow({ .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => ErrAsync(error), ), - ) - .flatMap(() => - context.activities.arrangeShipping(order).flatMapErrCases((matcher) => - matcher - // The deeper walk-back, in reverse order of the steps it - // undoes: release the reservation, then the placement. - .with({ errorName: "ShippingUnavailable" }, (error) => - context.activities - .releaseStock(order) - .flatMap(() => context.activities.cancelPlacement(order)) - .flatMap(() => - ErrAsync(context.errors.ShippingUnavailable({ id: error.data.id })), - ), - ) - .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => - ErrAsync(error), + ), + ) + .flatTap(() => + context.activities.arrangeShipping(order).flatMapErrCases((matcher) => + matcher + // The deeper walk-back, in reverse order of the steps it undoes: + // release the reservation, then the placement. + .with({ errorName: "ShippingUnavailable" }, (error) => + context.activities + .releaseStock(order) + .flatMap(() => context.activities.cancelPlacement(order)) + .flatMap(() => + ErrAsync(context.errors.ShippingUnavailable({ id: error.data.id })), ), + ) + .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => + ErrAsync(error), ), - ) - .map(() => placed), + ), ), ); }, @@ -130,7 +132,7 @@ export const chargeOrder = declareWorkflow({ ) .with(P.tag(ACTIVITY_ERROR_TAG), P.tag(ACTIVITY_CANCELLED_ERROR_TAG), (error) => error), ) - .flatMap((authorized) => + .flatTap((authorized) => context.activities .capturePayment({ tenantId: args.tenantId, @@ -148,8 +150,7 @@ export const chargeOrder = declareWorkflow({ }) .flatMap(() => ErrAsync(error)), ), - ) - .map(() => authorized), + ), ), ), });