first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,162 @@
# @suaveplan/workflow - Workflow / pipeline orchestration: sequential + parallel + conditional steps with saga compensation, retry, timeout, lifecycle hooks, and OTel instrumentation
Workflow and pipeline orchestration: sequential and parallel step
execution, conditional branching with guard functions, retry strategies,
timeouts, saga-pattern compensation, lifecycle hooks, and OpenTelemetry
instrumentation. Type-safe via TypeScript generics, tree-shakeable, and
zero external runtime dependencies beyond `@suaveplan/error`,
`@suaveplan/events`, `@suaveplan/types`, `@opentelemetry/api`, `tslib`,
and `zod`.
<!-- begin:auto-docs -->
## Documentation tree
- [compensator](./src/compensation/compensator/compensator.md) — `Compensator` is the explicit, registry-based rollback engine used by `@suaveplan/workflow`. The workflow engine and th…
- [compensator](./src/compensation/compensator/compensator.md) — `Compensator` is the explicit, registry-based rollback engine used by `@suaveplan/workflow`. The workflow engine and th…
- [saga](./src/compensation/saga/saga.md) — `Saga` is the dedicated, opinionated orchestrator for the textbook saga pattern: sequential business steps where every…
- [saga](./src/compensation/saga/saga.md) — `Saga` is the dedicated, opinionated orchestrator for the textbook saga pattern: sequential business steps where every…
- [context](./src/core/context/context.md) — `WorkflowContext` is the per-run state container that the workflow engine threads through every step. It owns the workf…
- [context](./src/core/context/context.md) — `WorkflowContext` is the per-run state container that the workflow engine threads through every step. It owns the workf…
- [workflow-builder](./src/core/workflow-builder/workflow-builder.md) — `WorkflowBuilder` is the fluent author-facing API for assembling workflows. Most consumers use it to chain together ste…
- [workflow-builder](./src/core/workflow-builder/workflow-builder.md) — `WorkflowBuilder` is the fluent author-facing API for assembling workflows. Most consumers use it to chain together ste…
- [workflow-engine](./src/core/workflow-engine/workflow-engine.md) — `WorkflowEngine` is the runtime orchestrator at the centre of `@suaveplan/workflow`. It accepts a Zod-validated `Workfl…
- [transition-hooks](./src/core/workflow-engine/transition-hooks.md) — `TransitionHooks` is the internal data-pipeline registry the `WorkflowEngine` uses to expose the `workflow:transitionin…
- [workflow-engine](./src/core/workflow-engine/workflow-engine.md) — `WorkflowEngine` is the runtime orchestrator at the centre of `@suaveplan/workflow`. It accepts a Zod-validated `Workfl…
- [errors](./src/errors/workflow-error.md) — `@suaveplan/workflow` raises a single typed error class — `WorkflowError` — for every failure mode the engine can surfa…
- [workflow-error](./src/errors/workflow-error.md) — `@suaveplan/workflow` raises a single typed error class — `WorkflowError` — for every failure mode the engine can surfa…
- [hook](./src/middleware/hook/hook.md) — `HookRegistry` is the lifecycle-event dispatcher for `@suaveplan/workflow`. It is the seam between the engine's interna…
- [hook](./src/middleware/hook/hook.md) — `HookRegistry` is the lifecycle-event dispatcher for `@suaveplan/workflow`. It is the seam between the engine's interna…
- [retry](./src/middleware/retry/retry.md) — `withRetry` is the standalone, reusable retry executor that `@suaveplan/workflow` exposes alongside the `Step.retry` co…
- [retry](./src/middleware/retry/retry.md) — `withRetry` is the standalone, reusable retry executor that `@suaveplan/workflow` exposes alongside the `Step.retry` co…
- [otel](./src/otel/instruments.md) — This module is the only place in `@suaveplan/workflow` that talks to the OpenTelemetry API directly. Every other module…
- [instruments](./src/otel/instruments.md) — This module is the only place in `@suaveplan/workflow` that talks to the OpenTelemetry API directly. Every other module…
- [conditional-step](./src/steps/conditional-step/conditional-step.md) — `ConditionalStep` is the branching primitive in `@suaveplan/workflow`. It evaluates a `GuardFn` against the context and…
- [conditional-step](./src/steps/conditional-step/conditional-step.md) — `ConditionalStep` is the branching primitive in `@suaveplan/workflow`. It evaluates a `GuardFn` against the context and…
- [parallel-step](./src/steps/parallel-step/parallel-step.md) — `ParallelStep` groups multiple `Step` instances and runs them concurrently against a shared context. It is the only pla…
- [parallel-step](./src/steps/parallel-step/parallel-step.md) — `ParallelStep` groups multiple `Step` instances and runs them concurrently against a shared context. It is the only pla…
- [step](./src/steps/step/step.md) — `Step` is the atomic unit of execution in `@suaveplan/workflow`. Every sequential branch of a workflow ultimately runs…
- [step](./src/steps/step/step.md) — `Step` is the atomic unit of execution in `@suaveplan/workflow`. Every sequential branch of a workflow ultimately runs…
- [common](./src/types/common.md) — This module is the canonical home for every shared structural type in `@suaveplan/workflow`. The `WorkflowEngine`, buil…
- [workflow-hooks](./src/types/workflow-hooks.md) — `@suaveplan/workflow` exposes two distinct hook surfaces. The first is the fire-and-forget `HookRegistry` (`workflow:st…
- [utils](./src/utils/utils.md) — Pure helpers shared across every other module in `@suaveplan/workflow`. Centralising them here keeps `Step`, the engine…
- [utils](./src/utils/utils.md) — Pure helpers shared across every other module in `@suaveplan/workflow`. Centralising them here keeps `Step`, the engine…
<!-- end:auto-docs -->
## Install
```bash
bun add @suaveplan/workflow
```
## Quick Start
```ts
import { createWorkflow } from "@suaveplan/workflow";
interface OrderData {
orderId: string;
items: string[];
total: number;
}
const result = await createWorkflow<OrderData>("process-order")
.addStep("validate", (ctx) => {
if (ctx.items.length === 0) throw new Error("No items");
})
.build()
.run({
initialData: { orderId: "ord-1", items: ["widget"], total: 29.99 },
});
```
## Modules
- [Errors](./src/errors/workflow-error.md) — `WORKFLOW_CODES` and
`WorkflowError` (`STEP_FAILED`, `TIMEOUT`, `CANCELLED`,
`COMPENSATION_FAILED`, `GUARD_REJECTED`, `RETRY_EXHAUSTED`).
- [OTel instruments](./src/otel/instruments.md) — counters + histogram.
- [Types](./src/types/common.md) and [transition hooks](./src/types/workflow-hooks.md).
- [Utils](./src/utils/utils.md) — `generateId`, `delay`, `withTimeout`,
`calculateRetryDelay`, `now`, `defaultClock`.
- [Step](./src/steps/step/step.md), [Parallel step](./src/steps/parallel-step/parallel-step.md),
[Conditional step](./src/steps/conditional-step/conditional-step.md).
- [Compensator](./src/compensation/compensator/compensator.md) and
[Saga](./src/compensation/saga/saga.md).
- [Hook registry](./src/middleware/hook/hook.md) and
[Retry middleware](./src/middleware/retry/retry.md).
- [Workflow context](./src/core/context/context.md).
- [Workflow builder](./src/core/workflow-builder/workflow-builder.md).
- [Workflow engine](./src/core/workflow-engine/workflow-engine.md) and
[Transition hooks](./src/core/workflow-engine/transition-hooks.md).
## Error Handling Strategies
```ts
builder.onError("stop"); // halt immediately on first failure
builder.onError("compensate"); // run reverse-order compensations, then stop
builder.onError("continue"); // log + skip the failure, run the rest
```
## Retry Configuration
```ts
import { RetryPresets } from "@suaveplan/workflow";
builder
.addStep("flaky-api", async (ctx) => callExternalApi(ctx))
.withRetry(RetryPresets.standard());
builder.addStep("custom", (ctx) => callApi(ctx)).withRetry({
maxAttempts: 5,
strategy: "exponential",
delayMs: 500,
maxDelayMs: 10_000,
backoffMultiplier: 2,
});
```
## Lifecycle Hooks
```ts
const engine = createWorkflowEngine(config);
engine.hooks.on("workflow:start", (p) =>
console.log(`Workflow ${p.workflowId} started`),
);
engine.hooks.on("step:complete", (p) =>
console.log(`Step ${p.stepId} completed`),
);
engine.hooks.on("step:fail", (p) =>
console.error(`Step ${p.stepId} failed:`, p.error),
);
```
## Saga Pattern
```ts
import { createSaga, createStep } from "@suaveplan/workflow";
const saga = createSaga({ id: "booking", name: "Booking Saga" });
saga
.addStep(createStep({
id: "reserve",
name: "Reserve Seats",
execute: (ctx) => reserveSeats(ctx),
compensate: (ctx) => releaseSeats(ctx),
}))
.addStep(createStep({
id: "charge",
name: "Charge Payment",
execute: (ctx) => chargeCard(ctx),
compensate: (ctx) => refundCard(ctx),
}));
const result = await saga.execute(context);
// If "charge" fails, "reserve" is automatically compensated.
```
## License
MIT

