ai-workflows 2.1.3 → 2.4.0

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.
Files changed (188) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/CHANGELOG.md +14 -1
  3. package/README.md +2 -0
  4. package/dist/barrier.d.ts +6 -0
  5. package/dist/barrier.d.ts.map +1 -1
  6. package/dist/barrier.js +45 -7
  7. package/dist/barrier.js.map +1 -1
  8. package/dist/cascade-context.d.ts.map +1 -1
  9. package/dist/cascade-context.js +25 -25
  10. package/dist/cascade-context.js.map +1 -1
  11. package/dist/cascade-executor.d.ts.map +1 -1
  12. package/dist/cascade-executor.js +1 -1
  13. package/dist/cascade-executor.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +23 -7
  16. package/dist/context.js.map +1 -1
  17. package/dist/cron-parser.d.ts +65 -0
  18. package/dist/cron-parser.d.ts.map +1 -0
  19. package/dist/cron-parser.js +294 -0
  20. package/dist/cron-parser.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +117 -0
  22. package/dist/cron-scheduler.d.ts.map +1 -0
  23. package/dist/cron-scheduler.js +176 -0
  24. package/dist/cron-scheduler.js.map +1 -0
  25. package/dist/database-context.d.ts +184 -0
  26. package/dist/database-context.d.ts.map +1 -0
  27. package/dist/database-context.js +428 -0
  28. package/dist/database-context.js.map +1 -0
  29. package/dist/digital-objects-adapter.d.ts +159 -0
  30. package/dist/digital-objects-adapter.d.ts.map +1 -0
  31. package/dist/digital-objects-adapter.js +229 -0
  32. package/dist/digital-objects-adapter.js.map +1 -0
  33. package/dist/durable-execution-cloudflare.d.ts +427 -0
  34. package/dist/durable-execution-cloudflare.d.ts.map +1 -0
  35. package/dist/durable-execution-cloudflare.js +510 -0
  36. package/dist/durable-execution-cloudflare.js.map +1 -0
  37. package/dist/durable-execution.d.ts +482 -0
  38. package/dist/durable-execution.d.ts.map +1 -0
  39. package/dist/durable-execution.js +594 -0
  40. package/dist/durable-execution.js.map +1 -0
  41. package/dist/durable-workflow.d.ts +176 -0
  42. package/dist/durable-workflow.d.ts.map +1 -0
  43. package/dist/durable-workflow.js +552 -0
  44. package/dist/durable-workflow.js.map +1 -0
  45. package/dist/graph/topological-sort.d.ts.map +1 -1
  46. package/dist/graph/topological-sort.js +5 -5
  47. package/dist/graph/topological-sort.js.map +1 -1
  48. package/dist/index.d.ts +4 -0
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +15 -0
  51. package/dist/index.js.map +1 -1
  52. package/dist/logger.d.ts +101 -0
  53. package/dist/logger.d.ts.map +1 -0
  54. package/dist/logger.js +115 -0
  55. package/dist/logger.js.map +1 -0
  56. package/dist/on.d.ts.map +1 -1
  57. package/dist/on.js +3 -3
  58. package/dist/on.js.map +1 -1
  59. package/dist/runtime.d.ts +169 -0
  60. package/dist/runtime.d.ts.map +1 -0
  61. package/dist/runtime.js +275 -0
  62. package/dist/runtime.js.map +1 -0
  63. package/dist/send.d.ts.map +1 -1
  64. package/dist/send.js +4 -3
  65. package/dist/send.js.map +1 -1
  66. package/dist/telemetry.d.ts +150 -0
  67. package/dist/telemetry.d.ts.map +1 -0
  68. package/dist/telemetry.js +388 -0
  69. package/dist/telemetry.js.map +1 -0
  70. package/dist/timer-registry.d.ts +25 -0
  71. package/dist/timer-registry.d.ts.map +1 -1
  72. package/dist/timer-registry.js +42 -8
  73. package/dist/timer-registry.js.map +1 -1
  74. package/dist/types.d.ts +17 -6
  75. package/dist/types.d.ts.map +1 -1
  76. package/dist/types.js +1 -1
  77. package/dist/types.js.map +1 -1
  78. package/dist/worker/durable-step.d.ts +481 -0
  79. package/dist/worker/durable-step.d.ts.map +1 -0
  80. package/dist/worker/durable-step.js +606 -0
  81. package/dist/worker/durable-step.js.map +1 -0
  82. package/dist/worker/index.d.ts +106 -0
  83. package/dist/worker/index.d.ts.map +1 -0
  84. package/dist/worker/index.js +124 -0
  85. package/dist/worker/index.js.map +1 -0
  86. package/dist/worker/state-adapter.d.ts +230 -0
  87. package/dist/worker/state-adapter.d.ts.map +1 -0
  88. package/dist/worker/state-adapter.js +409 -0
  89. package/dist/worker/state-adapter.js.map +1 -0
  90. package/dist/worker/topological-executor.d.ts +282 -0
  91. package/dist/worker/topological-executor.d.ts.map +1 -0
  92. package/dist/worker/topological-executor.js +396 -0
  93. package/dist/worker/topological-executor.js.map +1 -0
  94. package/dist/worker/workflow-builder.d.ts +286 -0
  95. package/dist/worker/workflow-builder.d.ts.map +1 -0
  96. package/dist/worker/workflow-builder.js +565 -0
  97. package/dist/worker/workflow-builder.js.map +1 -0
  98. package/dist/worker.d.ts +800 -0
  99. package/dist/worker.d.ts.map +1 -0
  100. package/dist/worker.js +2428 -0
  101. package/dist/worker.js.map +1 -0
  102. package/dist/workflow-builder.d.ts +287 -0
  103. package/dist/workflow-builder.d.ts.map +1 -0
  104. package/dist/workflow-builder.js +762 -0
  105. package/dist/workflow-builder.js.map +1 -0
  106. package/dist/workflow.d.ts +14 -30
  107. package/dist/workflow.d.ts.map +1 -1
  108. package/dist/workflow.js +132 -292
  109. package/dist/workflow.js.map +1 -1
  110. package/examples/01-ecommerce-order-pipeline.ts +358 -0
  111. package/examples/02-content-moderation-cascade.ts +454 -0
  112. package/examples/03-scheduled-reporting-dependencies.ts +479 -0
  113. package/examples/04-database-persistence.ts +518 -0
  114. package/examples/README.md +173 -0
  115. package/package.json +30 -13
  116. package/src/__tests__/digital-objects-adapter.test.ts +274 -0
  117. package/src/__tests__/durable-workflow.test.ts +297 -0
  118. package/src/barrier.ts +48 -7
  119. package/src/cascade-context.ts +36 -29
  120. package/src/cascade-executor.ts +3 -2
  121. package/src/context.ts +41 -12
  122. package/src/cron-parser.ts +347 -0
  123. package/src/cron-scheduler.ts +239 -0
  124. package/src/database-context.ts +658 -0
  125. package/src/digital-objects-adapter.ts +351 -0
  126. package/src/durable-execution-cloudflare.ts +855 -0
  127. package/src/durable-execution.ts +1042 -0
  128. package/src/durable-workflow.ts +717 -0
  129. package/src/graph/topological-sort.ts +6 -8
  130. package/src/index.ts +69 -0
  131. package/src/logger.ts +148 -0
  132. package/src/on.ts +8 -9
  133. package/src/runtime.ts +436 -0
  134. package/src/send.ts +4 -5
  135. package/src/telemetry.ts +577 -0
  136. package/src/timer-registry.ts +44 -10
  137. package/src/types.ts +32 -17
  138. package/src/worker/durable-step.ts +976 -0
  139. package/src/worker/index.ts +216 -0
  140. package/src/worker/state-adapter.ts +589 -0
  141. package/src/worker/topological-executor.ts +625 -0
  142. package/src/worker/workflow-builder.ts +871 -0
  143. package/src/worker.ts +2906 -0
  144. package/src/workflow-builder.ts +1068 -0
  145. package/src/workflow.ts +188 -351
  146. package/test/barrier-join.test.ts +32 -24
  147. package/test/cascade-executor.test.ts +9 -16
  148. package/test/cron-parser.test.ts +314 -0
  149. package/test/cron-scheduler.test.ts +291 -0
  150. package/test/database-context.test.ts +770 -0
  151. package/test/db-provider-adapter.test.ts +862 -0
  152. package/test/durable-execution-cloudflare.test.ts +606 -0
  153. package/test/durable-execution-in-process.test.ts +286 -0
  154. package/test/durable-execution.test.ts +247 -0
  155. package/test/e2e/workflow-scenarios.e2e.test.ts +1039 -0
  156. package/test/integration.test.ts +442 -0
  157. package/test/rpc-surface.test.ts +946 -0
  158. package/test/runtime.test.ts +262 -0
  159. package/test/schedule-timer-cleanup.test.ts +30 -21
  160. package/test/send-race-conditions.test.ts +30 -40
  161. package/test/worker/durable-cascade.test.ts +1117 -0
  162. package/test/worker/durable-step.test.ts +723 -0
  163. package/test/worker/topological-executor.test.ts +1240 -0
  164. package/test/worker/workflow-builder.test.ts +1067 -0
  165. package/test/worker.test.ts +608 -0
  166. package/test/workflow-builder.test.ts +1670 -0
  167. package/test/workflow-cron.test.ts +256 -0
  168. package/test/workflow-state-adapter.test.ts +923 -0
  169. package/test/workflow.test.ts +25 -22
  170. package/tsconfig.json +3 -1
  171. package/vitest.config.ts +38 -1
  172. package/vitest.workers.config.ts +44 -0
  173. package/wrangler.jsonc +22 -0
  174. package/.turbo/turbo-test.log +0 -169
  175. package/LICENSE +0 -21
  176. package/src/context.js +0 -83
  177. package/src/every.js +0 -267
  178. package/src/index.js +0 -71
  179. package/src/on.js +0 -79
  180. package/src/send.js +0 -111
  181. package/src/types.js +0 -4
  182. package/src/workflow.js +0 -455
  183. package/test/context.test.js +0 -116
  184. package/test/every.test.js +0 -282
  185. package/test/on.test.js +0 -80
  186. package/test/send.test.js +0 -89
  187. package/test/workflow.test.js +0 -224
  188. package/vitest.config.js +0 -7
