@stravigor/workflow 0.4.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +122 -0
- package/package.json +23 -0
- package/src/errors.ts +25 -0
- package/src/helpers.ts +6 -0
- package/src/index.ts +13 -0
- package/src/types.ts +77 -0
- package/src/workflow.ts +210 -0
- package/tsconfig.json +4 -0
package/README.md
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# @stravigor/workflow
|
|
2
|
+
|
|
3
|
+
Workflow orchestration for the [Strav](https://www.npmjs.com/package/@stravigor/core) framework. Build multi-step processes with sequential, parallel, conditional, and looping steps — plus saga-style compensation on failure.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
bun add @stravigor/workflow
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires `@stravigor/core` as a peer dependency.
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import { workflow } from '@stravigor/workflow'
|
|
17
|
+
|
|
18
|
+
const result = await workflow('order-process')
|
|
19
|
+
.step('validate', async (ctx) => {
|
|
20
|
+
return validateOrder(ctx.input.orderId)
|
|
21
|
+
})
|
|
22
|
+
.step('charge', async (ctx) => {
|
|
23
|
+
return await chargeCard(ctx.results.validate.total)
|
|
24
|
+
})
|
|
25
|
+
.step('notify', async (ctx) => {
|
|
26
|
+
return await sendConfirmation(ctx.results.charge.id)
|
|
27
|
+
})
|
|
28
|
+
.run({ orderId: 123 })
|
|
29
|
+
|
|
30
|
+
result.results.charge // { id: 'ch_123', amount: 99.99 }
|
|
31
|
+
result.duration // 342 (ms)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Step Types
|
|
35
|
+
|
|
36
|
+
### Sequential
|
|
37
|
+
|
|
38
|
+
Steps run in order. Each step's return value is stored in `ctx.results[name]`.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
workflow('pipeline')
|
|
42
|
+
.step('fetch', async (ctx) => fetchData(ctx.input.url))
|
|
43
|
+
.step('transform', async (ctx) => transform(ctx.results.fetch))
|
|
44
|
+
.step('save', async (ctx) => save(ctx.results.transform))
|
|
45
|
+
.run({ url: 'https://...' })
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
### Parallel
|
|
49
|
+
|
|
50
|
+
Run multiple handlers concurrently. Each result is stored under its name.
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
workflow('notify')
|
|
54
|
+
.parallel('send', [
|
|
55
|
+
{ name: 'email', handler: async (ctx) => sendEmail(ctx) },
|
|
56
|
+
{ name: 'sms', handler: async (ctx) => sendSMS(ctx) },
|
|
57
|
+
{ name: 'push', handler: async (ctx) => sendPush(ctx) },
|
|
58
|
+
])
|
|
59
|
+
.run({ userId: 1 })
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Route
|
|
63
|
+
|
|
64
|
+
Conditionally dispatch to a branch based on a resolver function.
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
workflow('support')
|
|
68
|
+
.step('classify', async (ctx) => classifyTicket(ctx.input.text))
|
|
69
|
+
.route(
|
|
70
|
+
'handle',
|
|
71
|
+
(ctx) => ctx.results.classify.category,
|
|
72
|
+
{
|
|
73
|
+
billing: async (ctx) => handleBilling(ctx),
|
|
74
|
+
shipping: async (ctx) => handleShipping(ctx),
|
|
75
|
+
technical: async (ctx) => handleTechnical(ctx),
|
|
76
|
+
}
|
|
77
|
+
)
|
|
78
|
+
.run({ text: 'My payment failed' })
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Loop
|
|
82
|
+
|
|
83
|
+
Repeat a handler until a condition is met or max iterations reached.
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
workflow('refine')
|
|
87
|
+
.loop('improve', async (input, ctx) => {
|
|
88
|
+
return await improveQuality(input)
|
|
89
|
+
}, {
|
|
90
|
+
maxIterations: 5,
|
|
91
|
+
until: (result) => result.score >= 0.95,
|
|
92
|
+
feedback: (result) => result.data,
|
|
93
|
+
mapInput: (ctx) => ctx.input.rawData,
|
|
94
|
+
})
|
|
95
|
+
.run({ rawData: '...' })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Compensation (Saga Pattern)
|
|
99
|
+
|
|
100
|
+
Define rollback functions for steps. If a downstream step fails, compensation runs in reverse order.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
workflow('order-saga')
|
|
104
|
+
.step('reserve', async (ctx) => reserveInventory(ctx.input), {
|
|
105
|
+
compensate: async (ctx) => releaseInventory(ctx.results.reserve),
|
|
106
|
+
})
|
|
107
|
+
.step('charge', async (ctx) => chargePayment(ctx.input), {
|
|
108
|
+
compensate: async (ctx) => refundPayment(ctx.results.charge),
|
|
109
|
+
})
|
|
110
|
+
.step('ship', async (ctx) => scheduleShipping(ctx.input))
|
|
111
|
+
.run({ orderId: 123 })
|
|
112
|
+
|
|
113
|
+
// If 'ship' fails → refundPayment → releaseInventory
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
## Documentation
|
|
117
|
+
|
|
118
|
+
See the full [Workflow guide](../../guides/workflow.md).
|
|
119
|
+
|
|
120
|
+
## License
|
|
121
|
+
|
|
122
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@stravigor/workflow",
|
|
3
|
+
"version": "0.4.6",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Workflow orchestration for the Strav framework",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts",
|
|
9
|
+
"./*": "./src/*.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/",
|
|
13
|
+
"package.json",
|
|
14
|
+
"tsconfig.json"
|
|
15
|
+
],
|
|
16
|
+
"peerDependencies": {
|
|
17
|
+
"@stravigor/core": "0.4.5"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "bun test tests/",
|
|
21
|
+
"typecheck": "tsc --noEmit"
|
|
22
|
+
}
|
|
23
|
+
}
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { StravError } from '@stravigor/core/exceptions'
|
|
2
|
+
|
|
3
|
+
/** Thrown when a workflow step fails during execution. */
|
|
4
|
+
export class WorkflowError extends StravError {
|
|
5
|
+
constructor(
|
|
6
|
+
public readonly step: string,
|
|
7
|
+
public readonly cause: Error
|
|
8
|
+
) {
|
|
9
|
+
super(`Workflow step "${step}" failed: ${cause.message}`)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Thrown when compensation fails. Contains the original step error and all compensation errors. */
|
|
14
|
+
export class CompensationError extends StravError {
|
|
15
|
+
constructor(
|
|
16
|
+
public readonly originalError: Error,
|
|
17
|
+
public readonly compensationErrors: { step: string; error: Error }[]
|
|
18
|
+
) {
|
|
19
|
+
const failures = compensationErrors.map(e => `${e.step}: ${e.error.message}`).join(', ')
|
|
20
|
+
super(
|
|
21
|
+
`Compensation failed for ${compensationErrors.length} step(s) [${failures}]. ` +
|
|
22
|
+
`Original error: ${originalError.message}`
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
package/src/helpers.ts
ADDED
package/src/index.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export { Workflow } from './workflow.ts'
|
|
2
|
+
export { workflow } from './helpers.ts'
|
|
3
|
+
export { WorkflowError, CompensationError } from './errors.ts'
|
|
4
|
+
export type {
|
|
5
|
+
WorkflowContext,
|
|
6
|
+
WorkflowResult,
|
|
7
|
+
StepHandler,
|
|
8
|
+
LoopHandler,
|
|
9
|
+
RouteResolver,
|
|
10
|
+
StepOptions,
|
|
11
|
+
ParallelEntry,
|
|
12
|
+
LoopOptions,
|
|
13
|
+
} from './types.ts'
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// ── Workflow Context ─────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
export interface WorkflowContext {
|
|
4
|
+
input: Record<string, unknown>
|
|
5
|
+
results: Record<string, unknown>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// ── Workflow Result ─────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface WorkflowResult {
|
|
11
|
+
results: Record<string, unknown>
|
|
12
|
+
duration: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ── Handlers ────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/** Handler for sequential, parallel, and route steps. Receives full context. */
|
|
18
|
+
export type StepHandler = (ctx: WorkflowContext) => Promise<unknown>
|
|
19
|
+
|
|
20
|
+
/** Handler for loop steps. Receives current iteration input + full context. */
|
|
21
|
+
export type LoopHandler = (input: unknown, ctx: WorkflowContext) => Promise<unknown>
|
|
22
|
+
|
|
23
|
+
/** Route resolver. Returns the branch key to dispatch to. */
|
|
24
|
+
export type RouteResolver = (ctx: WorkflowContext) => string | Promise<string>
|
|
25
|
+
|
|
26
|
+
// ── Step Options ────────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export interface StepOptions {
|
|
29
|
+
compensate?: (ctx: WorkflowContext) => Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ParallelEntry {
|
|
33
|
+
name: string
|
|
34
|
+
handler: StepHandler
|
|
35
|
+
compensate?: (ctx: WorkflowContext) => Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface LoopOptions<T = unknown> {
|
|
39
|
+
maxIterations: number
|
|
40
|
+
until?: (result: T, iteration: number) => boolean
|
|
41
|
+
feedback?: (result: T) => unknown
|
|
42
|
+
mapInput?: (ctx: WorkflowContext) => unknown
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── Internal Step Definitions ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
export interface SequentialStep {
|
|
48
|
+
type: 'step'
|
|
49
|
+
name: string
|
|
50
|
+
handler: StepHandler
|
|
51
|
+
compensate?: (ctx: WorkflowContext) => Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface ParallelStep {
|
|
55
|
+
type: 'parallel'
|
|
56
|
+
name: string
|
|
57
|
+
entries: ParallelEntry[]
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface RouteStep {
|
|
61
|
+
type: 'route'
|
|
62
|
+
name: string
|
|
63
|
+
resolver: RouteResolver
|
|
64
|
+
branches: Record<string, StepHandler>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface LoopStep {
|
|
68
|
+
type: 'loop'
|
|
69
|
+
name: string
|
|
70
|
+
handler: LoopHandler
|
|
71
|
+
maxIterations: number
|
|
72
|
+
until?: (result: unknown, iteration: number) => boolean
|
|
73
|
+
feedback?: (result: unknown) => unknown
|
|
74
|
+
mapInput?: (ctx: WorkflowContext) => unknown
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type WorkflowStep = SequentialStep | ParallelStep | RouteStep | LoopStep
|
package/src/workflow.ts
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
WorkflowContext,
|
|
3
|
+
WorkflowResult,
|
|
4
|
+
WorkflowStep,
|
|
5
|
+
StepHandler,
|
|
6
|
+
StepOptions,
|
|
7
|
+
ParallelEntry,
|
|
8
|
+
RouteResolver,
|
|
9
|
+
LoopHandler,
|
|
10
|
+
LoopOptions,
|
|
11
|
+
SequentialStep,
|
|
12
|
+
ParallelStep,
|
|
13
|
+
RouteStep,
|
|
14
|
+
LoopStep,
|
|
15
|
+
} from './types.ts'
|
|
16
|
+
import { CompensationError } from './errors.ts'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* General-purpose workflow orchestrator.
|
|
20
|
+
*
|
|
21
|
+
* Supports sequential steps, parallel fan-out, conditional routing, and loops.
|
|
22
|
+
* Each step is an arbitrary async function that receives and extends a shared context.
|
|
23
|
+
* Steps can optionally define compensation functions for saga-style rollback.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* const result = await new Workflow('order-process')
|
|
27
|
+
* .step('validate', async (ctx) => validateOrder(ctx.input.orderId))
|
|
28
|
+
* .step('charge', async (ctx) => chargeCard(ctx.results.validate.total), {
|
|
29
|
+
* compensate: async (ctx) => refundCharge(ctx.results.charge.id),
|
|
30
|
+
* })
|
|
31
|
+
* .parallel('notify', [
|
|
32
|
+
* { name: 'email', handler: async (ctx) => sendEmail(ctx) },
|
|
33
|
+
* { name: 'slack', handler: async (ctx) => notifySlack(ctx) },
|
|
34
|
+
* ])
|
|
35
|
+
* .run({ orderId: 123 })
|
|
36
|
+
*/
|
|
37
|
+
export class Workflow {
|
|
38
|
+
private steps: WorkflowStep[] = []
|
|
39
|
+
|
|
40
|
+
constructor(private name: string) {}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Add a sequential step. Runs after all previous steps complete.
|
|
44
|
+
* The handler receives the workflow context and its return value is stored in `ctx.results[name]`.
|
|
45
|
+
*/
|
|
46
|
+
step(name: string, handler: StepHandler, options?: StepOptions): this {
|
|
47
|
+
this.steps.push({
|
|
48
|
+
type: 'step',
|
|
49
|
+
name,
|
|
50
|
+
handler,
|
|
51
|
+
compensate: options?.compensate,
|
|
52
|
+
})
|
|
53
|
+
return this
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Run multiple handlers in parallel. Each handler's result is stored under its name.
|
|
58
|
+
*/
|
|
59
|
+
parallel(name: string, entries: ParallelEntry[]): this {
|
|
60
|
+
this.steps.push({ type: 'parallel', name, entries })
|
|
61
|
+
return this
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Route to a branch based on a resolver function's return value.
|
|
66
|
+
* The resolver returns a string key that maps to one of the branches.
|
|
67
|
+
* If no branch matches, the step completes silently with no result.
|
|
68
|
+
*/
|
|
69
|
+
route(name: string, resolver: RouteResolver, branches: Record<string, StepHandler>): this {
|
|
70
|
+
this.steps.push({ type: 'route', name, resolver, branches })
|
|
71
|
+
return this
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Run a handler in a loop until a condition is met or max iterations reached.
|
|
76
|
+
* Use `feedback` to transform the result into the next iteration's input.
|
|
77
|
+
* Use `mapInput` to derive the initial input from context.
|
|
78
|
+
*/
|
|
79
|
+
loop(name: string, handler: LoopHandler, options: LoopOptions): this {
|
|
80
|
+
this.steps.push({
|
|
81
|
+
type: 'loop',
|
|
82
|
+
name,
|
|
83
|
+
handler,
|
|
84
|
+
maxIterations: options.maxIterations,
|
|
85
|
+
until: options.until,
|
|
86
|
+
feedback: options.feedback,
|
|
87
|
+
mapInput: options.mapInput,
|
|
88
|
+
})
|
|
89
|
+
return this
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Execute the workflow. */
|
|
93
|
+
async run(input: Record<string, unknown>): Promise<WorkflowResult> {
|
|
94
|
+
const start = performance.now()
|
|
95
|
+
const ctx: WorkflowContext = { input, results: {} }
|
|
96
|
+
const completed: WorkflowStep[] = []
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
for (const step of this.steps) {
|
|
100
|
+
switch (step.type) {
|
|
101
|
+
case 'step':
|
|
102
|
+
await this.runStep(step, ctx)
|
|
103
|
+
break
|
|
104
|
+
case 'parallel':
|
|
105
|
+
await this.runParallel(step, ctx)
|
|
106
|
+
break
|
|
107
|
+
case 'route':
|
|
108
|
+
await this.runRoute(step, ctx)
|
|
109
|
+
break
|
|
110
|
+
case 'loop':
|
|
111
|
+
await this.runLoop(step, ctx)
|
|
112
|
+
break
|
|
113
|
+
}
|
|
114
|
+
completed.push(step)
|
|
115
|
+
}
|
|
116
|
+
} catch (error) {
|
|
117
|
+
await this.compensate(completed, ctx, error as Error)
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return { results: ctx.results, duration: performance.now() - start }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ── Step Executors ─────────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
private async runStep(step: SequentialStep, ctx: WorkflowContext): Promise<void> {
|
|
127
|
+
const result = await step.handler(ctx)
|
|
128
|
+
ctx.results[step.name] = result
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private async runParallel(step: ParallelStep, ctx: WorkflowContext): Promise<void> {
|
|
132
|
+
const promises = step.entries.map(async entry => {
|
|
133
|
+
const result = await entry.handler(ctx)
|
|
134
|
+
ctx.results[entry.name] = result
|
|
135
|
+
})
|
|
136
|
+
await Promise.all(promises)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private async runRoute(step: RouteStep, ctx: WorkflowContext): Promise<void> {
|
|
140
|
+
const routeKey = await step.resolver(ctx)
|
|
141
|
+
const branchHandler = step.branches[routeKey]
|
|
142
|
+
|
|
143
|
+
if (branchHandler) {
|
|
144
|
+
const result = await branchHandler(ctx)
|
|
145
|
+
ctx.results[step.name] = result
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private async runLoop(step: LoopStep, ctx: WorkflowContext): Promise<void> {
|
|
150
|
+
let currentInput: unknown = step.mapInput ? step.mapInput(ctx) : ctx.input
|
|
151
|
+
let lastResult: unknown
|
|
152
|
+
|
|
153
|
+
for (let i = 0; i < step.maxIterations; i++) {
|
|
154
|
+
lastResult = await step.handler(currentInput, ctx)
|
|
155
|
+
|
|
156
|
+
if (step.until?.(lastResult, i + 1)) break
|
|
157
|
+
|
|
158
|
+
if (step.feedback) {
|
|
159
|
+
currentInput = step.feedback(lastResult)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
if (lastResult !== undefined) {
|
|
164
|
+
ctx.results[step.name] = lastResult
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ── Compensation ───────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
private async compensate(
|
|
171
|
+
completed: WorkflowStep[],
|
|
172
|
+
ctx: WorkflowContext,
|
|
173
|
+
originalError: Error
|
|
174
|
+
): Promise<void> {
|
|
175
|
+
const compensationErrors: { step: string; error: Error }[] = []
|
|
176
|
+
|
|
177
|
+
// Run compensation in reverse order
|
|
178
|
+
for (let i = completed.length - 1; i >= 0; i--) {
|
|
179
|
+
const step = completed[i]!
|
|
180
|
+
const compensators = this.getCompensators(step)
|
|
181
|
+
|
|
182
|
+
for (const { name, compensate } of compensators) {
|
|
183
|
+
try {
|
|
184
|
+
await compensate(ctx)
|
|
185
|
+
} catch (err) {
|
|
186
|
+
compensationErrors.push({ step: name, error: err as Error })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (compensationErrors.length > 0) {
|
|
192
|
+
throw new CompensationError(originalError, compensationErrors)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
private getCompensators(
|
|
197
|
+
step: WorkflowStep
|
|
198
|
+
): { name: string; compensate: (ctx: WorkflowContext) => Promise<void> }[] {
|
|
199
|
+
switch (step.type) {
|
|
200
|
+
case 'step':
|
|
201
|
+
return step.compensate ? [{ name: step.name, compensate: step.compensate }] : []
|
|
202
|
+
case 'parallel':
|
|
203
|
+
return step.entries
|
|
204
|
+
.filter(e => e.compensate)
|
|
205
|
+
.map(e => ({ name: e.name, compensate: e.compensate! }))
|
|
206
|
+
default:
|
|
207
|
+
return []
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
package/tsconfig.json
ADDED