View File

@@ -0,0 +1,95 @@
# compensator
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Compensator` is the explicit, registry-based rollback engine used by
`@suaveplan/workflow`. The workflow engine and the saga orchestrator both
delegate to it: every step that completes successfully and exposes a
`compensate` hook registers a closure here so a later failure can unwind
the side effects in reverse order. Surfacing the registry as its own class
(rather than burying it inside the engine) makes it usable in custom
orchestration code where the built-in engine is not the right fit.
The compensator also records every attempt, success or failure, as a
structured `CompensationRecord`. Callers can inspect the records to drive
alerts, telemetry beyond OTel, or partial-recovery resume strategies.
## Features
- `register(stepId, fn)` — append-only ordered registry.
- `compensateAll(ctx)` — runs in reverse registration order.
- `compensateSteps(ctx, ids)` — runs only the matching ids, still reverse.
- `continueOnError` (default `true`) — toggle whether a thrown
compensation halts the rest.
- `CompensationRecord` history with timestamps via injected `Clock`.
- OTel emission on every attempt (`recordCompensation`).
- `clear()` to reuse the same instance across runs (used by `Saga`).
- `hasFailures` quick-check.
## Usage: Basic Example
```ts
import { createCompensator } from "@suaveplan/workflow";
const comp = createCompensator<{ orderId: string }>();
comp.register("charge", async (ctx) => refundCharge(ctx.orderId));
comp.register("ship", async (ctx) => recallShipment(ctx.orderId));
await comp.compensateAll({ orderId: "ord-42" });
// ship.compensate runs first, then charge.compensate.
```
## Usage: Advanced Example
```ts
import { createCompensator } from "@suaveplan/workflow";
const comp = createCompensator<{ id: string }>({
continueOnError: false,
clock: () => 0,
});
comp.register("a", () => {
throw new Error("a failed");
});
comp.register("b", () => undefined);
await comp.compensateAll({ id: "x" });
// Records contain a single entry for "b" with status "compensated"
// then the loop breaks because continueOnError is false.
if (comp.hasFailures === false) {
// Re-attempt the failed compensations later.
}
```
## API Reference
- `CompensationRecord` — `{ stepId; status; error?; timestamp; }`.
- `CompensatorOptions` — `{ continueOnError?; clock?; }`.
- `Compensator<TCtx>` —
- `size`, `records`, `hasFailures` accessors
- `register(stepId, fn): void`
- `compensateAll(ctx): Promise<ReadonlyArray<CompensationRecord>>`
- `compensateSteps(ctx, ids): Promise<ReadonlyArray<CompensationRecord>>`
- `clear(): void`
- `createCompensator<TCtx>(options?): Compensator<TCtx>`.
## Implementation Notes
The internal `_runEntry` helper is shared by `compensateAll` and
`compensateSteps` to keep the failure-handling identical. It returns
`true` when the loop should break (i.e. an error was raised and
`continueOnError` is false) so the callers can short-circuit without
duplicating the predicate.
`compensateSteps` filters by id *before* reversing, so the relative order
between matching ids is preserved as the inverse of registration order —
mirroring the `compensateAll` contract.
OTel attributes carry `step_id` and `outcome` so dashboards can chart
per-step compensation success rates without re-joining with the original
step registry.

View File

@@ -0,0 +1,120 @@
# saga
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Saga` is the dedicated, opinionated orchestrator for the textbook saga
pattern: sequential business steps where every successful step registers
its inverse so that a later failure can roll back every side effect in
strict reverse order. Compared to `WorkflowEngine`, `Saga` is intentionally
narrower — it has no `onError: "continue"` mode, no transition hooks, and
no lifecycle hook registry. Callers reach for it when they want the smaller
surface area and the explicit per-step semantics.
The class owns its own `Compensator` and resets it at the start of every
`execute` call, so a single `Saga` instance can be run repeatedly without
leaking state across runs.
## Features
- `addStep(step)` fluent registration.
- Sequential execution with first-failure short-circuit.
- Automatic reverse-order compensation via the internal `Compensator`.
- `continueCompensationOnError` (default `true`) — forwards to the
compensator.
- Skipped steps recorded but not compensated.
- Injectable `Clock` propagates to the compensator too.
## Usage: Basic Example
```ts
import { createSaga, createStep } from "@suaveplan/workflow";
const saga = createSaga<{ orderId: string }>({
id: "booking",
name: "Booking saga",
});
saga
.addStep(
createStep({
id: "reserve",
name: "Reserve seats",
execute: (ctx) => reserveSeats(ctx),
compensate: (ctx) => releaseSeats(ctx),
}),
)
.addStep(
createStep({
id: "charge",
name: "Charge payment",
execute: (ctx) => chargeCard(ctx),
compensate: (ctx) => refundCard(ctx),
}),
);
const result = await saga.execute({ orderId: "ord-1" });
```
## Usage: Advanced Example
```ts
import { createSaga, createStep } from "@suaveplan/workflow";
const fakeClock = () => 1_000;
const saga = createSaga<{ id: string }>({
id: "two-phase",
name: "Two-phase commit",
continueCompensationOnError: false,
clock: fakeClock,
});
saga.addStep(
createStep({
id: "lock",
name: "Lock resource",
execute: (ctx) => acquireLock(ctx.id),
compensate: (ctx) => releaseLock(ctx.id),
}),
);
saga.addStep(
createStep({
id: "commit",
name: "Commit",
execute: () => {
throw new Error("conflict");
},
}),
);
const result = await saga.execute({ id: "row-7" });
expect(result.status).toBe("failed");
expect(result.compensationRecords.length).toBe(1);
```
## API Reference
- `SagaResult<TCtx>` — `{ sagaId; status; stepResults; compensationRecords; startedAt; completedAt; error?; }`.
- `SagaOptions` — `{ id; name; continueCompensationOnError?; clock?; }`.
- `Saga<TCtx>` —
- `size`, `steps` accessors
- `addStep(step)` returns `this`
- `execute(context): Promise<SagaResult<TCtx>>`
- `createSaga<TCtx>(options): Saga<TCtx>`.
## Implementation Notes
The compensator is rebuilt at construction time and `clear()`-ed at the
start of every `execute`, so the saga is genuinely reusable. The internal
`extractStepError` helper guarantees that a failed step always surfaces a
real `Error` instance even if the underlying `StepResult.error` was
absent — that case is rare (a step that returns `status: "failed"` without
populating `error` is technically allowed by the type) but the saga has to
handle it cleanly.
Skipped steps do NOT register a compensation; this preserves the saga
contract that compensations correspond strictly to side effects that
happened, not to side effects that were considered.

