Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

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,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildStepPayload, createHookRegistry, HookRegistry } from "./hook.js";
describe("HookRegistry", () => {
it("invokes listeners in registration order", {
timeout: 5000,
}, async () => {
const order: number[] = [];
const h = new HookRegistry<Record<string, never>>();
h.on("workflow:start", () => {
order.push(1);
});
h.on("workflow:start", () => {
order.push(2);
});
await h.emit("workflow:start", { workflowId: "w", context: {} });
expect(order).toEqual([1, 2]);
expect(h.listenerCount("workflow:start")).toBe(2);
expect(h.totalListeners).toBe(2);
expect(h.events).toEqual(["workflow:start"]);
});
it("unsubscribe removes the listener", { timeout: 5000 }, async () => {
const h = new HookRegistry<Record<string, never>>();
let calls = 0;
const off = h.on("step:start", () => {
calls++;
});
await h.emit("step:start", { workflowId: "w", context: {} });
off();
await h.emit("step:start", { workflowId: "w", context: {} });
expect(calls).toBe(1);
});
it("unsubscribe is safe if listener already removed", {
timeout: 5000,
}, async () => {
const h = new HookRegistry<Record<string, never>>();
const off = h.on("step:start", () => {});
off();
off(); // double off — must not throw
expect(h.listenerCount("step:start")).toBe(0);
});
it("unsubscribe is safe after off(event) clears the topic", {
timeout: 5000,
}, () => {
const h = new HookRegistry<Record<string, never>>();
const off = h.on("step:start", () => {});
h.off("step:start");
off(); // listener map has no entry now — must not throw
expect(h.listenerCount("step:start")).toBe(0);
});
it("once listener auto-unregisters after first emit", {
timeout: 5000,
}, async () => {
const h = createHookRegistry<Record<string, never>>();
let calls = 0;
h.once("step:complete", () => {
calls++;
});
await h.emit("step:complete", { workflowId: "w", context: {} });
await h.emit("step:complete", { workflowId: "w", context: {} });
expect(calls).toBe(1);
expect(h.listenerCount("step:complete")).toBe(0);
});
it("off(event) removes only that topic", { timeout: 5000 }, () => {
const h = new HookRegistry<Record<string, never>>();
h.on("step:start", () => {});
h.on("step:complete", () => {});
h.off("step:start");
expect(h.listenerCount("step:start")).toBe(0);
expect(h.listenerCount("step:complete")).toBe(1);
});
it("off() clears all listeners", { timeout: 5000 }, () => {
const h = new HookRegistry<Record<string, never>>();
h.on("step:start", () => {});
h.on("step:complete", () => {});
h.off();
expect(h.totalListeners).toBe(0);
});
it("emit is a no-op when no listeners", { timeout: 5000 }, async () => {
const h = new HookRegistry<Record<string, never>>();
await h.emit("workflow:start", { workflowId: "w", context: {} });
// no throw
expect(h.totalListeners).toBe(0);
});
it("emit awaits async listeners sequentially", {
timeout: 5000,
}, async () => {
const order: string[] = [];
const h = new HookRegistry<Record<string, never>>();
h.on("workflow:start", async () => {
await new Promise((r) => setTimeout(r, 1));
order.push("a");
});
h.on("workflow:start", () => {
order.push("b");
});
await h.emit("workflow:start", { workflowId: "w", context: {} });
expect(order).toEqual(["a", "b"]);
});
it("includes timestamp from injected clock", {
timeout: 5000,
}, async () => {
const h = new HookRegistry<Record<string, never>>({ clock: () => 99 });
let ts: number | undefined;
h.on("workflow:start", (p) => {
ts = p.timestamp;
});
await h.emit("workflow:start", { workflowId: "w", context: {} });
expect(ts).toBe(99);
});
it("buildStepPayload builds the expected shape", { timeout: 5000 }, () => {
const stepResult = {
stepId: "s",
status: "completed" as const,
startedAt: 0,
completedAt: 1,
attempts: 1,
};
const err = new Error("e");
const payload = buildStepPayload("w", { x: 1 }, "s", stepResult, err);
expect(payload).toEqual({
workflowId: "w",
context: { x: 1 },
stepId: "s",
stepResult,
error: err,
});
const minimal = buildStepPayload("w", {}, "s");
expect(minimal.stepResult).toBeUndefined();
expect(minimal.error).toBeUndefined();
});
});

View File

