@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 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
@@ -0,0 +1,6 @@
1
+ import { Workflow } from './workflow.ts'
2
+
3
+ /** Create a new workflow instance. */
4
+ export function workflow(name: string): Workflow {
5
+ return new Workflow(name)
6
+ }
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
@@ -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
@@ -0,0 +1,4 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "include": ["src/**/*.ts"]
4
+ }