View File

@@ -0,0 +1,109 @@
# context
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkflowContext` is the per-run state container that the workflow engine
threads through every step. It owns the workflow id, the mutable data
payload, the current status, the accumulated step results, and a free-form
metadata key/value store. The engine sets the status as it transitions
between phases; step `execute` functions read and mutate `data`; observers
consult `stepResults` to see what has happened so far.
Snapshots (`snapshot()`) are point-in-time, deep-shallow copies that are
safe to ship to listeners or store as audit history without worrying about
the live context mutating beneath the caller.
## Features
- Mutable `data` with `updateData(partial)` shallow merge and
`setData(next)` full replace.
- `status` setter for engine-driven transitions.
- `addStepResult` / `getStepResult(stepId)` accumulators.
- `setMeta` / `getMeta<T>` metadata store (workflow-instance-scoped, not
per-step).
- `snapshot()` produces a `ContextSnapshot<TData>` whose every nested
collection is a shallow clone.
- `reset(next)` returns the context to the initial state with new data so
the same instance can be reused (e.g. inside a paused/resumed engine).
## Usage: Basic Example
```ts
import { createContext } from "@suaveplan/workflow";
interface OrderData {
orderId: string;
total: number;
}
const ctx = createContext<OrderData>("wf-1", {
orderId: "ord-1",
total: 9.99,
});
ctx.setStatus("running");
ctx.updateData({ total: 14.99 });
```
## Usage: Advanced Example
```ts
import { createContext } from "@suaveplan/workflow";
const ctx = createContext("wf-1", { user: "alice" });
ctx.setMeta("traceId", "00-abc-def");
ctx.addStepResult({
stepId: "validate",
status: "completed",
startedAt: 0,
completedAt: 1,
attempts: 1,
});
const before = ctx.snapshot();
ctx.updateData({ user: "bob" });
ctx.addStepResult({
stepId: "charge",
status: "completed",
startedAt: 1,
completedAt: 2,
attempts: 1,
});
console.log(before.data.user); // "alice" — snapshot is unaffected
console.log(before.stepResults.length); // 1
console.log(ctx.stepResults.length); // 2
```
## API Reference
- `ContextSnapshot<TData>` —
`{ workflowId; status; data; stepResults; metadata; }` (all readonly).
- `WorkflowContext<TData extends Record<string, unknown>>` —
- `readonly workflowId: string`
- Accessors: `status`, `data`, `stepResults`, `metadata`
- `setStatus(s): void`
- `updateData(p: Partial<TData>): void`
- `setData(d: TData): void`
- `addStepResult(r: StepResult): void`
- `setMeta(k, v): void`, `getMeta<T>(k): T | undefined`
- `getStepResult(stepId): StepResult | undefined`
- `snapshot(): ContextSnapshot<TData>`
- `reset(data: TData): void`
- `createContext<TData>(workflowId, initialData): WorkflowContext<TData>`.
## Implementation Notes
`reset` deletes individual metadata keys (rather than reassigning a fresh
record) because the `_metadata` field is `readonly` to outside callers and
re-binding it would require widening the field type. The deletion loop
guarantees the same object identity, which matters for any consumer that
held a reference to the metadata bag.
`snapshot` is intentionally a shallow clone of arrays and objects rather
than a deep clone — deep cloning arbitrary `TData` would either need a
library dependency or `structuredClone`, neither of which is justified by
the typical use case (small, plain-data context shapes).

View File

@@ -0,0 +1,98 @@
# workflow-builder
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkflowBuilder` is the fluent author-facing API for assembling workflows.
Most consumers use it to chain together step declarations rather than
hand-construct a `WorkflowConfig` object — the builder takes care of id
generation, default retry shapes, and `WorkflowEngine` instantiation in
one call. It is paired with `StepBuilder`, which is the per-step
configuration helper returned by `addStep` and is the only place callers
opt into compensation, guards, retry, and per-step timeouts.
The builder also funnels hook registrations through `.on(event, listener)`
so callers can subscribe to lifecycle events before the engine is built.
## Features
- `createWorkflow(name, options?)` factory.
- `addStep(name, execute, id?) → StepBuilder`.
- `StepBuilder.withCompensation / withGuard / withRetry / withTimeout`.
- `WorkflowBuilder.withName / withTimeout / onError / on`.
- `buildConfig()` returns the typed `WorkflowConfig`.
- `build()` returns the engine; `run(options)` builds-and-runs.
- Optional injectable `Clock` used for both id generation and the engine.
## Usage: Basic Example
```ts
import { createWorkflow } from "@suaveplan/workflow";
const result = await createWorkflow<{ orderId: string }>("checkout")
.addStep("validate", (ctx) => {
if (!ctx.orderId) throw new Error("missing");
})
.build()
.run({ initialData: { orderId: "ord-1" } });
```
## Usage: Advanced Example
```ts
import { createWorkflow, RetryPresets } from "@suaveplan/workflow";
const engine = createWorkflow<{ user: string }>("onboarding", {
clock: () => 1_700_000_000_000,
})
.withTimeout(30_000)
.onError("compensate")
.on("step:fail", (p) => console.error("failed:", p.stepId));
engine
.addStep("create", (ctx) => createAccount(ctx.user))
.withCompensation((ctx) => deleteAccount(ctx.user))
.withRetry({ maxAttempts: 2, strategy: "exponential" });
engine
.addStep("notify", (ctx) => sendWelcome(ctx.user))
.withGuard((ctx) => ctx.user.startsWith("@"))
.withTimeout(5_000);
const result = await engine.run({ initialData: { user: "@alice" } });
```
## API Reference
- `StepBuilder<TData>` —
- `withCompensation(fn): this`
- `withGuard(fn): this`
- `withRetry(partial): this`
- `withTimeout(ms): this`
- `build(): StepDefinition<TData>` (retry config is filled with defaults
if `withRetry` was called).
- `WorkflowBuilderOptions` — `{ id?; clock?; }`.
- `WorkflowBuilder<TData>` —
- `withName(name): this`, `withTimeout(ms): this`,
`onError("stop"|"compensate"|"continue"): this`
- `addStep(name, execute, id?): StepBuilder<TData>`
- `on(event, listener): this`
- `buildConfig(): WorkflowConfig<TData>`
- `build(): WorkflowEngine<TData>`
- `run(options): Promise<WorkflowResult<TData>>`
- `createWorkflow<TData>(name, options?): WorkflowBuilder<TData>`.
## Implementation Notes
`StepBuilder.build()` only fills retry defaults when `withRetry` was
called. If the caller never invokes `withRetry`, the resulting
`StepDefinition.retry` is `undefined`, which signals to `Step` that no
retry should run — distinct from "retry with defaults", which is what
`RetryPresets.none()` is for.
`WorkflowBuilder.build()` forwards its captured `Clock` into the engine's
`WorkflowEngineOptions`, so a clock injected at the builder level is the
single source of truth for timing throughout the run.

View File

