Skip to content
Open
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
23 changes: 23 additions & 0 deletions .changeset/workflow-factory.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@tanstack/workflow-core': minor
---

Add `createWorkflowFactory` for sharing middleware and step-retry defaults across a family of workflows.

```ts
import { createWorkflowFactory } from '@tanstack/workflow-core'

const appWorkflow = createWorkflowFactory({
defaultStepRetry: { maxAttempts: 3 },
}).middleware([traced, requireUser])

const onboard = appWorkflow({ id: 'onboard' })
.middleware([requireEmailVerified]) // appended after factory mws
.handler(async (ctx) => {
/* ctx.trace, ctx.user, ctx.emailVerified */
})
```

Factory middleware runs before per-workflow middleware; ctx extensions accumulate across both layers. Per-workflow config wins over factory defaults. `appWorkflow.extend({ ... })` forks a child factory with override defaults without mutating the parent.

See [docs/concepts/middleware.md](https://github.com/TanStack/workflow/blob/main/docs/concepts/middleware.md#recipe-share-middleware-across-a-family-of-workflows).
29 changes: 29 additions & 0 deletions docs/concepts/middleware.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,38 @@ async function sendReceipt(

Pass the typed `ctx` to the helper — the constraint documents which middleware fields must be in scope.

## Recipe: share middleware across a family of workflows

Use `createWorkflowFactory` to pin shared middleware and defaults so every workflow in an app gets them without repeating `.middleware([...])` in each file.

```ts
import { createWorkflowFactory } from '@tanstack/workflow-core'

export const appWorkflow = createWorkflowFactory({
defaultStepRetry: { maxAttempts: 3 },
}).middleware([traced, requireUser])

export const onboard = appWorkflow({ id: 'onboard' })
.middleware([requireEmailVerified]) // appended after factory mws
.handler(async (ctx) => {
ctx.trace; ctx.user; ctx.emailVerified // all visible
})
```

Factory middleware runs first, then per-workflow middleware, then the handler. Per-workflow config wins over factory defaults when both are set.

Fork a child factory with `.extend()` to layer extra middleware (and optionally override defaults) without mutating the parent:

```ts
export const paidWorkflow = appWorkflow
.extend({ defaultStepRetry: { maxAttempts: 5 } })
.middleware([requirePro])
```

## Rules

- `.middleware([a, b])` runs `a` first, then `b`, then the handler.
- Factory middleware (`createWorkflowFactory().middleware([...])`) runs before any per-workflow middleware.
- Each middleware must call `next()` exactly once. Twice throws `RUN_ERRORED`.
- Middleware extensions cannot shadow reserved ctx fields (`input`, `state`, `runId`, `signal`, `step`, `sleep`, `sleepUntil`, `waitForEvent`, `approve`, `now`, `uuid`, `emit`). Type system rejects them; runtime guards too.

Expand Down
18 changes: 18 additions & 0 deletions docs/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,24 @@ const wf = createWorkflow({ id: 'send-receipt' })

Specify the extension type as the generic on `.server<...>` — TS infers everything else.

## Recipe: factory for shared middleware

When several workflows want the same middleware + step-retry defaults, pin them with `createWorkflowFactory`:

```ts
import { createWorkflowFactory } from '@tanstack/workflow-core'

export const appWorkflow = createWorkflowFactory({
defaultStepRetry: { maxAttempts: 3 },
}).middleware([traced, requireUser])

export const onboard = appWorkflow({ id: 'onboard' })
.middleware([requireEmailVerified]) // appended after factory mws
.handler(async (ctx) => { /* ctx.trace, ctx.user, ctx.emailVerified */ })
```

Factory middleware runs before per-workflow middleware. Per-workflow config wins over factory defaults. Use `appWorkflow.extend({ ... })` to fork a child factory with override defaults.

## Recipe: cross-version resume

```ts
Expand Down
2 changes: 1 addition & 1 deletion packages/workflow-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ for await (const event of runWorkflow({
| `ctx.now()` / `ctx.uuid()` | `Promise<number / string>` | deterministic recorded values |
| `ctx.emit(name, value)` | `void` | observability-only custom event |

Middleware can add more.
Middleware can add more. Use [`createWorkflowFactory`](../../docs/concepts/middleware.md#recipe-share-middleware-across-a-family-of-workflows) to pin shared middleware and step-retry defaults across a family of workflows.

## Pause and resume

Expand Down
131 changes: 131 additions & 0 deletions packages/workflow-core/src/define/create-workflow-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { createWorkflow } from './define-workflow'
import type {
AccumulateExtensions,
CreateWorkflowConfig,
WorkflowBuilder,
} from './define-workflow'
import type { AnyMiddleware, SchemaInput, StepRetryOptions } from '../types'

// ============================================================
// Public configuration shape
// ============================================================

/**
* Defaults a factory pre-sets on every workflow it produces.
* Per-workflow config wins when both are present.
*/
export interface CreateWorkflowFactoryConfig {
/** Default retry policy applied to every `ctx.step()` call that
* doesn't carry its own `{ retry }` option. */
defaultStepRetry?: StepRetryOptions
}

// ============================================================
// Builder type — callable hybrid with chain methods
// ============================================================

/**
* A workflow factory. Calling it returns a `WorkflowBuilder` with
* the factory's middlewares pre-applied and config defaults merged.
* Chain `.middleware([...])` to layer in shared middleware;
* `.extend()` to fork a child factory without mutating the parent.
*/
export interface WorkflowFactoryBuilder<TCtxExt = unknown> {
/**
* Append middlewares to the factory. They run *before* any
* middleware registered on the produced workflow, in declaration
* order, then the workflow's own middlewares, then the handler.
*/
middleware: <const TMiddlewares extends ReadonlyArray<AnyMiddleware>>(
middlewares: TMiddlewares,
) => WorkflowFactoryBuilder<TCtxExt & AccumulateExtensions<TMiddlewares>>

/**
* Fork a child factory with the same middlewares + defaults.
* Pass overrides to shallow-merge new defaults onto the child.
* The parent is not mutated.
*/
extend: (
overrides?: CreateWorkflowFactoryConfig,
) => WorkflowFactoryBuilder<TCtxExt>;

/**
* Produce a workflow builder with the factory's state applied.
* The returned builder still supports `.middleware([...])` for
* per-workflow middlewares whose ctx extensions are appended to
* the factory's.
*/
<
TInputSchema extends SchemaInput | undefined = undefined,
TOutputSchema extends SchemaInput | undefined = undefined,
TStateSchema extends SchemaInput | undefined = undefined,
>(
config: CreateWorkflowConfig<TInputSchema, TOutputSchema, TStateSchema>,
): WorkflowBuilder<TInputSchema, TOutputSchema, TStateSchema, TCtxExt>
}

// ============================================================
// Implementation
// ============================================================

interface InternalFactoryState {
middlewares: ReadonlyArray<AnyMiddleware>
defaults: CreateWorkflowFactoryConfig
}

function buildFactory(
state: InternalFactoryState,
): WorkflowFactoryBuilder<any> {
const factory = ((config: CreateWorkflowConfig<any, any, any>) => {
const merged: CreateWorkflowConfig<any, any, any> = {
...config,
defaultStepRetry:
config.defaultStepRetry ?? state.defaults.defaultStepRetry,
}
const base = createWorkflow(merged)
return state.middlewares.length > 0
? base.middleware(state.middlewares)
: base
}) as WorkflowFactoryBuilder<any>

factory.middleware = ((middlewares: ReadonlyArray<AnyMiddleware>) =>
buildFactory({
...state,
middlewares: [...state.middlewares, ...middlewares],
})) as WorkflowFactoryBuilder<any>['middleware']

factory.extend = (overrides) =>
buildFactory({
middlewares: [...state.middlewares],
defaults: { ...state.defaults, ...overrides },
})

return factory
}

/**
* Build a workflow factory. Use it to pin shared middleware and
* defaults across a family of workflows:
*
* export const appWorkflow = createWorkflowFactory({
* defaultStepRetry: { maxAttempts: 3 },
* }).middleware([traced, requireUser])
*
* export const onboard = appWorkflow({ id: 'onboard' })
* .middleware([requireEmailVerified]) // appended after factory mws
* .handler(async (ctx) => {
* ctx.trace; ctx.user; ctx.emailVerified // all visible
* })
*
* Factories compose. Derive a specialized child without mutating
* the parent — `.extend()` accepts default overrides:
*
* export const paidWorkflow = appWorkflow
* .extend({ defaultStepRetry: { maxAttempts: 5 } })
* .middleware([requirePro])
*/
export function createWorkflowFactory(
config: CreateWorkflowFactoryConfig = {},
): WorkflowFactoryBuilder<unknown> {
return buildFactory({ middlewares: [], defaults: config })
Comment on lines +127 to +130
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Defensively copy initial config to avoid mutation leaks.

Line 130 stores the caller’s config object by reference. If that object is mutated later, existing factories can change behavior unexpectedly.

Suggested fix
 export function createWorkflowFactory(
   config: CreateWorkflowFactoryConfig = {},
 ): WorkflowFactoryBuilder<unknown> {
-  return buildFactory({ middlewares: [], defaults: config })
+  return buildFactory({ middlewares: [], defaults: { ...config } })
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export function createWorkflowFactory(
config: CreateWorkflowFactoryConfig = {},
): WorkflowFactoryBuilder<unknown> {
return buildFactory({ middlewares: [], defaults: config })
export function createWorkflowFactory(
config: CreateWorkflowFactoryConfig = {},
): WorkflowFactoryBuilder<unknown> {
return buildFactory({ middlewares: [], defaults: { ...config } })
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/workflow-core/src/define/create-workflow-factory.ts` around lines
127 - 130, createWorkflowFactory currently passes the caller's config object by
reference to buildFactory (defaults: config), risking mutation leaks; fix this
by defensively copying the config before passing it (e.g., create a shallow copy
of CreateWorkflowFactoryConfig and pass that to buildFactory) so
createWorkflowFactory and buildFactory use an independent defaults object and
external mutations won't affect existing factories.

}
5 changes: 5 additions & 0 deletions packages/workflow-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ export type {
CreateWorkflowConfig,
WorkflowBuilder,
} from './define/define-workflow'
export { createWorkflowFactory } from './define/create-workflow-factory'
export type {
CreateWorkflowFactoryConfig,
WorkflowFactoryBuilder,
} from './define/create-workflow-factory'

// ===== Middleware =====
export { createMiddleware } from './middleware/create-middleware'
Expand Down
Loading
Loading