@@ -0,0 +1,1068 @@
1
+ /**
2
+ * WorkflowBuilder DSL - Fluent API for building durable workflows
3
+ *
4
+ * Provides a declarative DSL for workflow definition with:
5
+ * - Sequential steps with .step()
6
+ * - Parallel execution with .parallel()
7
+ * - Conditional branching with .when().then().else()
8
+ * - Loops with .loop() and .forEach()
9
+ * - Error handling with .onError()
10
+ * - Timeouts with .timeout()
11
+ * - Retries with .retry()
12
+ *
13
+ * @example
14
+ * ```typescript
15
+ * import { workflow } from 'ai-workflows'
16
+ *
17
+ * const orderWorkflow = workflow('order-process')
18
+ * .step('validate', async (input) => ({ valid: true, ...input }))
19
+ * .when(ctx => ctx.result.valid)
20
+ * .then(
21
+ * workflow('charge-flow')
22
+ * .step('charge', async () => ({ charged: true }))
23
+ * )
24
+ * .step('fulfill', fulfillOrder)
25
+ * .timeout(5000)
26
+ * .retry({ attempts: 3, backoff: 'exponential' })
27
+ * .build()
28
+ *
29
+ * const result = await orderWorkflow.execute({ orderId: '123' })
30
+ * ```
31
+ *
32
+ * @packageDocumentation
33
+ */
34
+
35
+ // ============================================================================
36
+ // Type Definitions
37
+ // ============================================================================
38
+
39
+ /**
40
+ * Retry configuration for steps
41
+ */
42
+ export interface RetryConfig {
43
+ /** Maximum number of attempts */
44
+ attempts: number
45
+ /** Backoff strategy */
46
+ backoff?: 'constant' | 'linear' | 'exponential'
47
+ /** Base delay in milliseconds */
48
+ delay?: number
49
+ /** Maximum delay cap in milliseconds */
50
+ maxDelay?: number
51
+ /** Add randomness to delays */
52
+ jitter?: boolean
53
+ /** Condition to determine if retry should happen */
54
+ retryIf?: (error: Error, attempt: number) => boolean
55
+ }
56
+
57
+ /**
58
+ * Loop options
59
+ */
60
+ export interface LoopOptions {
61
+ /** Maximum number of iterations (safety limit) */
62
+ maxIterations?: number
63
+ /** Throw error when max iterations exceeded */
64
+ throwOnMaxIterations?: boolean
65
+ }
66
+
67
+ /**
68
+ * ForEach options
69
+ */
70
+ export interface ForEachOptions {
71
+ /** Concurrency level for parallel iteration */
72
+ concurrency?: number
73
+ }
74
+
75
+ /**
76
+ * Step context passed to handlers and conditions
77
+ */
78
+ export interface StepContext {
79
+ /** Original workflow input */
80
+ input: unknown
81
+ /** Current accumulated result */
82
+ result: Record<string, unknown>
83
+ /** Current step name (available in error handlers) */
84
+ currentStep?: string
85
+ /** Retry the current step */
86
+ retry: () => Promise<unknown>
87
+ /** Skip to the next step with a result */
88
+ skip: (result: unknown) => { __skip: true; result: unknown }
89
+ /** Abort the workflow */
90
+ abort: (reason?: string) => never
91
+ }
92
+
93
+ /**
94
+ * Step definition stored in the builder
95
+ */
96
+ export interface StepDefinition {
97
+ /** Step type */
98
+ type: 'step' | 'parallel' | 'conditional' | 'loop' | 'forEach'
99
+ /** Step name (for regular steps) */
100
+ name?: string
101
+ /** Step function (for regular steps) */
102
+ fn?: StepFunction<unknown, unknown>
103
+ /** Parallel steps */
104
+ parallelSteps?: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
105
+ /** Conditional configuration */
106
+ conditional?: {
107
+ condition: ConditionFunction
108
+ thenBranch: WorkflowBuilder<unknown, unknown>
109
+ elseBranch?: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
110
+ }
111
+ /** Loop configuration */
112
+ loop?: {
113
+ condition: ConditionFunction
114
+ body: WorkflowBuilder<unknown, unknown>
115
+ options?: LoopOptions
116
+ }
117
+ /** ForEach configuration */
118
+ forEach?: {
119
+ itemsSelector: (ctx: StepContext) => unknown[]
120
+ body: WorkflowBuilder<unknown, unknown>
121
+ options?: ForEachOptions
122
+ }
123
+ /** Timeout in milliseconds */
124
+ timeout?: number
125
+ /** Retry configuration */
126
+ retry?: RetryConfig
127
+ /** Error handler */
128
+ errorHandler?: ErrorHandler
129
+ }
130
+
131
+ /**
132
+ * Step function type
133
+ */
134
+ export type StepFunction<TInput, TOutput> = (input: TInput, ctx?: StepContext) => Promise<TOutput>
135
+
136
+ /**
137
+ * Condition function type
138
+ */
139
+ export type ConditionFunction = (ctx: StepContext) => boolean | Promise<boolean>
140
+
141
+ /**
142
+ * Error handler type
143
+ */
144
+ export type ErrorHandler = (error: Error, ctx: StepContext) => unknown | Promise<unknown>
145
+
146
+ /**
147
+ * Built workflow definition
148
+ */
149
+ export interface BuiltWorkflow<TInput = unknown, TOutput = unknown> {
150
+ /** Workflow name */
151
+ readonly name: string
152
+ /** All registered steps */
153
+ readonly steps: ReadonlyArray<StepDefinition>
154
+ /** Default retry configuration */
155
+ readonly defaultRetryConfig?: RetryConfig
156
+ /** Execute the workflow */
157
+ execute: (input?: TInput) => Promise<TOutput>
158
+ }
159
+
160
+ /**
161
+ * Conditional chain for .when().then().else()
162
+ */
163
+ export interface ConditionalChain<TInput, TOutput> {
164
+ /** Execute branch when condition is true */
165
+ then(
166
+ branch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
167
+ ): ConditionalChainWithThen<TInput, TOutput>
168
+ }
169
+
170
+ /**
171
+ * Conditional chain after .then()
172
+ */
173
+ export interface ConditionalChainWithThen<TInput, TOutput> {
174
+ /** Execute branch when condition is false */
175
+ else(
176
+ branch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
177
+ ): WorkflowBuilder<TInput, TOutput>
178
+ /** Add another step */
179
+ step<TO = unknown>(name: string, fn: StepFunction<unknown, TO>): WorkflowBuilder<TInput, TOutput>
180
+ /** Add parallel steps */
181
+ parallel(
182
+ steps: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
183
+ ): WorkflowBuilder<TInput, TOutput>
184
+ /** Add conditional */
185
+ when(condition: ConditionFunction): ConditionalChain<TInput, TOutput>
186
+ /** Add loop */
187
+ loop(
188
+ condition: ConditionFunction,
189
+ body: WorkflowBuilder<unknown, unknown>,
190
+ options?: LoopOptions
191
+ ): WorkflowBuilder<TInput, TOutput>
192
+ /** Add forEach */
193
+ forEach(
194
+ itemsSelector: (ctx: StepContext) => unknown[],
195
+ body: WorkflowBuilder<unknown, unknown>,
196
+ options?: ForEachOptions
197
+ ): WorkflowBuilder<TInput, TOutput>
198
+ /** Set error handler */
199
+ onError(handler: ErrorHandler): WorkflowBuilder<TInput, TOutput>
200
+ /** Set timeout */
201
+ timeout(ms: number | string): WorkflowBuilder<TInput, TOutput>
202
+ /** Set retry config */
203
+ retry(config: RetryConfig): WorkflowBuilder<TInput, TOutput>
204
+ /** Build the workflow */
205
+ build(): BuiltWorkflow<TInput, TOutput>
206
+ }
207
+
208
+ /**
209
+ * Loop chain type
210
+ */
211
+ export type LoopChain = WorkflowBuilder<unknown, unknown>
212
+
213
+ /**
214
+ * Step chain type (same as WorkflowBuilder)
215
+ */
216
+ export type StepChain = WorkflowBuilder<unknown, unknown>
217
+
218
+ /**
219
+ * Workflow definition type alias
220
+ */
221
+ export type WorkflowDefinition = BuiltWorkflow<unknown, unknown>
222
+
223
+ // ============================================================================
224
+ // Helper Functions
225
+ // ============================================================================
226
+
227
+ /**
228
+ * Parse duration string to milliseconds
229
+ */
230
+ function parseDuration(duration: string | number): number {
231
+ if (typeof duration === 'number') return duration
232
+ const match = duration.match(/^(\d+)(ms|s|m|h)?$/)
233
+ if (!match || match[1] === undefined) return parseInt(duration, 10)
234
+ const value = parseInt(match[1], 10)
235
+ const unit = match[2] || 'ms'
236
+ switch (unit) {
237
+ case 's':
238
+ return value * 1000
239
+ case 'm':
240
+ return value * 60 * 1000
241
+ case 'h':
242
+ return value * 60 * 60 * 1000
243
+ default:
244
+ return value
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Calculate backoff delay
250
+ */
251
+ function calculateBackoff(attempt: number, config: RetryConfig): number {
252
+ const baseDelay = config.delay || 100
253
+ let delay: number
254
+
255
+ switch (config.backoff) {
256
+ case 'linear':
257
+ delay = baseDelay * attempt
258
+ break
259
+ case 'exponential':
260
+ delay = baseDelay * Math.pow(2, attempt - 1)
261
+ break
262
+ case 'constant':
263
+ default:
264
+ delay = baseDelay
265
+ }
266
+
267
+ // Apply max delay cap
268
+ if (config.maxDelay) {
269
+ delay = Math.min(delay, config.maxDelay)
270
+ }
271
+
272
+ // Apply jitter
273
+ if (config.jitter) {
274
+ delay = delay * (0.5 + Math.random())
275
+ }
276
+
277
+ return delay
278
+ }
279
+
280
+ /**
281
+ * Execute with timeout
282
+ */
283
+ async function withTimeout<T>(promise: Promise<T>, ms: number, stepName?: string): Promise<T> {
284
+ return new Promise<T>((resolve, reject) => {
285
+ const timer = setTimeout(() => {
286
+ reject(new Error(`Timeout: step "${stepName || 'unknown'}" exceeded ${ms}ms`))
287
+ }, ms)
288
+
289
+ promise
290
+ .then((result) => {
291
+ clearTimeout(timer)
292
+ resolve(result)
293
+ })
294
+ .catch((error) => {
295
+ clearTimeout(timer)
296
+ reject(error)
297
+ })
298
+ })
299
+ }
300
+
301
+ /**
302
+ * Execute with retry
303
+ */
304
+ async function withRetry<T>(
305
+ fn: () => Promise<T>,
306
+ config: RetryConfig,
307
+ stepName?: string
308
+ ): Promise<T> {
309
+ let lastError: Error | undefined
310
+ for (let attempt = 1; attempt <= config.attempts; attempt++) {
311
+ try {
312
+ return await fn()
313
+ } catch (error) {
314
+ lastError = error as Error
315
+
316
+ // Check if we should retry
317
+ if (config.retryIf && !config.retryIf(lastError, attempt)) {
318
+ throw lastError
319
+ }
320
+
321
+ // Don't wait after last attempt
322
+ if (attempt < config.attempts) {
323
+ const delay = calculateBackoff(attempt, config)
324
+ await new Promise((r) => setTimeout(r, delay))
325
+ }
326
+ }
327
+ }
328
+ throw lastError
329
+ }
330
+
331
+ // ============================================================================
332
+ // WorkflowBuilder Implementation
333
+ // ============================================================================
334
+
335
+ /**
336
+ * WorkflowBuilder - Fluent DSL for building durable workflows
337
+ */
338
+ export class WorkflowBuilder<TInput = unknown, TOutput = unknown> {
339
+ /** Workflow name */
340
+ readonly name: string
341
+
342
+ private _steps: StepDefinition[] = []
343
+ private _stepNames: Set<string> = new Set()
344
+ private _defaultRetryConfig?: RetryConfig
345
+ private _workflowErrorHandler?: ErrorHandler
346
+ private _workflowTimeout?: number
347
+ // Track if the last operation was a configuration (timeout/retry) without step
348
+ private _lastOpWasConfig: boolean = false
349
+ // Track which step index the last config applied to (-1 for workflow level)
350
+ private _lastConfigStepIndex: number = -1
351
+ // Track the step index that was most recently directly configured (for step-level config)
352
+ private _lastDirectlyConfiguredStep: number = -1
353
+
354
+ constructor(name: string) {
355
+ if (!name || name.trim() === '') {
356
+ throw new Error('Workflow name is required')
357
+ }
358
+ this.name = name
359
+ }
360
+
361
+ /**
362
+ * Add a sequential step
363
+ */
364
+ step<TI = unknown, TO = unknown>(
365
+ name: string,
366
+ fn: StepFunction<TI, TO>
367
+ ): WorkflowBuilder<TInput, TOutput> {
368
+ // Defer duplicate check to build() for immutability
369
+ this._steps.push({
370
+ type: 'step',
371
+ name,
372
+ fn: fn as StepFunction<unknown, unknown>,
373
+ })
374
+ this._lastOpWasConfig = false
375
+ this._lastConfigStepIndex = -1
376
+ return this
377
+ }
378
+
379
+ /**
380
+ * Add parallel steps
381
+ */
382
+ parallel(
383
+ steps: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
384
+ ): WorkflowBuilder<TInput, TOutput> {
385
+ this._steps.push({
386
+ type: 'parallel',
387
+ parallelSteps: steps,
388
+ })
389
+ this._lastOpWasConfig = false
390
+ this._lastConfigStepIndex = -1
391
+ return this
392
+ }
393
+
394
+ /**
395
+ * Add conditional branching
396
+ */
397
+ when(condition: ConditionFunction): ConditionalChain<TInput, TOutput> {
398
+ const self = this
399
+ return {
400
+ then(
401
+ branch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
402
+ ): ConditionalChainWithThen<TInput, TOutput> {
403
+ const thenBranch =
404
+ branch instanceof WorkflowBuilder
405
+ ? branch
406
+ : workflow('inline-then').step('inline', branch as StepFunction<unknown, unknown>)
407
+
408
+ // Create step definition but don't add it yet
409
+ const stepDef: StepDefinition = {
410
+ type: 'conditional',
411
+ conditional: {
412
+ condition,
413
+ thenBranch: thenBranch as WorkflowBuilder<unknown, unknown>,
414
+ },
415
+ }
416
+
417
+ return {
418
+ else(
419
+ elseBranch: WorkflowBuilder<unknown, unknown> | StepFunction<unknown, unknown>
420
+ ): WorkflowBuilder<TInput, TOutput> {
421
+ const resolvedElseBranch =
422
+ elseBranch instanceof WorkflowBuilder
423
+ ? elseBranch
424
+ : workflow('inline-else').step(
425
+ 'inline',
426
+ elseBranch as StepFunction<unknown, unknown>
427
+ )
428
+ stepDef.conditional!.elseBranch = resolvedElseBranch as WorkflowBuilder<
429
+ unknown,
430
+ unknown
431
+ >
432
+ self._steps.push(stepDef)
433
+ return self
434
+ },
435
+ step<TO = unknown>(
436
+ name: string,
437
+ fn: StepFunction<unknown, TO>
438
+ ): WorkflowBuilder<TInput, TOutput> {
439
+ self._steps.push(stepDef)
440
+ return self.step(name, fn)
441
+ },
442
+ parallel(
443
+ steps: Array<{ name: string; fn: StepFunction<unknown, unknown> }>
444
+ ): WorkflowBuilder<TInput, TOutput> {
445
+ self._steps.push(stepDef)
446
+ return self.parallel(steps)
447
+ },
448
+ when(cond: ConditionFunction): ConditionalChain<TInput, TOutput> {
449
+ self._steps.push(stepDef)
450
+ return self.when(cond)
451
+ },
452
+ loop(
453
+ cond: ConditionFunction,
454
+ body: WorkflowBuilder<unknown, unknown>,
455
+ options?: LoopOptions
456
+ ): WorkflowBuilder<TInput, TOutput> {
457
+ self._steps.push(stepDef)
458
+ return self.loop(cond, body, options)
459
+ },
460
+ forEach(
461
+ itemsSelector: (ctx: StepContext) => unknown[],
462
+ body: WorkflowBuilder<unknown, unknown>,
463
+ options?: ForEachOptions
464
+ ): WorkflowBuilder<TInput, TOutput> {
465
+ self._steps.push(stepDef)
466
+ return self.forEach(itemsSelector, body, options)
467
+ },
468
+ onError(handler: ErrorHandler): WorkflowBuilder<TInput, TOutput> {
469
+ self._steps.push(stepDef)
470
+ return self.onError(handler)
471
+ },
472
+ timeout(ms: number | string): WorkflowBuilder<TInput, TOutput> {
473
+ self._steps.push(stepDef)
474
+ return self.timeout(ms)
475
+ },
476
+ retry(config: RetryConfig): WorkflowBuilder<TInput, TOutput> {
477
+ self._steps.push(stepDef)
478
+ return self.retry(config)
479
+ },
480
+ build(): BuiltWorkflow<TInput, TOutput> {
481
+ self._steps.push(stepDef)
482
+ return self.build()
483
+ },
484
+ }
485
+ },
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Add a loop
491
+ */
492
+ loop(
493
+ condition: ConditionFunction,
494
+ body: WorkflowBuilder<unknown, unknown>,
495
+ options?: LoopOptions
496
+ ): WorkflowBuilder<TInput, TOutput> {
497
+ this._steps.push({
498
+ type: 'loop',
499
+ loop: {
500
+ condition,
501
+ body,
502
+ ...(options !== undefined && { options }),
503
+ },
504
+ })
505
+ this._lastOpWasConfig = false
506
+ this._lastConfigStepIndex = -1
507
+ return this
508
+ }
509
+
510
+ /**
511
+ * Add forEach iteration
512
+ */
513
+ forEach(
514
+ itemsSelector: (ctx: StepContext) => unknown[],
515
+ body: WorkflowBuilder<unknown, unknown>,
516
+ options?: ForEachOptions
517
+ ): WorkflowBuilder<TInput, TOutput> {
518
+ this._steps.push({
519
+ type: 'forEach',
520
+ forEach: {
521
+ itemsSelector,
522
+ body,
523
+ ...(options !== undefined && { options }),
524
+ },
525
+ })
526
+ this._lastOpWasConfig = false
527
+ this._lastConfigStepIndex = -1
528
+ return this
529
+ }
530
+
531
+ /**
532
+ * Set error handler for the most recent step or workflow
533
+ *
534
+ * Rules:
535
+ * - If no steps exist, applies to workflow
536
+ * - If last config (timeout/retry) was workflow-level, this is workflow-level too
537
+ * - If last config was step-level, this applies to that same step
538
+ * - If multiple steps since last config, this is workflow-level
539
+ */
540
+ onError(handler: ErrorHandler): WorkflowBuilder<TInput, TOutput> {
541
+ const lastStepIndex = this._steps.length - 1
542
+
543
+ // Determine if this should be workflow-level or step-level
544
+ let isWorkflowLevel = false
545
+
546
+ if (this._steps.length === 0) {
547
+ isWorkflowLevel = true
548
+ } else if (this._lastOpWasConfig && this._lastConfigStepIndex === -1) {
549
+ // Last config was workflow-level (e.g., workflow timeout)
550
+ isWorkflowLevel = true
551
+ } else if (this._lastConfigStepIndex === lastStepIndex) {
552
+ // Last config was for the most recent step - stay step-level
553
+ isWorkflowLevel = false
554
+ } else {
555
+ // Check if multiple steps since last config
556
+ const unconfiguredStepsCount = lastStepIndex - this._lastDirectlyConfiguredStep
557
+ if (unconfiguredStepsCount > 1) {
558
+ isWorkflowLevel = true
559
+ } else if (unconfiguredStepsCount === 1) {
560
+ // One unconfigured step - apply to that step
561
+ isWorkflowLevel = false
562
+ this._lastDirectlyConfiguredStep = lastStepIndex
563
+ } else {
564
+ isWorkflowLevel = false
565
+ }
566
+ }
567
+
568
+ if (isWorkflowLevel) {
569
+ // Workflow-level error handler
570
+ if (this._workflowErrorHandler) {
571
+ // Chain error handlers
572
+ const previousHandler = this._workflowErrorHandler
573
+ this._workflowErrorHandler = async (error, ctx) => {
574
+ try {
575
+ return await previousHandler(error, ctx)
576
+ } catch (e) {
577
+ return await handler(e as Error, ctx)
578
+ }
579
+ }
580
+ } else {
581
+ this._workflowErrorHandler = handler
582
+ }
583
+ } else {
584
+ // Step-level error handler - apply to the step that was just configured
585
+ const targetStepIndex =
586
+ this._lastConfigStepIndex >= 0 ? this._lastConfigStepIndex : lastStepIndex
587
+ const targetStep = this._steps[targetStepIndex]
588
+ if (targetStep) {
589
+ if (targetStep.errorHandler) {
590
+ // Chain error handlers
591
+ const previousHandler = targetStep.errorHandler
592
+ targetStep.errorHandler = async (error, ctx) => {
593
+ try {
594
+ return await previousHandler(error, ctx)
595
+ } catch (e) {
596
+ return await handler(e as Error, ctx)
597
+ }
598
+ }
599
+ } else {
600
+ targetStep.errorHandler = handler
601
+ }
602
+ }
603
+ }
604
+ return this
605
+ }
606
+
607
+ /**
608
+ * Set timeout for the most recent step or workflow
609
+ *
610
+ * Rules:
611
+ * - If no steps exist, applies to workflow
612
+ * - If called immediately after a step that was just configured (same step), applies to that step
613
+ * - If called after a step that hasn't been configured yet (first config after that step), applies to that step
614
+ * - If multiple steps were added since last config, this becomes workflow-level
615
+ */
616
+ timeout(ms: number | string): WorkflowBuilder<TInput, TOutput> {
617
+ const timeout = parseDuration(ms)
618
+ const lastStepIndex = this._steps.length - 1
619
+
620
+ if (this._steps.length === 0) {
621
+ // No steps - workflow level
622
+ this._workflowTimeout = timeout
623
+ this._lastConfigStepIndex = -1
624
+ } else if (this._lastDirectlyConfiguredStep === lastStepIndex) {
625
+ // Same step was already configured - still step level for this step
626
+ const lastStep = this._steps[lastStepIndex]
627
+ if (lastStep) {
628
+ lastStep.timeout = timeout
629
+ }
630
+ this._lastConfigStepIndex = lastStepIndex
631
+ } else if (
632
+ this._lastDirectlyConfiguredStep === lastStepIndex - 1 ||
633
+ this._lastDirectlyConfiguredStep === -1
634
+ ) {
635
+ // Previous step was configured, or no step configured yet
636
+ // Check if there's only one unconfigured step (step-level) or multiple (workflow-level)
637
+ const unconfiguredStepsCount = lastStepIndex - this._lastDirectlyConfiguredStep
638
+ if (unconfiguredStepsCount === 1) {
639
+ // Only one step since last config - apply to that step
640
+ const lastStep = this._steps[lastStepIndex]
641
+ if (lastStep) {
642
+ lastStep.timeout = timeout
643
+ }
644
+ this._lastConfigStepIndex = lastStepIndex
645
+ this._lastDirectlyConfiguredStep = lastStepIndex
646
+ } else {
647
+ // Multiple steps since last config - apply to workflow
648
+ this._workflowTimeout = timeout
649
+ this._lastConfigStepIndex = -1
650
+ }
651
+ } else {
652
+ // More than one step was added since last config - workflow level
653
+ this._workflowTimeout = timeout
654
+ this._lastConfigStepIndex = -1
655
+ }
656
+ this._lastOpWasConfig = true
657
+ return this
658
+ }
659
+
660
+ /**
661
+ * Set retry configuration for the most recent step or workflow
662
+ *
663
+ * When called immediately after a step, applies to that step.
664
+ * When no steps exist, applies as default for all steps.
665
+ */
666
+ retry(config: RetryConfig): WorkflowBuilder<TInput, TOutput> {
667
+ if (this._steps.length > 0 && !this._lastOpWasConfig) {
668
+ // Apply to last step
669
+ const lastStep = this._steps[this._steps.length - 1]
670
+ if (lastStep) {
671
+ lastStep.retry = config
672
+ }
673
+ this._lastConfigStepIndex = this._steps.length - 1
674
+ } else {
675
+ // Apply as workflow default
676
+ this._defaultRetryConfig = config
677
+ this._lastConfigStepIndex = -1
678
+ }
679
+ this._lastOpWasConfig = true
680
+ return this
681
+ }
682
+
683
+ /**
684
+ * Build the workflow definition
685
+ */
686
+ build(): BuiltWorkflow<TInput, TOutput> {
687
+ // Check for duplicate step names
688
+ const names = new Set<string>()
689
+ for (const step of this._steps) {
690
+ if (step.type === 'step' && step.name) {
691
+ if (names.has(step.name)) {
692
+ throw new Error(`Duplicate step name: "${step.name}"`)
693
+ }
694
+ names.add(step.name)
695
+ }
696
+ if (step.type === 'parallel' && step.parallelSteps) {
697
+ for (const ps of step.parallelSteps) {
698
+ if (names.has(ps.name)) {
699
+ throw new Error(`Duplicate step name: "${ps.name}"`)
700
+ }
701
+ names.add(ps.name)
702
+ }
703
+ }
704
+ }
705
+
706
+ // Create immutable copies
707
+ const steps = this._steps.map((s) => ({ ...s }))
708
+ const defaultRetryConfig = this._defaultRetryConfig
709
+ const workflowErrorHandler = this._workflowErrorHandler
710
+ const workflowTimeout = this._workflowTimeout
711
+ const workflowName = this.name
712
+
713
+ return {
714
+ name: workflowName,
715
+ steps: Object.freeze(steps),
716
+ ...(defaultRetryConfig !== undefined && { defaultRetryConfig }),
717
+ execute: async (input?: TInput): Promise<TOutput> => {
718
+ return executeWorkflow(
719
+ steps,
720
+ input,
721
+ defaultRetryConfig,
722
+ workflowErrorHandler,
723
+ workflowTimeout
724
+ ) as Promise<TOutput>
725
+ },
726
+ }
727
+ }
728
+ }
729
+
730
+ // ============================================================================
731
+ // Workflow Execution
732
+ // ============================================================================
733
+
734
+ /**
735
+ * Execute a workflow
736
+ */
737
+ async function executeWorkflow(
738
+ steps: StepDefinition[],
739
+ input: unknown,
740
+ defaultRetryConfig?: RetryConfig,
741
+ workflowErrorHandler?: ErrorHandler,
742
+ workflowTimeout?: number
743
+ ): Promise<unknown> {
744
+ let result: Record<string, unknown> = {}
745
+ const startTime = Date.now()
746
+
747
+ const createContext = (currentStep?: string): StepContext => ({
748
+ input,
749
+ result: { ...result },
750
+ ...(currentStep !== undefined && { currentStep }),
751
+ retry: async () => {
752
+ throw new Error('retry() can only be called from an error handler')
753
+ },
754
+ skip: (skipResult: unknown) => ({ __skip: true as const, result: skipResult }),
755
+ abort: (reason?: string) => {
756
+ throw new Error(reason || 'Workflow aborted')
757
+ },
758
+ })
759
+
760
+ // Helper to check workflow timeout
761
+ const checkWorkflowTimeout = () => {
762
+ if (workflowTimeout && Date.now() - startTime > workflowTimeout) {
763
+ throw new Error('Timeout: workflow exceeded timeout')
764
+ }
765
+ }
766
+
767
+ const executeWithWorkflowTimeout = async (): Promise<unknown> => {
768
+ for (const step of steps) {
769
+ // Check workflow timeout before each step
770
+ checkWorkflowTimeout()
771
+
772
+ try {
773
+ result = await executeStep(
774
+ step,
775
+ input,
776
+ result,
777
+ createContext,
778
+ defaultRetryConfig,
779
+ workflowTimeout ? workflowTimeout - (Date.now() - startTime) : undefined
780
+ )
781
+ } catch (error) {
782
+ // Try step-level error handler first
783
+ if (step.errorHandler) {
784
+ let currentError = error as Error
785
+ let retryRequested = false
786
+ let retrySucceeded = false
787
+ let retryResult: Record<string, unknown> | null = null
788
+ const maxRetryAttempts = 100 // Safety limit
789
+
790
+ for (let retryAttempt = 0; retryAttempt < maxRetryAttempts; retryAttempt++) {
791
+ retryRequested = false
792
+
793
+ // Create context with retry support
794
+ const errorCtx: StepContext = {
795
+ input,
796
+ result: { ...result },
797
+ ...(step.name !== undefined && { currentStep: step.name }),
798
+ retry: async () => {
799
+ retryRequested = true
800
+ // Re-execute just the step function directly
801
+ const stepInput = Object.keys(result).length > 0 ? result : input
802
+ const stepResult = await step.fn!(stepInput, createContext(step.name))
803
+ retryResult = { ...result, ...(stepResult as Record<string, unknown>) }
804
+ retrySucceeded = true
805
+ return retryResult
806
+ },
807
+ skip: (skipResult: unknown) => ({ __skip: true as const, result: skipResult }),
808
+ abort: (reason?: string) => {
809
+ throw new Error(reason || 'Workflow aborted')
810
+ },
811
+ }
812
+
813
+ try {
814
+ const handlerResult = await step.errorHandler(currentError, errorCtx)
815
+
816
+ // If retry succeeded, use that result and exit loop
817
+ if (retrySucceeded && retryResult) {
818
+ result = retryResult
819
+ break
820
+ }
821
+
822
+ // If no retry was requested, process the handler result and exit
823
+ if (!retryRequested) {
824
+ if (
825
+ handlerResult &&
826
+ typeof handlerResult === 'object' &&
827
+ '__skip' in handlerResult
828
+ ) {
829
+ result = {
830
+ ...result,
831
+ ...((handlerResult as { __skip: boolean; result: unknown }).result as Record<
832
+ string,
833
+ unknown
834
+ >),
835
+ }
836
+ } else if (handlerResult !== undefined) {
837
+ result = { ...result, ...(handlerResult as Record<string, unknown>) }
838
+ }
839
+ break
840
+ }
841
+
842
+ // Retry was requested but succeeded, so we're done
843
+ if (retrySucceeded) {
844
+ result = retryResult!
845
+ break
846
+ }
847
+ } catch (retryError) {
848
+ // Retry was attempted but failed
849
+ // Continue the loop to call the error handler again with the new error
850
+ currentError = retryError as Error
851
+ retrySucceeded = false
852
+ retryResult = null
853
+ }
854
+ }
855
+ } else if (workflowErrorHandler) {
856
+ // Try workflow-level error handler
857
+ const ctx = createContext(step.name)
858
+ const handlerResult = await workflowErrorHandler(error as Error, ctx)
859
+ result = { ...result, ...(handlerResult as Record<string, unknown>) }
860
+ // Stop execution after workflow error handler (don't continue to next step)
861
+ return result
862
+ } else {
863
+ throw error
864
+ }
865
+ }
866
+ }
867
+
868
+ return result
869
+ }
870
+
871
+ try {
872
+ // If there's a workflow timeout, wrap the entire execution
873
+ if (workflowTimeout) {
874
+ return await withTimeout(executeWithWorkflowTimeout(), workflowTimeout, 'workflow')
875
+ }
876
+ return await executeWithWorkflowTimeout()
877
+ } catch (error) {
878
+ if (workflowErrorHandler) {
879
+ const ctx = createContext()
880
+ return await workflowErrorHandler(error as Error, ctx)
881
+ }
882
+ throw error
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Execute a single step
888
+ */
889
+ async function executeStep(
890
+ step: StepDefinition,
891
+ input: unknown,
892
+ currentResult: Record<string, unknown>,
893
+ createContext: (stepName?: string) => StepContext,
894
+ defaultRetryConfig?: RetryConfig,
895
+ _remainingTimeout?: number
896
+ ): Promise<Record<string, unknown>> {
897
+ let result = { ...currentResult }
898
+ const retryConfig = step.retry || defaultRetryConfig
899
+
900
+ switch (step.type) {
901
+ case 'step': {
902
+ const execute = async () => {
903
+ const ctx = createContext(step.name)
904
+ const stepInput = Object.keys(result).length > 0 ? result : input
905
+ const stepResult = await step.fn!(stepInput, ctx)
906
+ return { ...result, ...(stepResult as Record<string, unknown>) }
907
+ }
908
+
909
+ let executeWithTimeout = execute
910
+ if (step.timeout) {
911
+ executeWithTimeout = () => withTimeout(execute(), step.timeout!, step.name)
912
+ }
913
+
914
+ if (retryConfig) {
915
+ result = await withRetry(executeWithTimeout, retryConfig, step.name)
916
+ } else {
917
+ result = await executeWithTimeout()
918
+ }
919
+ break
920
+ }
921
+
922
+ case 'parallel': {
923
+ const execute = async () => {
924
+ const promises = step.parallelSteps!.map(async (ps) => {
925
+ const ctx = createContext(ps.name)
926
+ const stepInput = Object.keys(result).length > 0 ? result : input
927
+ const stepResult = await ps.fn(stepInput, ctx)
928
+ return { name: ps.name, result: stepResult }
929
+ })
930
+
931
+ const results = await Promise.all(promises)
932
+ const merged: Record<string, unknown> = { ...result }
933
+ for (const { name, result: r } of results) {
934
+ merged[name] = r
935
+ }
936
+ return merged
937
+ }
938
+
939
+ if (step.timeout) {
940
+ result = await withTimeout(execute(), step.timeout)
941
+ } else {
942
+ result = await execute()
943
+ }
944
+ break
945
+ }
946
+
947
+ case 'conditional': {
948
+ const ctx = createContext()
949
+ const conditionResult = await step.conditional!.condition(ctx)
950
+
951
+ if (conditionResult) {
952
+ const thenResult = await step.conditional!.thenBranch.build().execute(result)
953
+ result = { ...result, ...(thenResult as Record<string, unknown>) }
954
+ } else if (step.conditional!.elseBranch) {
955
+ const elseBranch = step.conditional!.elseBranch
956
+ if (elseBranch instanceof WorkflowBuilder) {
957
+ const elseResult = await elseBranch.build().execute(result)
958
+ result = { ...result, ...(elseResult as Record<string, unknown>) }
959
+ } else {
960
+ const elseResult = await (elseBranch as StepFunction<unknown, unknown>)(result, ctx)
961
+ result = { ...result, ...(elseResult as Record<string, unknown>) }
962
+ }
963
+ }
964
+ break
965
+ }
966
+
967
+ case 'loop': {
968
+ const { condition, body, options } = step.loop!
969
+ const maxIterations = options?.maxIterations ?? Infinity
970
+ let iterations = 0
971
+
972
+ while (true) {
973
+ const ctx = createContext()
974
+ ctx.result = result
975
+ const shouldContinue = await condition(ctx)
976
+
977
+ if (!shouldContinue) break
978
+
979
+ iterations++
980
+ if (iterations > maxIterations) {
981
+ if (options?.throwOnMaxIterations) {
982
+ throw new Error(`Max iterations exceeded: loop exceeded ${maxIterations} iterations`)
983
+ }
984
+ break
985
+ }
986
+
987
+ const loopResult = await body.build().execute(result)
988
+ result = { ...result, ...(loopResult as Record<string, unknown>) }
989
+ }
990
+ break
991
+ }
992
+
993
+ case 'forEach': {
994
+ const { itemsSelector, body, options } = step.forEach!
995
+ const ctx = createContext()
996
+ ctx.result = result
997
+ const items = itemsSelector(ctx)
998
+
999
+ if (items.length === 0) {
1000
+ break
1001
+ }
1002
+
1003
+ const concurrency = options?.concurrency ?? 1
1004
+ const forEachResults: unknown[] = []
1005
+
1006
+ if (concurrency === 1) {
1007
+ // Sequential execution
1008
+ for (let i = 0; i < items.length; i++) {
1009
+ const item = items[i]
1010
+ const itemInput = { item, index: i }
1011
+ const itemResult = await body.build().execute(itemInput)
1012
+ forEachResults.push(itemResult)
1013
+ }
1014
+ } else {
1015
+ // Parallel execution with concurrency limit
1016
+ const chunks: unknown[][] = []
1017
+ for (let i = 0; i < items.length; i += concurrency) {
1018
+ chunks.push(items.slice(i, i + concurrency))
1019
+ }
1020
+
1021
+ let index = 0
1022
+ for (const chunk of chunks) {
1023
+ const chunkPromises = chunk.map(async (item) => {
1024
+ const currentIndex = index++
1025
+ const itemInput = { item, index: currentIndex }
1026
+ return body.build().execute(itemInput)
1027
+ })
1028
+ const chunkResults = await Promise.all(chunkPromises)
1029
+ forEachResults.push(...chunkResults)
1030
+ }
1031
+ }
1032
+
1033
+ result = { ...result, forEachResults }
1034
+ break
1035
+ }
1036
+ }
1037
+
1038
+ return result
1039
+ }
1040
+
1041
+ // ============================================================================
1042
+ // Factory Function
1043
+ // ============================================================================
1044
+
1045
+ /**
1046
+ * Create a new workflow builder
1047
+ *
1048
+ * @param name - Workflow name (required)
1049
+ * @returns WorkflowBuilder instance
1050
+ *
1051
+ * @example
1052
+ * ```typescript
1053
+ * const orderWorkflow = workflow('order-process')
1054
+ * .step('validate', async (input) => ({ valid: true }))
1055
+ * .step('charge', async (input) => ({ charged: true }))
1056
+ * .build()
1057
+ *
1058
+ * const result = await orderWorkflow.execute({ orderId: '123' })
1059
+ * ```
1060
+ */
1061
+ export function workflow<TInput = unknown, TOutput = unknown>(
1062
+ name: string
1063
+ ): WorkflowBuilder<TInput, TOutput> {
1064
+ return new WorkflowBuilder<TInput, TOutput>(name)
1065
+ }
1066
+
1067
+ // Re-export for convenience
1068
+ export { workflow as default }