@@ -0,0 +1,72 @@
# transition-hooks
## Purpose
`TransitionHooks` is the internal data-pipeline registry the
`WorkflowEngine` uses to expose the `workflow:transitioning` hook to its
callers. It mirrors the upstream `@suaveplan/events` `Hooks<TMap>` surface
(suaveplan's `@suaveplan/events` package does not yet export `Hooks`) so the
engine's public `transitionHooks` property keeps the same ergonomics
callers expect from upstream: a sequential chain of handlers where each
handler may return a transformed payload or `void` to pass through.
Keeping this class internal to the engine (rather than exporting it as a
general-purpose utility) is intentional — it has a single consumer, and
broadening it to other workflows would just duplicate functionality that
the broader `Hooks` class in upstream already provides.
## Features
- `hook(handler)` registration returning an unsubscribe.
- `applyHooksAsync(event, data)` sequential async chain.
- Handlers may return a transformed `WorkflowTransitionData` or `void`.
- `handlerCount` accessor for assertions / testing.
## Usage: Basic Example
```ts
import { createWorkflowEngine } from "@suaveplan/workflow";
const engine = createWorkflowEngine({ id: "w", name: "n", steps: [] });
engine.transitionHooks.hook((data) => {
console.log("entering", data.stepName);
});
```
## Usage: Advanced Example
```ts
const engine = createWorkflowEngine<{ region: string }>({
id: "w",
name: "checkout",
steps,
});
const unsubscribe = engine.transitionHooks.hook(async (data) => {
if (data.context.region === "EU") {
return {
...data,
context: { ...data.context, region: "EU-norm" },
};
}
});
await engine.run({ initialData: { region: "EU" } });
unsubscribe();
```
## API Reference
- `TransitionHooks<TData extends Record<string, unknown>>` —
- `hook(handler: WorkflowTransitioningHandler<TData>): () => void`
- `applyHooksAsync(event, data): Promise<WorkflowTransitionData<TData>>`
- `handlerCount: number`
## Implementation Notes
The chain stops returning `current` instead of recursively cloning the
data, so callers that prefer mutate-in-place handlers keep working. The
`event` parameter is accepted for API parity with the upstream `Hooks`
class even though the suaveplan implementation only handles the single
`workflow:transitioning` topic — keeping the signature future-proof for a
later migration that maps multiple topics into the engine.

View File

@@ -0,0 +1,127 @@
# workflow-engine
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`WorkflowEngine` is the runtime orchestrator at the centre of
`@suaveplan/workflow`. It accepts a Zod-validated `WorkflowConfig`,
materialises each step definition into a `Step` instance, and drives them
sequentially through their lifecycle: guard evaluation, optional
transition-hook chain, step start, execution, and step completion (or
failure / skip). The engine owns the `HookRegistry`, the data-pipeline
`TransitionHooks`, the workflow-level timeout, the cancellation flag, and
the compensator that ships with the `onError: "compensate"` strategy.
The engine is also the only call site that emits `WorkflowError` with the
`STEP_FAILED`, `TIMEOUT`, and `CANCELLED` codes, so any consumer wiring
up alerting can rely on the engine as the single point of truth for those
codes.
## Features
- Sequential execution with three error strategies (`stop`, `compensate`,
`continue`).
- Configurable workflow-level timeout (default 60s).
- Cancellation between steps via `engine.cancel()`.
- Transition-pipeline hook (`workflow:transitioning`) that may mutate the
per-step context handed to `Step.run`.
- Hook registry for fire-and-forget lifecycle events.
- Zod boundary validation of `WorkflowConfig` shape.
- OTel emission via `Step` and the engine's own `recordStepExecuted` skip
emission.
- Injectable `Clock` propagated to every owned subsystem.
## Usage: Basic Example
```ts
import { createWorkflowEngine } from "@suaveplan/workflow";
const engine = createWorkflowEngine<{ orderId: string }>({
id: "wf-1",
name: "checkout",
onError: "stop",
steps: [
{
id: "validate",
name: "Validate",
execute: (ctx) => {
if (!ctx.orderId) throw new Error("missing");
},
},
],
});
const result = await engine.run({ initialData: { orderId: "ord-1" } });
```
## Usage: Advanced Example
```ts
import { createWorkflowEngine } from "@suaveplan/workflow";
const engine = createWorkflowEngine<{ user: string }>({
id: "wf-1",
name: "onboarding",
timeout: 30_000,
onError: "compensate",
steps: [
{
id: "create-account",
name: "Create account",
execute: (ctx) => createAccount(ctx.user),
compensate: (ctx) => deleteAccount(ctx.user),
},
{
id: "send-welcome",
name: "Send welcome email",
execute: (ctx) => sendWelcome(ctx.user),
},
],
});
engine.hooks.on("step:fail", (p) => {
console.error("step failed", p.stepId, p.error?.message);
});
engine.transitionHooks.hook((data) => {
return { ...data, context: { ...data.context, user: data.context.user.trim() } };
});
const run = engine.run({ initialData: { user: " alice " } });
// e.g. from a UI button:
// engine.cancel();
const result = await run;
```
## API Reference
- `workflowConfigSchema` — Zod schema asserting the engine's input shape.
- `RunOptions<TData>` — `{ initialData: TData; workflowId?: string; }`.
- `WorkflowEngineOptions` — `{ clock?: Clock }`.
- `WorkflowEngine<TData>` constructor
`(config, hooks?, options?)`:
- Accessors: `hooks`, `transitionHooks`, `config`
- `cancel(): void`
- `run(options): Promise<WorkflowResult<TData>>`
- `createWorkflowEngine(config, hooks?, options?)`.
## Implementation Notes
The engine wraps the entire step loop in `withTimeout(..., timeout)`. When
the timeout fires before the loop finishes, the helper rejects with a
plain `Error` whose message contains `"timed out"`; the catch arm detects
that string and translates it into a typed `WorkflowError(TIMEOUT)`. This
keeps the surface plain for callers who don't care about the typed code
while still satisfying the registry's reachability gate.
`extractStepError` always emits `WorkflowError(STEP_FAILED)` whose `cause`
is the original step error, so callers walking the cause chain still see
the real underlying error.
The `workflow:cancel` emit happens during the loop *and* the status is
re-set to `"cancelled"` after the loop returns; the second assignment is
a guard for the rare case where the loop returns immediately because the
flag was set before the first iteration.

View File

@@ -0,0 +1,83 @@
# workflow-error
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`@suaveplan/workflow` raises a single typed error class — `WorkflowError` — for
every failure mode the engine can surface. Codes are registered via the
`defineCodes` helper from `@suaveplan/error`, giving callers a stable
fully-qualified string (e.g. `"WORKFLOW.TIMEOUT"`) that they can pattern-match
on without `instanceof` chains. This contract is what consumers wire into
retry, alerting, and i18n layers and is therefore the spine of the package's
public surface.
The migration replaces upstream's plain `Error` throws with this
structured form so downstream packages (alerting, dashboards, audit log) get
the same OTel/i18n hooks as every other `@suaveplan/*` library.
## Features
- Stable code registry: `STEP_FAILED`, `TIMEOUT`, `CANCELLED`,
`COMPENSATION_FAILED`, `GUARD_REJECTED`, `RETRY_EXHAUSTED`.
- Extends `SuaveplanError<WorkflowCode>` — gets cause chain, redaction, OTel
span-event adapter, i18n resolver hook for free.
- Type-safe `code` discriminator; no `instanceof` required.
## Usage: Basic Example
```ts
import { WORKFLOW_CODES, WorkflowError } from "@suaveplan/workflow/errors";
throw new WorkflowError(WORKFLOW_CODES.TIMEOUT, 'Workflow "checkout" timed out');
```
## Usage: Advanced Example
```ts
import { WORKFLOW_CODES, WorkflowError } from "@suaveplan/workflow/errors";
try {
await engine.run({ initialData });
} catch (error) {
if (error instanceof WorkflowError) {
switch (error.code) {
case WORKFLOW_CODES.TIMEOUT:
metrics.record("workflow.timeout");
break;
case WORKFLOW_CODES.COMPENSATION_FAILED:
alerts.fire({ severity: "critical", error });
break;
default:
logger.error("workflow failed", { code: error.code });
}
}
}
```
## API Reference
- `WORKFLOW_CODES` — frozen object whose values are the fully-qualified code
strings (`"WORKFLOW.STEP_FAILED"`, etc.). Returned by `defineCodes`.
- `WorkflowCode` — string-literal union of every value of `WORKFLOW_CODES`.
- `WorkflowError extends SuaveplanError<WorkflowCode>` —
`constructor(code, message, options?)` where `options` is
`{ cause?: Error; context?: Record<string, unknown> }`.
## Implementation Notes
The registry is declared via `defineCodes("WORKFLOW", [...] as const)` which
asserts the array is a literal tuple and produces a `Record<Key, "WORKFLOW.Key">`
mapping. Adding a code anywhere except the tuple breaks both the type and the
runtime registry — the type-system guarantees the two stay in sync.
Each code corresponds to a real production code path: `STEP_FAILED` is raised
from inside `Step.run`'s retry-exhausted branch (when callers explicitly opt
into structured errors) and from the engine's failure-emission helper;
`TIMEOUT` from `withTimeout` and the workflow-level timeout in
`WorkflowEngine.run`; `CANCELLED` from the engine's cancel path;
`COMPENSATION_FAILED` from `Compensator.compensateAll` when an entry throws;
`GUARD_REJECTED` from the guard-failure helper; `RETRY_EXHAUSTED` is reserved
for `withRetry` when `shouldRetry` exhausts every attempt.

