first commit
This commit is contained in:
162
docs/foundation/flow/workflow/README.md
Normal file
162
docs/foundation/flow/workflow/README.md
Normal 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
|
||||
|
||||
@@ -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.
|
||||
120
docs/foundation/flow/workflow/src/compensation/saga/saga.md
Normal file
120
docs/foundation/flow/workflow/src/compensation/saga/saga.md
Normal 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.
|
||||
109
docs/foundation/flow/workflow/src/core/context/context.md
Normal file
109
docs/foundation/flow/workflow/src/core/context/context.md
Normal 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).
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
83
docs/foundation/flow/workflow/src/errors/workflow-error.md
Normal file
83
docs/foundation/flow/workflow/src/errors/workflow-error.md
Normal 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.
|
||||
108
docs/foundation/flow/workflow/src/middleware/hook/hook.md
Normal file
108
docs/foundation/flow/workflow/src/middleware/hook/hook.md
Normal 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.
|
||||
84
docs/foundation/flow/workflow/src/middleware/retry/retry.md
Normal file
84
docs/foundation/flow/workflow/src/middleware/retry/retry.md
Normal 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.
|
||||
86
docs/foundation/flow/workflow/src/otel/instruments.md
Normal file
86
docs/foundation/flow/workflow/src/otel/instruments.md
Normal 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`).
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
100
docs/foundation/flow/workflow/src/steps/step/step.md
Normal file
100
docs/foundation/flow/workflow/src/steps/step/step.md
Normal 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.
|
||||
103
docs/foundation/flow/workflow/src/types/common.md
Normal file
103
docs/foundation/flow/workflow/src/types/common.md
Normal 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.
|
||||
85
docs/foundation/flow/workflow/src/types/workflow-hooks.md
Normal file
85
docs/foundation/flow/workflow/src/types/workflow-hooks.md
Normal 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.
|
||||
81
docs/foundation/flow/workflow/src/utils/utils.md
Normal file
81
docs/foundation/flow/workflow/src/utils/utils.md
Normal 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.
|
||||
Reference in New Issue
Block a user