Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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()`
Expand Down
33 changes: 25 additions & 8 deletions docs/examples/order-temporal-worker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand All @@ -156,22 +160,25 @@ 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),
P.tag(ACTIVITY_CANCELLED_ERROR_TAG),
(error) =>
context.activities
.refundPayment({
tenantId: args.tenantId,
authorizationId: authorized.authorizationId,
})
.flatMap(() => ErrAsync(error)),
),
)
.map(() => authorized),
),
),
),
});
Expand All @@ -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

Expand Down
30 changes: 30 additions & 0 deletions docs/how-to/split-a-worker-into-slices.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions docs/reference/temporal.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 26 additions & 25 deletions examples/order-temporal-worker/src/workflows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand All @@ -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),
),
),
);
},
Expand All @@ -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,
Expand All @@ -148,8 +150,7 @@ export const chargeOrder = declareWorkflow({
})
.flatMap(() => ErrAsync(error)),
),
)
.map(() => authorized),
),
),
),
});
Loading