View File

@@ -0,0 +1,108 @@
# hook
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`HookRegistry` is the lifecycle-event dispatcher for `@suaveplan/workflow`.
It is the seam between the engine's internals and any observer that needs
to react to workflow or step transitions (logging, OpenTelemetry spans
that aren't built in, push-notifications, audit trails). It deliberately
keeps the surface to four operations — `on`, `once`, `off`, `emit` — so
the engine and its consumers depend on a stable contract.
Every other module in the package emits *through* this class rather than
acquiring its own listeners-map, so the registry is the canonical extension
point for downstream tooling.
## Features
- Per-topic listener registration with insertion-order preservation.
- Returned unsubscribe function from `on` for ergonomic cleanup.
- `once(event, listener)` auto-unregisters after the first emit.
- `off(event?)` removes one topic or every topic.
- Sequential async `emit` — handlers may be async; the next handler waits.
- `listenerCount`, `events`, `totalListeners` introspection accessors.
- Injectable `Clock` controls the `timestamp` on emitted payloads.
## Usage: Basic Example
```ts
import { createHookRegistry } from "@suaveplan/workflow";
const hooks = createHookRegistry<{ orderId: string }>();
hooks.on("workflow:start", (p) => {
console.log("starting", p.workflowId);
});
await hooks.emit("workflow:start", {
workflowId: "wf-1",
context: { orderId: "ord-1" },
});
```
## Usage: Advanced Example
```ts
import {
buildStepPayload,
createHookRegistry,
} from "@suaveplan/workflow";
const hooks = createHookRegistry<{ user: string }>();
const unsubscribe = hooks.on("step:fail", async (p) => {
if (p.stepResult && p.stepResult.attempts >= 3) {
await pageOnCall(p.error);
}
});
hooks.once("workflow:complete", (p) => {
console.log("done", p.workflowId, p.timestamp);
});
await hooks.emit("step:fail", buildStepPayload(
"wf-1",
{ user: "alice" },
"step-1",
{
stepId: "step-1",
status: "failed",
startedAt: 0,
completedAt: 100,
attempts: 3,
},
new Error("boom"),
));
unsubscribe();
```
## API Reference
- `HookRegistry<TCtx>` (constructor takes optional `{ clock }`) —
- `on(event, listener): () => void` — returns unsubscribe
- `once(event, listener): void`
- `off(event?): void`
- `emit(event, data): Promise<void>` — fires listeners sequentially
- `listenerCount(event): number`
- `events: HookEvent[]`
- `totalListeners: number`
- `createHookRegistry<TCtx>(options?): HookRegistry<TCtx>`.
- `buildStepPayload<TCtx>(workflowId, context, stepId, stepResult?, error?): Omit<HookPayload, "event" | "timestamp">`
— assembles the fixed part of a step payload so callers do not
hand-build the shape.
## Implementation Notes
`emit` short-circuits before allocating the payload object when no
listeners are registered for the topic — workflows that run thousands of
steps benefit from this for hot topics like `step:start` / `step:complete`.
`once` wraps the user listener in a closure that unregisters before
invoking the underlying function, so even synchronous re-emits within the
listener body cannot trigger the same listener twice. The
unsubscribe-function returned from the wrapping `on` call is the cleanup
seam.

View File

@@ -0,0 +1,84 @@
# retry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`withRetry` is the standalone, reusable retry executor that
`@suaveplan/workflow` exposes alongside the `Step.retry` configuration.
Where the step's built-in retry is bound to the step's lifecycle (it only
fires inside `Step.run`), `withRetry` can wrap any async function and is
intended for the moments callers want retry semantics outside the
workflow engine — e.g. retrying a manual cleanup, retrying a request
inside a custom step that doesn't quite fit `Step`, or building higher-level
retry-with-circuit-breaker compositions.
The module also re-exports the `RetryPresets` object so callers can pick
sensible defaults (`quick`, `standard`, `aggressive`) without duplicating
the numbers in every consumer.
## Features
- `withRetry(fn, options?)` — retries with the resolved config.
- `createRetryWrapper(options?)` — reusable closure capturing options.
- `resolveRetryConfig(partial?)` — fills in defaults from
`constants/index`.
- `RetryPresets.{none, quick, standard, aggressive}` — pre-baked configs.
- Hooks: `onRetry(err, attempt)` before each backoff, `shouldRetry(err,
attempt)` to short-circuit early.
## Usage: Basic Example
```ts
import { withRetry, RetryPresets } from "@suaveplan/workflow";
const result = await withRetry(
() => fetch("/api/data").then((r) => r.json()),
RetryPresets.standard(),
);
```
## Usage: Advanced Example
```ts
import { createRetryWrapper } from "@suaveplan/workflow";
const wrap = createRetryWrapper({
maxAttempts: 4,
strategy: "exponential",
delayMs: 100,
maxDelayMs: 2_000,
shouldRetry: (err) => !(err instanceof TypeError),
onRetry: (err, attempt) => {
console.warn(`attempt ${attempt} failed:`, err.message);
},
});
const json = await wrap(() => fetch("/api/v1").then((r) => r.json()));
```
## API Reference
- `RetryOptions extends Partial<RetryConfig>` —
- `shouldRetry?: (error, attempt) => boolean`
- `onRetry?: (error, attempt) => void`
- `resolveRetryConfig(options?: Partial<RetryConfig>): RetryConfig`.
- `withRetry<T>(fn: () => T | Promise<T>, options?: RetryOptions): Promise<T>`.
- `createRetryWrapper(options?: RetryOptions): <T>(fn) => Promise<T>`.
- `RetryPresets` — `{ none, quick, standard, aggressive }`, each a function
returning a fresh `RetryConfig`.
## Implementation Notes
`withRetry` always rethrows the *last* error, not the *first*. Callers
that want the first-error semantics can capture it inside an `onRetry`
closure. The loop checks `attempt >= maxAttempts` before `shouldRetry` so
the final attempt always actually runs even if `shouldRetry` would have
returned false — the contract is "make all the attempts you promised."
`RetryPresets` are factory functions rather than constants so each call
returns a fresh object. That keeps callers from accidentally mutating a
shared preset (e.g. by spreading-and-overriding) and seeing the change
ripple through the rest of the application.

View File

@@ -0,0 +1,86 @@
# otel/instruments
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
This module is the only place in `@suaveplan/workflow` that talks to the
OpenTelemetry API directly. Every other module reaches metrics through the
`recordStepExecuted` and `recordCompensation` helpers exposed here, which
keeps OTel-specific concerns (meter resolution, attribute conventions, unit
labels) in a single audit-surface.
The instruments are registered eagerly at import time against
`metrics.getMeter("@suaveplan/workflow")`. When no SDK provider is
configured the OTel API resolves to no-op instruments, so importing this
module in unit tests is safe — nothing is exported, nothing is exfiltrated,
and the helpers degrade to cheap function calls.
## Features
- Counter `workflow.step.executed_total` with `{outcome, step_id}`.
- Counter `workflow.step.compensated_total` with `{outcome, step_id}`.
- Histogram `workflow.step.duration_ms` (unit: ms) with `{step_id}`.
- `recordStepExecuted(stepId, outcome, durationMs)` helper that fans the
attributes correctly and skips the histogram for `"skipped"` outcomes
(durations are meaningless for a guard-rejected step).
- `recordCompensation(stepId, outcome)` helper for the compensator and the
saga.
## Usage: Basic Example
```ts
import { recordStepExecuted } from "@suaveplan/workflow/otel";
recordStepExecuted("charge", "completed", 42);
```
## Usage: Advanced Example
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import { recordStepExecuted, recordCompensation } from "@suaveplan/workflow/otel";
const reader = new InMemoryMetricReader();
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
await engine.run({ initialData });
const snapshot = await reader.collect();
const stepCounter = snapshot.resourceMetrics.scopeMetrics[0].metrics.find(
(m) => m.descriptor.name === "workflow.step.executed_total",
);
expect(stepCounter?.dataPoints.length).toBeGreaterThan(0);
// Direct helpers also work outside the engine.
recordStepExecuted("custom", "failed", 17);
recordCompensation("custom", "compensated");
```
## API Reference
- `stepExecutedCounter: Counter` — exported for advanced inspection / typed
assertions in tests.
- `stepCompensatedCounter: Counter` — same.
- `stepDurationHistogram: Histogram` — same.
- `recordStepExecuted(stepId: string, outcome: "completed" | "failed" | "skipped", durationMs: number): void`
— increments the executed counter and records the duration histogram
(when the outcome is not `"skipped"`).
- `recordCompensation(stepId: string, outcome: "compensated" | "compensation-failed"): void`
— increments the compensated counter only.
## Implementation Notes
We deliberately skip the histogram emit on `"skipped"` outcomes because the
guard never enters `execute`, so any duration we recorded would be the wall
time of `shouldExecute` — a different signal from "step running time".
Consumers who want guard-evaluation timing should add their own histogram
inside the guard.
Attribute keys follow the OpenTelemetry semantic-conventions style
(snake_case) so they group cleanly alongside other `@suaveplan/*` metrics
(e.g. `step_id` matches `entity_id` in `@suaveplan/ecs`).

View File

@@ -0,0 +1,102 @@
# conditional-step
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ConditionalStep` is the branching primitive in `@suaveplan/workflow`. It
evaluates a `GuardFn` against the context and dispatches to one of two
`Step` instances (`onTrue` or `onFalse`). When the false branch is omitted
and the condition resolves falsy, the step is recorded as `"skipped"` —
giving callers a way to express "do this only if the predicate holds"
without filling in a no-op alternative.
This class is the workflow analogue of a ternary expression: it never
declares its own retry policy or compensation directly; both branches are
fully-fledged `Step` instances whose own configuration governs those
concerns. `compensate` rolls back only the branch that actually ran.
## Features
- `onTrue` / optional `onFalse` branches.
- `lastBranch` accessor reflecting which path executed.
- Skipped branch records a `"skipped"` `StepResult` and emits an OTel
`"skipped"` counter increment (the duration histogram is intentionally
skipped — see `otel/instruments`).
- `compensate(ctx)` rolls back only the branch that ran.
- Injectable `Clock`.
## Usage: Basic Example
```ts
import { createConditionalStep, createStep } from "@suaveplan/workflow";
const branch = createConditionalStep<{ premium: boolean }>({
id: "discount",
name: "Discount",
condition: (ctx) => ctx.premium,
onTrue: createStep({
id: "premium",
name: "Premium discount",
execute: () => ({ discountPct: 30 }),
}),
onFalse: createStep({
id: "standard",
name: "Standard discount",
execute: () => ({ discountPct: 5 }),
}),
});
const result = await branch.run({ premium: true });
```
## Usage: Advanced Example
```ts
import { createConditionalStep, createStep } from "@suaveplan/workflow";
const ship = createStep<{ shipped: boolean }>({
id: "ship",
name: "Ship",
execute: async (ctx) => {
await dispatch(ctx);
},
compensate: async (ctx) => {
await recall(ctx);
},
});
const guard = createConditionalStep<{ ready: boolean }>({
id: "maybe-ship",
name: "Maybe ship",
condition: (ctx) => ctx.ready,
onTrue: ship,
});
await guard.run({ ready: true });
// Later, on outer-step rollback:
await guard.compensate({ ready: true });
```
## API Reference
- `ConditionalStepOptions<TCtx>` —
`{ id; name; condition; onTrue; onFalse?; clock?; }`.
- `ConditionalStep<TCtx>` —
- `trueBranch`, `falseBranch?`, `lastBranch?`
- `run(context): Promise<StepResult>`
- `compensate(context): Promise<void>`
- `createConditionalStep<TCtx>(options): ConditionalStep<TCtx>`.
## Implementation Notes
`lastBranch` is set before the branch runs so callers that observe the
step (e.g. via hooks) can attribute downstream side-effects to the chosen
arm. `compensate` checks both the branch identity and the underlying
step's `hasCompensation` flag, so a guard whose `onTrue` step has no
compensation is a no-op rather than a thrown error.
`recordStepExecuted(stepId, "skipped", 0)` is emitted on the no-false-branch
fallthrough so the metric for skipped steps lines up with what
`WorkflowEngine` emits when a top-level `Step.shouldExecute` returns false.

View File

@@ -0,0 +1,107 @@
# parallel-step
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ParallelStep` groups multiple `Step` instances and runs them concurrently
against a shared context. It is the only place in `@suaveplan/workflow`
that introduces concurrency, which keeps the engine's main loop strictly
sequential and easy to reason about. The class supports two execution
modes: an unbounded mode (`maxConcurrency: Infinity` — default) that fans
all steps out via a single `Promise.allSettled`, and a bounded mode that
batches them in groups of `maxConcurrency`.
The group also exposes a `compensateAll` helper that walks every member
step in reverse insertion order and invokes its compensation hook, which is
how the engine and saga unwind a partially-completed parallel group on
failure.
## Features
- `addStep(step)` fluent API to build up the group after construction.
- `failFast` short-circuit between batches when any error has been observed.
- `maxConcurrency` bounded batching (when finite).
- `Promise.allSettled` semantics inside each batch so a single throw never
aborts sibling steps in the same batch.
- Guard short-circuit: a step whose `shouldExecute` returns false is
recorded as `"skipped"` without invoking `run`.
- Injectable `Clock` for deterministic timestamps.
## Usage: Basic Example
```ts
import { createParallelStep, createStep } from "@suaveplan/workflow";
const group = createParallelStep<{ url: string }>({
groupId: "fetch-group",
name: "Fetch resources",
});
group
.addStep(createStep({ id: "a", name: "A", execute: () => "a" }))
.addStep(createStep({ id: "b", name: "B", execute: () => "b" }));
const result = await group.run({ url: "https://example.com" });
```
## Usage: Advanced Example
```ts
import { createParallelStep, createStep } from "@suaveplan/workflow";
const group = createParallelStep<{ jobIds: string[] }>({
groupId: "process",
name: "Process jobs",
maxConcurrency: 2,
failFast: true,
});
for (const id of ["a", "b", "c", "d"]) {
group.addStep(
createStep({
id,
name: `process-${id}`,
execute: async (ctx) => {
if (id === "b") throw new Error("nope");
return id;
},
compensate: async () => {
await unprocess(id);
},
}),
);
}
const result = await group.run({ jobIds: [] });
if (result.status === "failed") {
await group.compensateAll({ jobIds: [] });
}
```
## API Reference
- `ParallelResult<TCtx>` — `{ groupId; status; results; startedAt; completedAt; errors; }`.
- `ParallelStepOptions` — `{ groupId; name; failFast?; maxConcurrency?; clock?; }`.
- `ParallelStep<TCtx>` —
- `size`, `steps` accessors
- `addStep(step)` returns `this`
- `run(context): Promise<ParallelResult<TCtx>>`
- `compensateAll(context): Promise<void>`
- `createParallelStep<TCtx>(options): ParallelStep<TCtx>`.
## Implementation Notes
In bounded mode, batches are computed by slicing `_steps` at
`maxConcurrency` boundaries (so a `[a, b, c, d]` group with
`maxConcurrency: 2` produces `[[a, b], [c, d]]`). When `failFast: true`,
the second batch is skipped if the first produced any error — but errors
are already collected for every step in the failing batch, so callers see
every sibling outcome that ran before the short-circuit.
`compensateAll` reverses the original insertion order so the most recently
added (and therefore most recently completed) step rolls back first. This
matches saga semantics and matches what callers expect when authoring
inverse pairs (`reserve → release`, `charge → refund`).

View File

@@ -0,0 +1,100 @@
# step
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`Step` is the atomic unit of execution in `@suaveplan/workflow`. Every
sequential branch of a workflow ultimately runs through this class: it owns
the guard evaluation, optional retry policy, optional per-step timeout,
compensation hook, and the OTel emission for both successful and failed
terminal states. `WorkflowEngine`, `Saga`, `ParallelStep`, and
`ConditionalStep` all delegate execution to instances of `Step`, which is
what keeps the per-step behaviour consistent across every higher-level
composition.
The class is fully generic over the context type (`TCtx`) and the result
type (`TResult`) so callers do not have to widen anything to `unknown`
unless they want to.
## Features
- Optional `guard(context)` — short-circuits to a `"skipped"` `StepResult`
(recorded by the engine; the step itself returns from `shouldExecute`).
- Optional `retry` (`fixed | linear | exponential`) with backoff cap.
- Optional `timeout` per step — rejects the in-flight execution.
- Optional `compensate(context)` — invoked by the engine, saga, or
compensator on rollback paths.
- Injectable `Clock` (defaults to `Date.now`) — every `startedAt` /
`completedAt` read goes through it.
- OTel counter + histogram emission per terminal state.
## Usage: Basic Example
```ts
import { createStep } from "@suaveplan/workflow";
const charge = createStep<{ total: number }>({
id: "charge",
name: "Charge card",
execute: (ctx) => ({ chargedAt: Date.now(), amount: ctx.total }),
});
const result = await charge.run({ total: 9.99 });
```
## Usage: Advanced Example
```ts
import { createStep, RetryPresets } from "@suaveplan/workflow";
const fakeClock = () => 0;
const flaky = createStep<{ url: string }>({
id: "fetch",
name: "Fetch resource",
execute: async (ctx) => {
const response = await fetch(ctx.url);
if (!response.ok) throw new Error(`status ${response.status}`);
return response.json();
},
guard: (ctx) => ctx.url.startsWith("https://"),
retry: RetryPresets.standard(),
timeout: 5_000,
compensate: async () => {
await releaseLock();
},
clock: fakeClock,
});
```
## API Reference
- `StepOptions<TCtx, TResult>` —
`{ id; name; execute; compensate?; guard?; retry?; timeout?; clock?; }`.
- `Step<TCtx, TResult>` class —
- `id: string`, `name: string`
- `hasCompensation: boolean`, `hasGuard: boolean`, `hasRetry: boolean`
- `retryConfig: RetryConfig | undefined`
- `shouldExecute(context): Promise<boolean>`
- `run(context): Promise<StepResult<TResult>>`
- `compensate(context): Promise<void>`
- `toDefinition(): StepDefinition<TCtx, TResult>`
- `createStep<TCtx, TResult>(options): Step<TCtx, TResult>`.
## Implementation Notes
The retry loop is `while (attempts < maxAttempts)` so the bookkeeping reads
naturally: `attempts` increments before each call and is the final attempt
count in the returned `StepResult`. Throws inside `execute` are normalised
into `Error` (wrapping non-`Error` throwables in `new Error(String(x))`) so
the downstream `error` field always satisfies the type.
`recordStepExecuted` is only called for the `"completed"` and `"failed"`
terminal states because guards short-circuit before `Step.run`. The engine
emits `"skipped"` separately when it observes `shouldExecute === false`.
`shouldExecute` returns true immediately when no guard is registered; this
avoids waking the microtask queue on every step run when no guard is in
play.

View File

@@ -0,0 +1,103 @@
# types/common
## Purpose
This module is the canonical home for every shared structural type in
`@suaveplan/workflow`. The `WorkflowEngine`, builders, step primitives, saga
orchestrator, hook registry, and retry middleware all import their interface
contracts from here so the package has exactly one definition of
`StepResult`, `RetryConfig`, `HookPayload`, and friends. Keeping the types
flat in a single module (rather than scattering them across each feature
directory) is what lets consumers re-import individual symbols from the
package root barrel and reason about wire shapes without chasing imports.
## Features
- `WorkflowStatus` and `StepStatus` discriminators for run-time state.
- `StepExecutor`, `CompensationFn`, `GuardFn` callback signatures.
- `RetryConfig` + `RetryStrategy` strategy union.
- `StepResult` + `StepDefinition` + `WorkflowResult` value shapes.
- `HookEvent` topic union and `HookPayload` / `HookListener` callback types.
- `WorkflowConfig` — the Zod-validated input to the engine.
## Usage: Basic Example
```ts
import type { WorkflowConfig, StepDefinition } from "@suaveplan/workflow";
const validate: StepDefinition<{ items: string[] }> = {
id: "validate",
name: "Validate",
execute: (ctx) => {
if (ctx.items.length === 0) throw new Error("empty");
},
};
const config: WorkflowConfig<{ items: string[] }> = {
id: "wf-1",
name: "checkout",
steps: [validate],
onError: "stop",
};
```
## Usage: Advanced Example
```ts
import type {
HookListener,
StepResult,
RetryConfig,
} from "@suaveplan/workflow";
const auditLog: HookListener<{ orderId: string }> = (payload) => {
if (payload.event === "step:fail" && payload.stepResult) {
console.error("step failed", {
id: payload.stepResult.stepId,
attempts: payload.stepResult.attempts,
error: payload.stepResult.error?.message,
});
}
};
const exponential: RetryConfig = {
maxAttempts: 5,
strategy: "exponential",
delayMs: 100,
backoffMultiplier: 2,
maxDelayMs: 5000,
};
const skipped: StepResult = {
stepId: "skip",
status: "skipped",
startedAt: 0,
completedAt: 0,
attempts: 0,
};
```
## API Reference
- `WorkflowStatus` = `"idle" | "running" | "completed" | "failed" | "cancelled" | "compensating"`.
- `StepStatus` = `"pending" | "running" | "completed" | "failed" | "skipped" | "compensated"`.
- `StepExecutor<TCtx, TResult>` — `(context) => TResult | Promise<TResult>`.
- `CompensationFn<TCtx>` — `(context) => void | Promise<void>`.
- `GuardFn<TCtx>` — `(context) => boolean | Promise<boolean>`.
- `RetryStrategy` = `"fixed" | "exponential" | "linear"`.
- `RetryConfig` — `{ maxAttempts; strategy; delayMs; maxDelayMs?; backoffMultiplier?; }`.
- `StepResult<TResult>` — `{ stepId; status; result?; error?; startedAt; completedAt; attempts; }`.
- `StepDefinition<TCtx, TResult>` — `{ id; name; execute; compensate?; guard?; retry?; timeout?; }`.
- `WorkflowResult<TCtx>` — `{ workflowId; status; context; steps; startedAt; completedAt; error?; }`.
- `HookEvent` — twelve string topics (`workflow:*`, `step:*`, `compensation:*`).
- `HookPayload<TCtx>` / `HookListener<TCtx>` — see source for fields.
- `WorkflowConfig<TCtx>` — `{ id; name; steps; timeout?; onError?; }`.
## Implementation Notes
The interfaces are intentionally structural — none of them carry runtime
methods. That keeps them free for downstream packages to extend via module
augmentation and for adapters (e.g. SurrealDB persistence, durable replay)
to slot in without inheriting any class hierarchy. Every property that is
`readonly` is so because the consuming code never re-assigns it; if a future
adapter requires mutation it should clone first.

View File

@@ -0,0 +1,85 @@
# types/workflow-hooks
## Purpose
`@suaveplan/workflow` exposes two distinct hook surfaces. The first is the
fire-and-forget `HookRegistry` (`workflow:start`, `step:complete`, etc.)
covered by `types/common`. The second — declared here — is the
data-pipeline hook `workflow:transitioning` that runs between
`Step.shouldExecute` and `Step.run`, giving consumers a single point at
which to inspect and mutate the context handed to the step. This file owns
the type contracts for that pipeline.
The pipeline mirrors the upstream `@suaveplan/events` `Hooks<TMap>` shape so
applications already using upstream's `Hooks` class can swap 's
internal `TransitionHooks` (defined in `core/workflow-engine`) without
changing the handler signatures.
## Features
- `WorkflowTransitionData<TData>` — the data shape flowing through the
pipeline (workflowId, stepId, stepName, mutable context).
- `WorkflowTransitionHookMap<TData>` — extensible map mapping the
`"workflow:transitioning"` topic to its data type.
- `WorkflowTransitioningHandler<TData>` — handler signature that may return
a transformed `WorkflowTransitionData`, `void` (pass-through), or a
promise of either.
- `WorkflowHookMap` — base index signature for future hook topics.
## Usage: Basic Example
```ts
import type { WorkflowTransitioningHandler } from "@suaveplan/workflow";
const audit: WorkflowTransitioningHandler<{ userId: string }> = (data) => {
console.log("entering step", data.stepName, "for", data.context.userId);
};
```
## Usage: Advanced Example
```ts
import type {
WorkflowTransitionData,
WorkflowTransitioningHandler,
} from "@suaveplan/workflow";
interface OrderCtx {
orderId: string;
region: string;
taxRate?: number;
}
// Enrich context with region-specific tax rate before each step runs.
const enrichTax: WorkflowTransitioningHandler<OrderCtx> = async (data) => {
if (data.context.taxRate !== undefined) return;
const rate = await fetchTaxRate(data.context.region);
const next: WorkflowTransitionData<OrderCtx> = {
...data,
context: { ...data.context, taxRate: rate },
};
return next;
};
```
## API Reference
- `WorkflowTransitionData<TData extends Record<string, unknown>>` —
- `readonly workflowId: string`
- `readonly stepId: string`
- `readonly stepName: string`
- `context: TData` (intentionally mutable for the data-pipeline use case)
- `WorkflowHookMap` — `{ [key: string]: unknown }` index signature.
- `WorkflowTransitionHookMap<TData>` — extends `WorkflowHookMap` with the
literal `"workflow:transitioning"` key.
- `WorkflowTransitioningHandler<TData>` —
`(data) => WorkflowTransitionData<TData> | void | Promise<...>`.
## Implementation Notes
`context` is intentionally non-readonly so the most common consumer use case
(mutate-and-return-void) keeps working. Handlers that prefer immutability
can shallow-clone `data.context` and return a fresh
`WorkflowTransitionData`. The engine treats any returned value as the
authoritative data for the next handler in the chain, and `void` as
pass-through — matching the upstream `Hooks.applyHooksAsync` contract.

View File

@@ -0,0 +1,81 @@
# utils
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pure helpers shared across every other module in `@suaveplan/workflow`.
Centralising them here keeps `Step`, the engine, the saga, and the retry
middleware free of duplicated timer-juggling code, and concentrates the
`Clock` injection seam in one file so test fixtures only have to swap a
single function to control time across the whole package.
The helpers are intentionally tiny and synchronous (except `delay`, which is
the only one that has to schedule a real timer). They never throw structured
errors — at this layer the right behaviour for misuse is the engine's typed
`WorkflowError`, raised by the caller.
## Features
- `defaultClock` — `Date.now`-backed `Clock` (from `@suaveplan/types`).
- `generateId(prefix, clock?)` — collision-resistant id via `timestamp_random`.
- `now(clock?)` — single read of the injected clock; returns a number ms.
- `delay(ms)` — promise that resolves after a `setTimeout`.
- `withTimeout(promise, ms, message?)` — race a promise against a timeout.
- `calculateRetryDelay(attempt, strategy, baseDelay, multiplier?, maxDelay?)`
— returns the next attempt's delay capped at `maxDelay`.
## Usage: Basic Example
```ts
import { calculateRetryDelay, delay, generateId, now } from "@suaveplan/workflow";
const id = generateId("step");
await delay(50);
const t = now();
const next = calculateRetryDelay(2, "exponential", 100); // 200
```
## Usage: Advanced Example
```ts
import { generateId, withTimeout } from "@suaveplan/workflow";
const fakeClock = () => 1_700_000_000_000;
const deterministicId = generateId("wf", fakeClock);
const slow = new Promise((r) => setTimeout(() => r("ok"), 50));
const result = await withTimeout(slow, 200, "operation took too long");
```
## API Reference
- `defaultClock: Clock` — re-exported default; calls `Date.now`.
- `generateId(prefix = "wf", clock = defaultClock): string` — timestamp +
random suffix in base-36.
- `now(clock = defaultClock): number` — single clock read.
- `delay(ms: number): Promise<void>` — schedule a `setTimeout`.
- `withTimeout<T>(promise: Promise<T>, ms: number, message?: string): Promise<T>`
— rejects with `Error(message)` if the promise hasn't settled in time.
- `calculateRetryDelay(attempt, strategy, baseDelay, multiplier?, maxDelay?): number`
— `"fixed"` → `baseDelay`, `"linear"` → `baseDelay * attempt`,
`"exponential"` → `baseDelay * multiplier^(attempt-1)`. Capped at
`maxDelay` (default 30 000 ms).
## Implementation Notes
`withTimeout` clears the timer in a `.finally()` handler regardless of
which side of the race wins; without that, fast resolutions would leave the
timer leaking until it fires, which test isolation cannot tolerate.
`calculateRetryDelay` deliberately doesn't accept jitter — adding jitter is
a downstream-policy decision; the deterministic numbers here are easier to
test and easier to reason about when a downstream system layers its own
strategy on top.
`generateId` reads through the injected clock so two workflows constructed
under the same `FakeClock` get distinct ids only via the random suffix,
which is enough for the typical millions-per-second instantiation rate
because the suffix has ~6 base-36 characters of entropy.