@@ -0,0 +1,103 @@
import type { Clock } from "@suaveplan/types";
import type {
HookEvent,
HookListener,
HookPayload,
StepResult,
} from "../../types/common.js";
import { defaultClock, now } from "../../utils/utils.js";
/**
* Registry that manages hook listeners and dispatches events during
* workflow execution.
*/
export class HookRegistry<TCtx = unknown> {
private readonly _listeners = new Map<HookEvent, HookListener<TCtx>[]>();
private readonly _clock: Clock;
constructor(options?: { clock?: Clock }) {
this._clock = options?.clock ?? defaultClock;
}
on(event: HookEvent, listener: HookListener<TCtx>): () => void {
const existing = this._listeners.get(event) ?? [];
existing.push(listener);
this._listeners.set(event, existing);
return () => {
const current = this._listeners.get(event);
if (current) {
const idx = current.indexOf(listener);
if (idx >= 0) current.splice(idx, 1);
}
};
}
once(event: HookEvent, listener: HookListener<TCtx>): void {
const wrapped: HookListener<TCtx> = (payload) => {
unregister();
return listener(payload);
};
const unregister = this.on(event, wrapped);
}
off(event?: HookEvent): void {
if (event) {
this._listeners.delete(event);
} else {
this._listeners.clear();
}
}
async emit(
event: HookEvent,
data: Omit<HookPayload<TCtx>, "event" | "timestamp">
): Promise<void> {
const listeners = this._listeners.get(event);
if (!listeners || listeners.length === 0) return;
const payload: HookPayload<TCtx> = {
...data,
event,
timestamp: now(this._clock),
};
for (const listener of listeners) {
await listener(payload);
}
}
listenerCount(event: HookEvent): number {
return this._listeners.get(event)?.length ?? 0;
}
get events(): HookEvent[] {
return [...this._listeners.keys()];
}
get totalListeners(): number {
let count = 0;
for (const listeners of this._listeners.values()) {
count += listeners.length;
}
return count;
}
}
/** Factory function to create a hook registry. */
export function createHookRegistry<TCtx = unknown>(options?: {
clock?: Clock;
}): HookRegistry<TCtx> {
return new HookRegistry(options);
}
/** Helper to build a step-related hook payload. */
export function buildStepPayload<TCtx>(
workflowId: string,
context: TCtx,
stepId: string,
stepResult?: StepResult,
error?: Error
): Omit<HookPayload<TCtx>, "event" | "timestamp"> {
return { workflowId, context, stepId, stepResult, error };
}

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,188 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { WORKFLOW_CODES, WorkflowError } from "../../errors/workflow-error.js";
import {
createRetryWrapper,
RetryPresets,
resolveRetryConfig,
withRetry,
} from "./retry.js";
describe("resolveRetryConfig", () => {
it("fills defaults", { timeout: 5000 }, () => {
const cfg = resolveRetryConfig();
expect(cfg.maxAttempts).toBe(3);
expect(cfg.strategy).toBe("fixed");
expect(cfg.delayMs).toBe(1000);
expect(cfg.maxDelayMs).toBe(30000);
expect(cfg.backoffMultiplier).toBe(2);
});
it("respects overrides", { timeout: 5000 }, () => {
const cfg = resolveRetryConfig({
maxAttempts: 5,
strategy: "linear",
delayMs: 50,
maxDelayMs: 500,
backoffMultiplier: 3,
});
expect(cfg).toEqual({
maxAttempts: 5,
strategy: "linear",
delayMs: 50,
maxDelayMs: 500,
backoffMultiplier: 3,
});
});
});
describe("withRetry", () => {
it("returns on first success", { timeout: 5000 }, async () => {
let calls = 0;
const result = await withRetry(() => {
calls++;
return 42;
});
expect(result).toBe(42);
expect(calls).toBe(1);
});
it("exhausts attempts and rethrows the final error", {
timeout: 5000,
}, async () => {
let calls = 0;
try {
await withRetry(
() => {
calls++;
throw new Error(`fail-${calls}`);
},
{ maxAttempts: 3, delayMs: 1 }
);
throw new Error("should not reach");
} catch (e) {
expect(e).toBeInstanceOf(WorkflowError);
expect((e as WorkflowError).code).toBe(
WORKFLOW_CODES.RETRY_EXHAUSTED
);
expect((e as WorkflowError).message).toBe("fail-3");
}
expect(calls).toBe(3);
});
it("retry-exhausted with no thrown error path", {
timeout: 5000,
}, async () => {
// Edge: zero maxAttempts is invalid input but guards against infinite
// loops if a future caller passes 0 — the loop body is skipped and we
// surface the default message.
try {
await withRetry(() => 1, { maxAttempts: 0 });
throw new Error("should not reach");
} catch (e) {
expect(e).toBeInstanceOf(WorkflowError);
expect((e as WorkflowError).message).toBe(
"Retry attempts exhausted"
);
}
});
it("short-circuits when shouldRetry returns false", {
timeout: 5000,
}, async () => {
let calls = 0;
try {
await withRetry(
() => {
calls++;
throw new Error("nope");
},
{
maxAttempts: 5,
delayMs: 1,
shouldRetry: () => false,
}
);
throw new Error("should not reach");
} catch (e) {
expect(e).toBeInstanceOf(WorkflowError);
expect((e as WorkflowError).message).toBe("nope");
}
expect(calls).toBe(1);
});
it("invokes onRetry between attempts", { timeout: 5000 }, async () => {
const attempts: number[] = [];
let calls = 0;
await expect(
withRetry(
() => {
calls++;
throw new Error("again");
},
{
maxAttempts: 3,
delayMs: 1,
onRetry: (_e, a) => attempts.push(a),
}
)
).rejects.toThrow(WorkflowError);
expect(attempts).toEqual([1, 2]);
expect(calls).toBe(3);
});
it("wraps non-Error throwables", { timeout: 5000 }, async () => {
try {
await withRetry(
() => {
const v: unknown = "literal";
throw v;
},
{ maxAttempts: 1 }
);
throw new Error("should not reach");
} catch (e) {
expect((e as WorkflowError).message).toBe("literal");
}
});
});
describe("createRetryWrapper", () => {
it("wraps any function with the same options", {
timeout: 5000,
}, async () => {
const wrap = createRetryWrapper({ maxAttempts: 2, delayMs: 1 });
let calls = 0;
await expect(
wrap(() => {
calls++;
throw new Error("x");
})
).rejects.toThrow(WorkflowError);
expect(calls).toBe(2);
});
});
describe("RetryPresets", () => {
it("returns fresh objects each call", { timeout: 5000 }, () => {
const a = RetryPresets.standard();
const b = RetryPresets.standard();
expect(a).not.toBe(b);
expect(a).toEqual(b);
});
it("none has 1 attempt", { timeout: 5000 }, () => {
expect(RetryPresets.none().maxAttempts).toBe(1);
});
it("quick has 3 fixed attempts", { timeout: 5000 }, () => {
const q = RetryPresets.quick();
expect(q.maxAttempts).toBe(3);
expect(q.strategy).toBe("fixed");
});
it("aggressive has 5 exponential attempts", { timeout: 5000 }, () => {
const a = RetryPresets.aggressive();
expect(a.maxAttempts).toBe(5);
expect(a.strategy).toBe("exponential");
});
});

