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
7 changes: 7 additions & 0 deletions .changeset/configure-workflow-without-resolving.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@node-ts/bus-core': minor
---

**Breaking:** the bus reads a class workflow's `configureWorkflow()` without creating the workflow (#354). It's called once when the bus provisions or initializes, on an instance created from the class' prototype without running its constructor, and the workflow is only resolved from the container, or constructed, to handle a message. A request-scoped workflow, or one whose dependencies have side effects, is no longer created with no message just to read its mapping.

A `configureWorkflow()` that uses the workflow's fields or injected dependencies now finds them `undefined`; if it throws, `provision()` or `initialize()` throws the new `WorkflowConfigurationFailed`, naming the workflow. `startedBy` and `when` throw the new `WorkflowMappingInvalid`, which it wraps, when given no message, a handler name that isn't a string, or a lookup without a `lookup` function or `mapsTo` field, so a value read from a field fails at startup rather than on every message. A `configureWorkflow` declared as an arrow function property must become a method. A workflow the container can't resolve now fails the messages it handles with `WorkflowHandlerFailed`, as a class handler does, rather than failing `initialize()`. `testWorkflow()` reads `configureWorkflow()` the same way, so `createWorkflow` is only called for each message. See `MIGRATING.md`.
2 changes: 1 addition & 1 deletion .changeset/nestjs-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Add `@node-ts/bus-nestjs`, a NestJS module for the bus (#268), for NestJS 11 and

- `BusModule.forRoot({ configure })` and `BusModule.forRootAsync({ imports, inject, useFactory })` register a bus, configured by a factory that's given a `BusConfiguration` (logging through Nest's `Logger`, with no interrupt signals) and returns it with the transport, persistence and message types. The bus is injected as `BusInstance`; named buses with `@InjectBus(name)` and `getBusToken(name)`.
- Class handlers and workflows are providers of your modules, registered with the bus by `@BusHandler()` and `@BusWorkflow()` or by listing them in `BusModule.forFeature()`, and resolved from Nest's container for each message by `nestContainer(moduleRef)`. Function handlers and `defineWorkflow()` workflows are registered with `BusModule.forFeatureAsync()`, given the providers they close over. Request-scoped providers get a request scope per received message, with the message and its attributes as Nest's `REQUEST` (`BusRequest`).
- The bus is built when the application initializes, and initialized and started when it bootstraps (or by the application, with `lifecycle: 'manual'`); if that fails, it's disposed. Since `BusModule` is global, Nest stops it after the `onModuleDestroy` of every module that isn't global, and disposes it after their `onApplicationShutdown`, so release what handlers use in `beforeApplicationShutdown` or `onApplicationShutdown`. Startup fails with `BusClassNotProvided`, `BusNotRegistered`, `BusAlreadyRegistered`, `BusFeatureNotStatic`, `WorkflowResolvedWithoutMessage` or `BusCoreVersionNotSupported`, naming the fix.
- The bus is built when the application initializes, and initialized and started when it bootstraps (or by the application, with `lifecycle: 'manual'`); if that fails, it's disposed. Since `BusModule` is global, Nest stops it after the `onModuleDestroy` of every module that isn't global, and disposes it after their `onApplicationShutdown`, so release what handlers use in `beforeApplicationShutdown` or `onApplicationShutdown`. Startup fails with `BusClassNotProvided`, `BusNotRegistered`, `BusAlreadyRegistered`, `BusFeatureNotStatic` or `BusCoreVersionNotSupported`, naming the fix.
- `createBusForProvisioning(AppModule)` returns the application's bus, built but not initialized, for `bus provision`.

`bus provision` in `@node-ts/bus-cli` also accepts a module that exports a built, uninitialized bus, or a function that returns one, and exits once it has printed its report, even if the module left handles open. `BusInstance.canStart` in `@node-ts/bus-core` says whether `start()` can be called: false for a send-only bus or one with a `Receiver`.
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ pnpm exec dotenv -e test.env -- jest packages/bus-sqs/src/sqs-transport.spec.ts

### Workflows (bus-core/src/workflow)

