updated skills
This commit is contained in:
@@ -54,19 +54,19 @@ bun add @suaveplan/workflow
|
||||
import { createWorkflow } from "@suaveplan/workflow";
|
||||
|
||||
interface OrderData {
|
||||
orderId: string;
|
||||
items: string[];
|
||||
total: number;
|
||||
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 },
|
||||
});
|
||||
.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
|
||||
@@ -103,15 +103,15 @@ builder.onError("continue"); // log + skip the failure, run the rest
|
||||
import { RetryPresets } from "@suaveplan/workflow";
|
||||
|
||||
builder
|
||||
.addStep("flaky-api", async (ctx) => callExternalApi(ctx))
|
||||
.withRetry(RetryPresets.standard());
|
||||
.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,
|
||||
maxAttempts: 5,
|
||||
strategy: "exponential",
|
||||
delayMs: 500,
|
||||
maxDelayMs: 10_000,
|
||||
backoffMultiplier: 2,
|
||||
});
|
||||
```
|
||||
|
||||
@@ -121,13 +121,13 @@ builder.addStep("custom", (ctx) => callApi(ctx)).withRetry({
|
||||
const engine = createWorkflowEngine(config);
|
||||
|
||||
engine.hooks.on("workflow:start", (p) =>
|
||||
console.log(`Workflow ${p.workflowId} started`),
|
||||
console.log(`Workflow ${p.workflowId} started`),
|
||||
);
|
||||
engine.hooks.on("step:complete", (p) =>
|
||||
console.log(`Step ${p.stepId} completed`),
|
||||
console.log(`Step ${p.stepId} completed`),
|
||||
);
|
||||
engine.hooks.on("step:fail", (p) =>
|
||||
console.error(`Step ${p.stepId} failed:`, p.error),
|
||||
console.error(`Step ${p.stepId} failed:`, p.error),
|
||||
);
|
||||
```
|
||||
|
||||
@@ -139,18 +139,18 @@ 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),
|
||||
}));
|
||||
.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.
|
||||
@@ -159,4 +159,3 @@ const result = await saga.execute(context);
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
|
||||
@@ -49,12 +49,12 @@ await comp.compensateAll({ orderId: "ord-42" });
|
||||
import { createCompensator } from "@suaveplan/workflow";
|
||||
|
||||
const comp = createCompensator<{ id: string }>({
|
||||
continueOnError: false,
|
||||
clock: () => 0,
|
||||
continueOnError: false,
|
||||
clock: () => 0,
|
||||
});
|
||||
|
||||
comp.register("a", () => {
|
||||
throw new Error("a failed");
|
||||
throw new Error("a failed");
|
||||
});
|
||||
comp.register("b", () => undefined);
|
||||
|
||||
@@ -62,7 +62,7 @@ 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.
|
||||
// Re-attempt the failed compensations later.
|
||||
}
|
||||
```
|
||||
|
||||
@@ -86,7 +86,7 @@ The internal `_runEntry` helper is shared by `compensateAll` and
|
||||
`continueOnError` is false) so the callers can short-circuit without
|
||||
duplicating the predicate.
|
||||
|
||||
`compensateSteps` filters by id *before* reversing, so the relative order
|
||||
`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.
|
||||
|
||||
|
||||
@@ -34,27 +34,27 @@ leaking state across runs.
|
||||
import { createSaga, createStep } from "@suaveplan/workflow";
|
||||
|
||||
const saga = createSaga<{ orderId: string }>({
|
||||
id: "booking",
|
||||
name: "Booking saga",
|
||||
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),
|
||||
}),
|
||||
);
|
||||
.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" });
|
||||
```
|
||||
@@ -66,28 +66,28 @@ 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,
|
||||
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),
|
||||
}),
|
||||
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");
|
||||
},
|
||||
}),
|
||||
createStep({
|
||||
id: "commit",
|
||||
name: "Commit",
|
||||
execute: () => {
|
||||
throw new Error("conflict");
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await saga.execute({ id: "row-7" });
|
||||
|
||||
@@ -36,13 +36,13 @@ the live context mutating beneath the caller.
|
||||
import { createContext } from "@suaveplan/workflow";
|
||||
|
||||
interface OrderData {
|
||||
orderId: string;
|
||||
total: number;
|
||||
orderId: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
const ctx = createContext<OrderData>("wf-1", {
|
||||
orderId: "ord-1",
|
||||
total: 9.99,
|
||||
orderId: "ord-1",
|
||||
total: 9.99,
|
||||
});
|
||||
ctx.setStatus("running");
|
||||
ctx.updateData({ total: 14.99 });
|
||||
@@ -56,21 +56,21 @@ 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,
|
||||
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,
|
||||
stepId: "charge",
|
||||
status: "completed",
|
||||
startedAt: 1,
|
||||
completedAt: 2,
|
||||
attempts: 1,
|
||||
});
|
||||
|
||||
console.log(before.data.user); // "alice" — snapshot is unaffected
|
||||
|
||||
@@ -33,11 +33,11 @@ so callers can subscribe to lifecycle events before the engine is built.
|
||||
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" } });
|
||||
.addStep("validate", (ctx) => {
|
||||
if (!ctx.orderId) throw new Error("missing");
|
||||
})
|
||||
.build()
|
||||
.run({ initialData: { orderId: "ord-1" } });
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
@@ -46,21 +46,21 @@ const result = await createWorkflow<{ orderId: string }>("checkout")
|
||||
import { createWorkflow, RetryPresets } from "@suaveplan/workflow";
|
||||
|
||||
const engine = createWorkflow<{ user: string }>("onboarding", {
|
||||
clock: () => 1_700_000_000_000,
|
||||
clock: () => 1_700_000_000_000,
|
||||
})
|
||||
.withTimeout(30_000)
|
||||
.onError("compensate")
|
||||
.on("step:fail", (p) => console.error("failed:", p.stepId));
|
||||
.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" });
|
||||
.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);
|
||||
.addStep("notify", (ctx) => sendWelcome(ctx.user))
|
||||
.withGuard((ctx) => ctx.user.startsWith("@"))
|
||||
.withTimeout(5_000);
|
||||
|
||||
const result = await engine.run({ initialData: { user: "@alice" } });
|
||||
```
|
||||
|
||||
@@ -29,7 +29,7 @@ import { createWorkflowEngine } from "@suaveplan/workflow";
|
||||
|
||||
const engine = createWorkflowEngine({ id: "w", name: "n", steps: [] });
|
||||
engine.transitionHooks.hook((data) => {
|
||||
console.log("entering", data.stepName);
|
||||
console.log("entering", data.stepName);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -37,18 +37,18 @@ engine.transitionHooks.hook((data) => {
|
||||
|
||||
```ts
|
||||
const engine = createWorkflowEngine<{ region: string }>({
|
||||
id: "w",
|
||||
name: "checkout",
|
||||
steps,
|
||||
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" },
|
||||
};
|
||||
}
|
||||
if (data.context.region === "EU") {
|
||||
return {
|
||||
...data,
|
||||
context: { ...data.context, region: "EU-norm" },
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await engine.run({ initialData: { region: "EU" } });
|
||||
|
||||
@@ -40,18 +40,18 @@ codes.
|
||||
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");
|
||||
},
|
||||
},
|
||||
],
|
||||
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" } });
|
||||
@@ -63,31 +63,31 @@ const result = await engine.run({ initialData: { orderId: "ord-1" } });
|
||||
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),
|
||||
},
|
||||
],
|
||||
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);
|
||||
console.error("step failed", p.stepId, p.error?.message);
|
||||
});
|
||||
|
||||
engine.transitionHooks.hook((data) => {
|
||||
return { ...data, context: { ...data.context, user: data.context.user.trim() } };
|
||||
return { ...data, context: { ...data.context, user: data.context.user.trim() } };
|
||||
});
|
||||
|
||||
const run = engine.run({ initialData: { user: " alice " } });
|
||||
@@ -121,7 +121,7 @@ while still satisfying the registry's reachability gate.
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -40,20 +40,20 @@ throw new WorkflowError(WORKFLOW_CODES.TIMEOUT, 'Workflow "checkout" timed out')
|
||||
import { WORKFLOW_CODES, WorkflowError } from "@suaveplan/workflow/errors";
|
||||
|
||||
try {
|
||||
await engine.run({ initialData });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ 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
|
||||
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.
|
||||
|
||||
@@ -34,12 +34,12 @@ import { createHookRegistry } from "@suaveplan/workflow";
|
||||
|
||||
const hooks = createHookRegistry<{ orderId: string }>();
|
||||
hooks.on("workflow:start", (p) => {
|
||||
console.log("starting", p.workflowId);
|
||||
console.log("starting", p.workflowId);
|
||||
});
|
||||
|
||||
await hooks.emit("workflow:start", {
|
||||
workflowId: "wf-1",
|
||||
context: { orderId: "ord-1" },
|
||||
workflowId: "wf-1",
|
||||
context: { orderId: "ord-1" },
|
||||
});
|
||||
```
|
||||
|
||||
@@ -47,34 +47,34 @@ await hooks.emit("workflow:start", {
|
||||
|
||||
```ts
|
||||
import {
|
||||
buildStepPayload,
|
||||
createHookRegistry,
|
||||
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);
|
||||
}
|
||||
if (p.stepResult && p.stepResult.attempts >= 3) {
|
||||
await pageOnCall(p.error);
|
||||
}
|
||||
});
|
||||
|
||||
hooks.once("workflow:complete", (p) => {
|
||||
console.log("done", p.workflowId, p.timestamp);
|
||||
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"),
|
||||
"wf-1",
|
||||
{ user: "alice" },
|
||||
"step-1",
|
||||
{
|
||||
stepId: "step-1",
|
||||
status: "failed",
|
||||
startedAt: 0,
|
||||
completedAt: 100,
|
||||
attempts: 3,
|
||||
},
|
||||
new Error("boom"),
|
||||
));
|
||||
|
||||
unsubscribe();
|
||||
|
||||
@@ -35,8 +35,8 @@ the numbers in every consumer.
|
||||
import { withRetry, RetryPresets } from "@suaveplan/workflow";
|
||||
|
||||
const result = await withRetry(
|
||||
() => fetch("/api/data").then((r) => r.json()),
|
||||
RetryPresets.standard(),
|
||||
() => fetch("/api/data").then((r) => r.json()),
|
||||
RetryPresets.standard(),
|
||||
);
|
||||
```
|
||||
|
||||
@@ -46,14 +46,14 @@ const result = await withRetry(
|
||||
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);
|
||||
},
|
||||
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()));
|
||||
@@ -72,7 +72,7 @@ const json = await wrap(() => fetch("/api/v1").then((r) => r.json()));
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`withRetry` always rethrows the *last* error, not the *first*. Callers
|
||||
`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
|
||||
|
||||
@@ -52,7 +52,7 @@ 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",
|
||||
(m) => m.descriptor.name === "workflow.step.executed_total",
|
||||
);
|
||||
expect(stepCounter?.dataPoints.length).toBeGreaterThan(0);
|
||||
|
||||
|
||||
@@ -34,19 +34,19 @@ concerns. `compensate` rolls back only the branch that actually ran.
|
||||
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 }),
|
||||
}),
|
||||
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 });
|
||||
```
|
||||
@@ -57,21 +57,21 @@ const result = await branch.run({ premium: true });
|
||||
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);
|
||||
},
|
||||
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,
|
||||
id: "maybe-ship",
|
||||
name: "Maybe ship",
|
||||
condition: (ctx) => ctx.ready,
|
||||
onTrue: ship,
|
||||
});
|
||||
|
||||
await guard.run({ ready: true });
|
||||
|
||||
@@ -36,13 +36,13 @@ failure.
|
||||
import { createParallelStep, createStep } from "@suaveplan/workflow";
|
||||
|
||||
const group = createParallelStep<{ url: string }>({
|
||||
groupId: "fetch-group",
|
||||
name: "Fetch resources",
|
||||
groupId: "fetch-group",
|
||||
name: "Fetch resources",
|
||||
});
|
||||
|
||||
group
|
||||
.addStep(createStep({ id: "a", name: "A", execute: () => "a" }))
|
||||
.addStep(createStep({ id: "b", name: "B", execute: () => "b" }));
|
||||
.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" });
|
||||
```
|
||||
@@ -53,31 +53,31 @@ const result = await group.run({ url: "https://example.com" });
|
||||
import { createParallelStep, createStep } from "@suaveplan/workflow";
|
||||
|
||||
const group = createParallelStep<{ jobIds: string[] }>({
|
||||
groupId: "process",
|
||||
name: "Process jobs",
|
||||
maxConcurrency: 2,
|
||||
failFast: true,
|
||||
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);
|
||||
},
|
||||
}),
|
||||
);
|
||||
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: [] });
|
||||
await group.compensateAll({ jobIds: [] });
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -37,9 +37,9 @@ unless they want to.
|
||||
import { createStep } from "@suaveplan/workflow";
|
||||
|
||||
const charge = createStep<{ total: number }>({
|
||||
id: "charge",
|
||||
name: "Charge card",
|
||||
execute: (ctx) => ({ chargedAt: Date.now(), amount: ctx.total }),
|
||||
id: "charge",
|
||||
name: "Charge card",
|
||||
execute: (ctx) => ({ chargedAt: Date.now(), amount: ctx.total }),
|
||||
});
|
||||
|
||||
const result = await charge.run({ total: 9.99 });
|
||||
@@ -52,20 +52,20 @@ 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,
|
||||
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,
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -26,18 +26,18 @@ package root barrel and reason about wire shapes without chasing imports.
|
||||
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");
|
||||
},
|
||||
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",
|
||||
id: "wf-1",
|
||||
name: "checkout",
|
||||
steps: [validate],
|
||||
onError: "stop",
|
||||
};
|
||||
```
|
||||
|
||||
@@ -45,35 +45,35 @@ const config: WorkflowConfig<{ items: string[] }> = {
|
||||
|
||||
```ts
|
||||
import type {
|
||||
HookListener,
|
||||
StepResult,
|
||||
RetryConfig,
|
||||
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,
|
||||
});
|
||||
}
|
||||
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,
|
||||
maxAttempts: 5,
|
||||
strategy: "exponential",
|
||||
delayMs: 100,
|
||||
backoffMultiplier: 2,
|
||||
maxDelayMs: 5000,
|
||||
};
|
||||
|
||||
const skipped: StepResult = {
|
||||
stepId: "skip",
|
||||
status: "skipped",
|
||||
startedAt: 0,
|
||||
completedAt: 0,
|
||||
attempts: 0,
|
||||
stepId: "skip",
|
||||
status: "skipped",
|
||||
startedAt: 0,
|
||||
completedAt: 0,
|
||||
attempts: 0,
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ changing the handler signatures.
|
||||
import type { WorkflowTransitioningHandler } from "@suaveplan/workflow";
|
||||
|
||||
const audit: WorkflowTransitioningHandler<{ userId: string }> = (data) => {
|
||||
console.log("entering step", data.stepName, "for", data.context.userId);
|
||||
console.log("entering step", data.stepName, "for", data.context.userId);
|
||||
};
|
||||
```
|
||||
|
||||
@@ -40,25 +40,25 @@ const audit: WorkflowTransitioningHandler<{ userId: string }> = (data) => {
|
||||
|
||||
```ts
|
||||
import type {
|
||||
WorkflowTransitionData,
|
||||
WorkflowTransitioningHandler,
|
||||
WorkflowTransitionData,
|
||||
WorkflowTransitioningHandler,
|
||||
} from "@suaveplan/workflow";
|
||||
|
||||
interface OrderCtx {
|
||||
orderId: string;
|
||||
region: string;
|
||||
taxRate?: number;
|
||||
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;
|
||||
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;
|
||||
};
|
||||
```
|
||||
|
||||
|
||||
Reference in New Issue
Block a user