View File

@@ -0,0 +1,105 @@
import {
DEFAULT_BACKOFF_MULTIPLIER,
DEFAULT_DELAY_MS,
DEFAULT_MAX_ATTEMPTS,
DEFAULT_MAX_DELAY_MS,
} from "../../constants/index.js";
import { WORKFLOW_CODES, WorkflowError } from "../../errors/workflow-error.js";
import type { RetryConfig, RetryStrategy } from "../../types/common.js";
import { calculateRetryDelay, delay } from "../../utils/utils.js";
/** Options for the retry executor. */
export interface RetryOptions extends Partial<RetryConfig> {
readonly shouldRetry?: (error: Error, attempt: number) => boolean;
readonly onRetry?: (error: Error, attempt: number) => void;
}
/** Creates a full RetryConfig from partial options. */
export function resolveRetryConfig(
options?: Partial<RetryConfig>
): RetryConfig {
return {
maxAttempts: options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
strategy: options?.strategy ?? "fixed",
delayMs: options?.delayMs ?? DEFAULT_DELAY_MS,
maxDelayMs: options?.maxDelayMs ?? DEFAULT_MAX_DELAY_MS,
backoffMultiplier:
options?.backoffMultiplier ?? DEFAULT_BACKOFF_MULTIPLIER,
};
}
/** Executes a function with retry logic. */
export async function withRetry<T>(
fn: () => T | Promise<T>,
options?: RetryOptions
): Promise<T> {
const config = resolveRetryConfig(options);
const shouldRetry = options?.shouldRetry ?? (() => true);
const onRetry = options?.onRetry;
let lastError: Error | undefined;
for (let attempt = 1; attempt <= config.maxAttempts; attempt++) {
try {
return await fn();
} catch (error) {
lastError =
error instanceof Error ? error : new Error(String(error));
if (attempt >= config.maxAttempts) break;
if (!shouldRetry(lastError, attempt)) break;
onRetry?.(lastError, attempt);
const retryDelay = calculateRetryDelay(
attempt,
config.strategy,
config.delayMs,
config.backoffMultiplier,
config.maxDelayMs
);
await delay(retryDelay);
}
}
// All attempts exhausted — wrap into a typed WorkflowError so callers can
// pattern-match on the stable code while still seeing the underlying cause.
throw new WorkflowError(
WORKFLOW_CODES.RETRY_EXHAUSTED,
lastError?.message ?? "Retry attempts exhausted",
lastError ? { cause: lastError } : undefined
);
}
/** Creates a retry wrapper function that can be reused. */
export function createRetryWrapper(
options?: RetryOptions
): <T>(fn: () => T | Promise<T>) => Promise<T> {
return <T>(fn: () => T | Promise<T>) => withRetry(fn, options);
}
/** Predefined retry configurations. */
export const RetryPresets = {
none: (): RetryConfig => ({
maxAttempts: 1,
strategy: "fixed" as RetryStrategy,
delayMs: 0,
}),
quick: (): RetryConfig => ({
maxAttempts: 3,
strategy: "fixed" as RetryStrategy,
delayMs: 100,
}),
standard: (): RetryConfig => ({
maxAttempts: 3,
strategy: "exponential" as RetryStrategy,
delayMs: 1000,
backoffMultiplier: 2,
maxDelayMs: 10000,
}),
aggressive: (): RetryConfig => ({
maxAttempts: 5,
strategy: "exponential" as RetryStrategy,
delayMs: 500,
backoffMultiplier: 2,
maxDelayMs: 30000,
}),
} as const;