- A workflow extends `Workflow<TState>` and implements `configureWorkflow(mapper)`, declaring `startedBy(Message, 'handlerName')` and `when(Message, 'handlerName', { lookup, mapsTo })`. Handlers return a partial state that is merged and saved; `completeWorkflow()` / `discardWorkflow()` end or drop it.
- A workflow extends `Workflow<TState>` and implements `configureWorkflow(mapper)`, declaring `startedBy(Message, 'handlerName')` and `when(Message, 'handlerName', { lookup, mapsTo })`. Handlers return a partial state that is merged and saved; `completeWorkflow()` / `discardWorkflow()` end or drop it. `configureClassWorkflow` (`workflow/configure-class-workflow.ts`, used by the registry and `testWorkflow()`) calls `configureWorkflow` on `Object.create(prototype)`, never constructing or resolving the workflow, throws `WorkflowConfigurationFailed` if it isn't a method, and wraps what it throws in it (other than the mapper's `WorkflowAlreadyStartedByMessage`/`WorkflowAlreadyHandlesMessage`; the mapper's `startedBy`/`when` throw `WorkflowMappingInvalid` for an undefined message, non-string handler name or bad lookup, so field-held values fail at startup); the workflow is only resolved, with the message, to handle one (#354).
- `defineWorkflow(State)` (`workflow/define-workflow.ts`) is the function equivalent: an immutable builder whose `.startedBy(Message, fn)`, `.when(Message, fn)` and `.when(Message, { lookup, mapsTo }, fn)` handlers get `(message, state, ctx)`, where `ctx` is a `WorkflowContext` (handler context + `attributes`, `complete()`, `discard()`; `workflowContext()` builds a recording fake for tests, and `testWorkflow()` runs either workflow style message by message with the registry's merge rules from `apply-workflow-state-change.ts`). It's named after its state's `$name`. Each handler is a generic `THandler` checked by `CheckedWorkflowHandler` (`workflow-state-change.ts`), which finds returned fields that aren't in the state at any depth; a handler with an annotated return type is only checked against the annotation. Class workflows reuse it: the mapper's `startedBy`/`when` take a generic handler name checked by `CheckedClassWorkflowHandler` (`workflow.ts`) against the method's parameters and return, and both of `WorkflowMapper`'s type parameters are `out`, so `configureWorkflow(mapper: WorkflowMapper<TState, this>)` accepts subclasses but not an unrelated workflow's mapper, and checking it stays cheap (without `out` on the state, tsc measures it as unreliable and falls back to structural checks costing ~200k instantiations). Keep the stored handler names `string`, and write the argument checks in terms of the generic `THandlerName` (`WorkflowHandlerArgument`), since anything else typed by the workflow's keys breaks that variance (TS2636). The handler lists live on the internal `FunctionWorkflowDefinition` (`function-workflow-definition.ts`, not exported). `WorkflowRegistry` turns both styles into the same `WorkflowHandlers` before registering them, and always keeps the persisted `$workflowId`, `$version` and `$name` over what a handler returns.
- `WorkflowRegistry` turns these into handler registrations and uses `Persistence` (`getWorkflowState`, `saveWorkflowState`) to load state by the mapped property; the storage of each workflow state is created by `Persistence.provision()`. State carries `$workflowId`, `$status`, and `$version` for optimistic concurrency. When no custom lookup is given, `when` matches on `stickyAttributes.workflowId` → `$workflowId`; the id is put into the sticky attributes when the workflow starts, so messages sent from that point on (and their replies) route back to the same workflow instance.

Expand Down
1 change: 1 addition & 0 deletions MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ Two things behave differently:

`WorkflowHandler`'s parameters are now required, `completeWorkflow()` and `discardWorkflow()` return `WorkflowStateChange<TState>`, and `WorkflowMapper`'s `onStartedBy` and `onWhen` store handler names as `string` (`OnWhenHandler` no longer takes type arguments).

- **Class workflows are only created to handle a message.** When the bus provisions or initializes, it reads `configureWorkflow()` from an instance created from the class' prototype, without running its constructor, instead of first resolving the workflow from the container, or constructing it. A `configureWorkflow()` that uses the workflow's fields or injected dependencies now finds them `undefined`, and if it throws or passes the mapper an undefined message, handler name or lookup, `provision()` or `initialize()` throws `WorkflowConfigurationFailed` naming the workflow. A `configureWorkflow` declared as an arrow function property must become a method. Map messages with only the mapper and values that don't come from the instance, and use dependencies in the handler methods. A workflow the container can't resolve now fails each message it handles with `WorkflowHandlerFailed`, as a class handler does, rather than failing `initialize()`. `testWorkflow()` reads `configureWorkflow()` the same way, so its `createWorkflow` is only called for each message.
- **`Transport` has a required `endpointName`**, the name of the queue the bus receives from. A custom transport must add it, such as `get endpointName() { return this.configuration.queueName }`. `InMemoryQueue` takes an optional `endpointName`, `in-memory` by default.
- **Every message the bus sends has a `messageId` and a `sentAt`** in its `MessageAttributes`: a new UUID and an ISO 8601 timestamp, unless you pass your own to `send` or `publish`. Unlike `correlationId`, they aren't copied from the message being handled. A custom transport must carry them with the message and keep them across retries and in the dead letter queue (see [Custom transports](https://node-ts.github.io/bus/transports/custom)). Tests that compare a handler's attributes with `toEqual` may need `toMatchObject` or `expect.objectContaining`.
- **Retry strategies are replaced by a recoverability policy.** `withRetryStrategy()`, `RetryStrategy`, `DefaultRetryStrategy` and `CoreDependencies.retryStrategy` are removed. The bus now decides both the delay and when a message is out of attempts, with the policy passed to `withRecoverability()`: a function of the failure that returns `retry(delay)` or `deadLetter()` (see [Recoverability](https://node-ts.github.io/bus/guide/recoverability)). The default, `defaultRecoverability()`, makes 10 attempts with the same exponential delays as `DefaultRetryStrategy`, so a bus that didn't configure retries behaves as before.
Expand Down
2 changes: 1 addition & 1 deletion docs/guide/nestjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ The bus resolves class handlers and workflows for each message. Request-scoped p

<<< @/snippets/nestjs.ts#request-scope

A request-scoped class workflow is also resolved once when the bus initializes, to read its `configureWorkflow()`, with no message, so `REQUEST` is `undefined` then. Keep the request in its constructor, and the constructors of the request-scoped providers it depends on, and read its message and attributes when a message is handled. If resolving it fails then, the application fails to start with `WorkflowResolvedWithoutMessage`.
Class workflows are only resolved to handle a message, so a request-scoped workflow, and the request-scoped providers it depends on, can read `REQUEST` in their constructors. The bus reads a class workflow's `configureWorkflow()` without creating it (see [Creating a workflow](/guide/workflows/creating-a-workflow#declaring-the-workflow)).

Outside `BusModule`, `nestContainer(moduleRef)` gives `withContainer()` the same container adapter.

Expand Down
4 changes: 3 additions & 1 deletion docs/guide/workflows/creating-a-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ With functions, `defineWorkflow(State)` returns a workflow that `startedBy` and

:::

A function workflow reaches its dependencies through closures and needs no container. A class workflow is constructed with no arguments, or resolved from a [container](/guide/dependency-injection) when the bus has one.
A function workflow reaches its dependencies through closures and needs no container. A class workflow is constructed with no arguments, or resolved from a [container](/guide/dependency-injection) when the bus has one, for each message it handles.

The bus calls `configureWorkflow` once when it provisions or initializes, on an instance created from the class' prototype without running its constructor, so it can't use the workflow's fields or dependencies; use those in the handler methods. Declare it as a method, not as a property holding an arrow function, and pass the mapper the message, handler name and lookup directly rather than from a field. If it throws, or passes the mapper an undefined value, `provision()` or `initialize()` throws `WorkflowConfigurationFailed`, naming the workflow.

Type a class workflow's mapper with the class itself, as `WorkflowMapper<FulfilmentWorkflowState, FulfilmentWorkflow>`. The compiler then checks every handler name against the method it names:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
PersistenceInitializationOptions,
PersistenceProvisionOptions,
Workflow,
WorkflowConfigurationFailed,
WorkflowMapper
} from '../workflow'
import {
Expand All @@ -28,18 +29,13 @@ import { BusConfiguration } from './bus-configuration'
import { BusInstance } from './bus-instance'

/**
* A workflow that can't be constructed, so registering it fails
* A workflow whose configureWorkflow() throws, so registering it fails
*/
class UnconstructableWorkflow extends Workflow<TestWorkflowState> {
constructor() {
super()
throw new Error('UnconstructableWorkflow failed to construct')
}

class UnconfigurableWorkflow extends Workflow<TestWorkflowState> {
configureWorkflow(
mapper: WorkflowMapper<TestWorkflowState, UnconstructableWorkflow>
_mapper: WorkflowMapper<TestWorkflowState, UnconfigurableWorkflow>
): void {
mapper.withState(TestWorkflowState)
throw new Error('UnconfigurableWorkflow failed to configure')
}
}

Expand Down Expand Up @@ -439,7 +435,7 @@ describe('BusInstance provisioning', () => {
.withLogger(() => Mock.ofType<Logger>().object)
.withMessageTypes(testMessageTypes)
.withTransport(new ProvisioningQueue())
.withWorkflow(UnconstructableWorkflow)
.withWorkflow(UnconfigurableWorkflow)
.build()
provisionError = await sut.provision().catch((e: unknown) => e)
initializeError = await sut.initialize().catch((e: unknown) => e)
Expand All @@ -448,8 +444,9 @@ describe('BusInstance provisioning', () => {
afterAll(async () => sut.dispose())

it('should fail provisioning', () => {
expect((provisionError as Error).message).toEqual(
'UnconstructableWorkflow failed to construct'
expect(provisionError).toBeInstanceOf(WorkflowConfigurationFailed)
expect((provisionError as WorkflowConfigurationFailed).cause).toEqual(
new Error('UnconfigurableWorkflow failed to configure')
)
})

Expand Down
30 changes: 27 additions & 3 deletions packages/bus-core/src/testing/test-workflow.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { ReturnAddressMissing } from '../error'
import { InvalidDeliveryOptions } from '../outgoing-message'
import { defineWorkflow, WorkflowStateNotProvided } from '../workflow'
import {
defineWorkflow,
WorkflowConfigurationFailed,
WorkflowStateNotProvided
} from '../workflow'
import { TestCommand } from '../workflow/test/test-command'
import { TestDiscardedWorkflow } from '../workflow/test/test-discarded-workflow'
import {
Expand Down Expand Up @@ -419,8 +423,8 @@ describe('testWorkflow', () => {
await sut.when(TestPaymentReceived({ orderId: '1' }))
})

it('should create one to configure it and one for each message', () => {
expect(created).toEqual(3)
it('should create one for each message, and none to configure it', () => {
expect(created).toEqual(2)
})
})

Expand All @@ -441,6 +445,26 @@ describe('testWorkflow', () => {
})
})

describe('when a class workflow reads its fields in configureWorkflow', () => {
class FieldReadingWorkflow extends Workflow<TestScenarioState> {
private readonly options = { stateType: TestScenarioState }

configureWorkflow(
mapper: WorkflowMapper<TestScenarioState, FieldReadingWorkflow>
): void {
mapper.withState(this.options.stateType)
}
}

it('should throw WorkflowConfigurationFailed, as the bus does', () => {
expect(() =>
testWorkflow(FieldReadingWorkflow, {
createWorkflow: () => new FieldReadingWorkflow()
})
).toThrow(WorkflowConfigurationFailed)
})
})

describe('when a handler replies', () => {
let result: WorkflowScenarioResult<TestScenarioState>

Expand Down
15 changes: 5 additions & 10 deletions packages/bus-core/src/testing/test-workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,11 @@ import {
MessageWorkflowMapping,
Workflow,
WorkflowHandlerResult,
WorkflowMapper,
WorkflowState,
WorkflowStateNotProvided,
WorkflowStatus
} from '../workflow'
import { applyWorkflowStateChange } from '../workflow/apply-workflow-state-change'
import { configureClassWorkflow } from '../workflow/configure-class-workflow'
import {
FunctionWorkflowDefinition,
isFunctionWorkflow
Expand Down Expand Up @@ -131,14 +130,8 @@ const readClassWorkflow = <TWorkflowState extends WorkflowState>(
throw new WorkflowFactoryMissing(workflowType.name)
}
const create = createWorkflow ?? (() => new workflowType())
const mapper = new WorkflowMapper<TWorkflowState, Workflow<TWorkflowState>>(
workflowType
)
create().configureWorkflow(mapper)
const workflowStateType = mapper.workflowStateCtor
if (!workflowStateType) {
throw new WorkflowStateNotProvided(workflowType.name)
}
// Read as the bus reads it, without creating the workflow
const { mapper, workflowStateType } = configureClassWorkflow(workflowType)

// A new workflow handles each message, as on a bus
const handlerOf =
Expand Down Expand Up @@ -285,6 +278,8 @@ const outgoingAttributes = (
* @param options how to create a class workflow, members of every handler's context, and when the clock starts
* @returns the scenario, with no workflow instance yet
* @throws WorkflowStateNotProvided if a class workflow doesn't declare its state with `mapper.withState()`
* @throws WorkflowConfigurationFailed if a class workflow's `configureWorkflow()` throws, which is called without
* creating the workflow, as on a bus
* @throws WorkflowFactoryMissing if a class workflow's constructor takes arguments and `createWorkflow` isn't given
* @example
* const scenario = testWorkflow(orderWorkflow)
Expand Down
Loading
Loading