@wooksjs/event-wf 0.7.7 → 0.7.9

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.
@@ -1,570 +0,0 @@
1
- # Steps & Flows — @wooksjs/event-wf
2
-
3
- > Covers defining steps, defining flows (schemas), workflow schema syntax (conditions, loops, subflows), parametric steps, accessing workflow state with `useWfState`, string-based handlers, user input handling, and `StepRetriableError`.
4
-
5
- ## Defining Steps
6
-
7
- ### `app.step(id, opts)`
8
-
9
- Registers a reusable step with a unique ID:
10
-
11
- ```ts
12
- import { createWfApp } from '@wooksjs/event-wf'
13
-
14
- const app = createWfApp<{ result: number }>()
15
-
16
- // Function handler
17
- app.step('double', {
18
- handler: (ctx) => {
19
- ctx.result *= 2
20
- },
21
- })
22
-
23
- // String handler (storable, runs in restricted env)
24
- app.step('add', {
25
- input: 'number',
26
- handler: 'ctx.result += input',
27
- })
28
- ```
29
-
30
- **Parameters:**
31
-
32
- - `id` — Step identifier (used in flow schemas to reference this step). Supports router syntax: `'add/:n'`, `'process/*'`, etc.
33
- - `opts.handler` — Either a function `(ctx: T, input?: I) => void | IR` or a JavaScript string.
34
- - `opts.input` — Optional: input type description (string). When present and no input is provided at runtime, the workflow pauses to request input.
35
-
36
- ### Function handlers
37
-
38
- Function handlers receive the workflow context and optional input:
39
-
40
- ```ts
41
- app.step('process-item', {
42
- handler: (ctx, input) => {
43
- // ctx = workflow context (type T)
44
- // input = step input (type I, from flow schema or resume)
45
- ctx.items.push(input)
46
- },
47
- })
48
- ```
49
-
50
- Function handlers can use composables:
51
-
52
- ```ts
53
- import { useRouteParams } from '@wooksjs/event-core'
54
- import { useWfState } from '@wooksjs/event-wf'
55
-
56
- app.step('add/:n', {
57
- handler: () => {
58
- const { ctx } = useWfState()
59
- const context = ctx<{ result: number }>()
60
- context.result += Number(useRouteParams().get('n'))
61
- },
62
- })
63
- ```
64
-
65
- ### String handlers
66
-
67
- String handlers are JavaScript expressions evaluated in a restricted sandbox. They have access to `ctx` (context) and `input`:
68
-
69
- ```ts
70
- app.step('add', {
71
- input: 'number',
72
- handler: 'ctx.result += input',
73
- })
74
-
75
- app.step('set-name', {
76
- input: 'string',
77
- handler: 'ctx.name = input',
78
- })
79
-
80
- app.step('multiply', {
81
- handler: 'ctx.result *= 2',
82
- })
83
- ```
84
-
85
- String handlers are useful when workflow definitions are stored in a database — they can be serialized and loaded dynamically.
86
-
87
- **Restrictions:** String handlers cannot access `require`, `import`, `process`, `fs`, or other Node.js globals. They only see `ctx` and `input`.
88
-
89
- ## Defining Flows
90
-
91
- ### `app.flow(id, schema, prefix?, init?)`
92
-
93
- Registers a flow (workflow schema) — an ordered sequence of steps:
94
-
95
- ```ts
96
- app.flow('calculate', [{ id: 'add', input: 5 }, { id: 'add', input: 2 }, { id: 'double' }])
97
- ```
98
-
99
- **Parameters:**
100
-
101
- - `id` — Flow identifier. Supports router syntax (e.g., `'process/:type'`, `'batch/*'`).
102
- - `schema` — Array of step references, conditions, and loops (see Schema Syntax below).
103
- - `prefix` — Optional prefix prepended to step IDs during resolution.
104
- - `init` — Optional async function called before the first step executes.
105
-
106
- ### Flow init function
107
-
108
- The `init` callback runs in the workflow context before any step executes:
109
-
110
- ```ts
111
- app.flow('my-flow', ['step1', 'step2'], '', () => {
112
- const { ctx } = useWfState()
113
- const context = ctx<{ result: number }>()
114
- // Modify context before steps run
115
- context.result = 0
116
- })
117
- ```
118
-
119
- ## Schema Syntax
120
-
121
- Flow schemas are arrays of step references and control structures.
122
-
123
- ### Step references
124
-
125
- Three forms for referencing steps in a schema:
126
-
127
- ```ts
128
- // 1. String shorthand (step ID, optionally with parametric segments)
129
- app.flow('f1', ['step1', 'add/5', 'add/10'])
130
-
131
- // 2. Object with ID and optional input
132
- app.flow('f2', [
133
- { id: 'add', input: 5 },
134
- { id: 'process', input: { key: 'value' } },
135
- ])
136
-
137
- // 3. Mixed
138
- app.flow('f3', ['step1', { id: 'add', input: 5 }, 'step2'])
139
- ```
140
-
141
- ### Conditional execution
142
-
143
- Skip steps or groups based on runtime conditions:
144
-
145
- ```ts
146
- app.flow('order', [
147
- 'check-inventory',
148
- // Single step with condition
149
- { id: 'apply-discount', condition: 'order.total > 100' },
150
- // Group of steps with condition (subflow)
151
- {
152
- condition: 'order.type !== "digital"',
153
- steps: ['pack-item', 'ship-item'],
154
- },
155
- 'send-confirmation',
156
- ])
157
- ```
158
-
159
- Conditions are JavaScript expressions evaluated against the workflow context. They have access to all context properties directly (not via `ctx.` prefix — the context is the scope).
160
-
161
- ```ts
162
- // If context is { result: 5, items: [] }:
163
- // condition: 'result > 3' → true
164
- // condition: 'items.length > 0' → false
165
- ```
166
-
167
- ### Loops (`while`)
168
-
169
- Repeat a group of steps while a condition is true:
170
-
171
- ```ts
172
- app.flow('retry-flow', [
173
- {
174
- while: 'attempts < 5 && !success',
175
- steps: [
176
- { id: 'attempt' },
177
- { break: 'success' }, // break when success is truthy
178
- ],
179
- },
180
- ])
181
- ```
182
-
183
- Loop constructs:
184
-
185
- - `while` — Condition string evaluated before each iteration
186
- - `break` — Condition string; if truthy, exits the loop
187
- - `continue` — Condition string; if truthy, skips to next iteration
188
-
189
- ```ts
190
- app.flow('process-batch', [
191
- {
192
- while: 'index < items.length',
193
- steps: [
194
- { id: 'skip-invalid', continue: '!items[index].valid' },
195
- { id: 'process-item' },
196
- { id: 'increment-index' },
197
- ],
198
- },
199
- ])
200
- ```
201
-
202
- ### Nested subflows
203
-
204
- Subflows group steps for conditional execution or organizational clarity:
205
-
206
- ```ts
207
- app.flow('deploy', [
208
- 'build',
209
- {
210
- condition: 'env === "production"',
211
- steps: [
212
- 'run-tests',
213
- 'run-security-scan',
214
- {
215
- condition: 'securityPassed',
216
- steps: ['deploy-to-prod', 'notify-team'],
217
- },
218
- ],
219
- },
220
- {
221
- condition: 'env === "staging"',
222
- steps: ['deploy-to-staging'],
223
- },
224
- ])
225
- ```
226
-
227
- ## Parametric Steps
228
-
229
- Step IDs support the same router syntax as HTTP routes:
230
-
231
- ### Named parameters
232
-
233
- ```ts
234
- app.step('add/:n', {
235
- handler: () => {
236
- const { ctx } = useWfState()
237
- const context = ctx<{ result: number }>()
238
- context.result += Number(useRouteParams().get('n'))
239
- },
240
- })
241
-
242
- // Use in flow with different values
243
- app.flow('calculate', ['add/5', 'add/10', 'add/3'])
244
- ```
245
-
246
- ### Regex-constrained parameters
247
-
248
- ```ts
249
- app.step('multiply/:factor(\\d+)', {
250
- handler: () => {
251
- const { ctx } = useWfState()
252
- ctx<{ result: number }>().result *= Number(useRouteParams().get('factor'))
253
- },
254
- })
255
- ```
256
-
257
- ### Wildcard steps
258
-
259
- ```ts
260
- app.step('log/*', {
261
- handler: () => {
262
- const message = useRouteParams().get('*')
263
- console.log(message)
264
- },
265
- })
266
-
267
- app.flow('verbose', ['log/starting', 'process', 'log/done'])
268
- ```
269
-
270
- ### Parametric flow IDs
271
-
272
- Flows can also have parametric IDs:
273
-
274
- ```ts
275
- app.flow('process/:type', ['validate', 'transform', 'save'])
276
-
277
- // Start with different types
278
- await app.start('process/csv', { data: rawData })
279
- await app.start('process/json', { data: rawData })
280
- ```
281
-
282
- ## Accessing Workflow State
283
-
284
- ### `useWfState()`
285
-
286
- The primary composable for accessing workflow execution state from within step handlers:
287
-
288
- ```ts
289
- import { useWfState } from '@wooksjs/event-wf'
290
-
291
- app.step('my-step', {
292
- handler: () => {
293
- const { ctx, input, schemaId, stepId, indexes, resume } = useWfState()
294
-
295
- ctx<MyContext>() // the workflow context object (type T)
296
- input<MyInput>() // the current step's input (or undefined)
297
- schemaId // the flow ID being executed
298
- stepId() // the current step ID
299
- indexes() // position in schema (for resume tracking)
300
- resume // boolean: true if this is a resumed execution
301
- },
302
- })
303
- ```
304
-
305
- ### `ctx<T>()`
306
-
307
- Returns the workflow context — the mutable state shared across all steps:
308
-
309
- ```ts
310
- app.step('transform', {
311
- handler: () => {
312
- const { ctx } = useWfState()
313
- const context = ctx<{ items: string[]; processed: boolean }>()
314
- context.items = context.items.map((s) => s.toUpperCase())
315
- context.processed = true
316
- },
317
- })
318
- ```
319
-
320
- ### `input<I>()`
321
-
322
- Returns the input provided for this step (from the flow schema or from resume):
323
-
324
- ```ts
325
- app.step('configure', {
326
- handler: () => {
327
- const { input } = useWfState()
328
- const config = input<{ port: number; host: string }>()
329
- if (config) {
330
- // Use the provided input
331
- }
332
- },
333
- })
334
- ```
335
-
336
- ### Using `useRouteParams()` in steps
337
-
338
- For parametric step IDs, use `useRouteParams()` from `@wooksjs/event-core`:
339
-
340
- ```ts
341
- import { useRouteParams } from '@wooksjs/event-core'
342
-
343
- app.step('set/:key/:value', {
344
- handler: () => {
345
- const { ctx } = useWfState()
346
- const { get } = useRouteParams<{ key: string; value: string }>()
347
- const context = ctx<Record<string, string>>()
348
- context[get('key')] = get('value')
349
- },
350
- })
351
-
352
- app.flow('setup', ['set/name/Alice', 'set/role/admin'])
353
- ```
354
-
355
- ## User Input & Pause/Resume
356
-
357
- When a step declares an `input` type but no input is provided in the schema, the workflow pauses:
358
-
359
- ```ts
360
- app.step('get-email', {
361
- input: 'string', // declares expected input type
362
- handler: 'ctx.email = input',
363
- })
364
-
365
- app.step('send-welcome', {
366
- handler: (ctx) => sendEmail(ctx.email, 'Welcome!'),
367
- })
368
-
369
- app.flow('onboarding', [
370
- { id: 'get-email' }, // no input provided → workflow pauses
371
- { id: 'send-welcome' },
372
- ])
373
-
374
- // Start the workflow
375
- const output = await app.start('onboarding', {})
376
- // output.finished === false
377
- // output.inputRequired.type === 'string'
378
-
379
- // Resume with user's email
380
- const final = await app.resume(output.state, { input: 'user@example.com' })
381
- // final.finished === true
382
- ```
383
-
384
- ### Providing input in schema (no pause)
385
-
386
- When input is provided in the schema, the step executes immediately:
387
-
388
- ```ts
389
- app.flow('auto-onboarding', [
390
- { id: 'get-email', input: 'default@example.com' }, // input provided → no pause
391
- { id: 'send-welcome' },
392
- ])
393
- ```
394
-
395
- ### State serialization
396
-
397
- The `output.state` object is plain JSON — serialize it for persistence:
398
-
399
- ```ts
400
- // Save
401
- await db.save('workflow:123', JSON.stringify(output.state))
402
-
403
- // Load and resume
404
- const saved = JSON.parse(await db.load('workflow:123'))
405
- const result = await app.resume(saved, { input: userInput })
406
- ```
407
-
408
- ## StepRetriableError
409
-
410
- A special error type for recoverable failures. When thrown, the workflow can be resumed:
411
-
412
- ```ts
413
- import { StepRetriableError } from '@wooksjs/event-wf'
414
-
415
- app.step('fetch-data', {
416
- handler: async (ctx) => {
417
- try {
418
- ctx.data = await fetchFromApi()
419
- } catch (e) {
420
- throw new StepRetriableError('API temporarily unavailable')
421
- }
422
- },
423
- })
424
-
425
- // Handle retry
426
- try {
427
- await app.start('my-flow', {})
428
- } catch (error) {
429
- if (error instanceof StepRetriableError) {
430
- // Wait and retry
431
- await sleep(5000)
432
- await app.resume(error.state, { input: retryInput })
433
- }
434
- }
435
- ```
436
-
437
- ## Common Patterns
438
-
439
- ### Pattern: Calculator workflow
440
-
441
- ```ts
442
- const app = createWfApp<{ result: number }>()
443
-
444
- app.step('add', { input: 'number', handler: 'ctx.result += input' })
445
- app.step('multiply', { input: 'number', handler: 'ctx.result *= input' })
446
- app.step('add/:n', {
447
- handler: () => {
448
- const { ctx } = useWfState()
449
- ctx<{ result: number }>().result += Number(useRouteParams().get('n'))
450
- },
451
- })
452
-
453
- app.flow('calculate', [{ id: 'add', input: 10 }, { id: 'multiply', input: 2 }, 'add/5'])
454
-
455
- const output = await app.start('calculate', { result: 0 })
456
- // result: (0 + 10) * 2 + 5 = 25
457
- ```
458
-
459
- ### Pattern: Conditional processing pipeline
460
-
461
- ```ts
462
- const app = createWfApp<{
463
- data: unknown
464
- format: string
465
- validated: boolean
466
- output?: string
467
- }>()
468
-
469
- app.step('validate', {
470
- handler: (ctx) => {
471
- ctx.validated = isValid(ctx.data)
472
- },
473
- })
474
-
475
- app.step('to-json', {
476
- handler: (ctx) => {
477
- ctx.output = JSON.stringify(ctx.data)
478
- },
479
- })
480
-
481
- app.step('to-csv', {
482
- handler: (ctx) => {
483
- ctx.output = toCsv(ctx.data)
484
- },
485
- })
486
-
487
- app.flow('export', [
488
- { id: 'validate' },
489
- { condition: '!validated', steps: [] }, // early exit if invalid
490
- { condition: 'format === "json"', steps: ['to-json'] },
491
- { condition: 'format === "csv"', steps: ['to-csv'] },
492
- ])
493
- ```
494
-
495
- ### Pattern: Interactive wizard
496
-
497
- ```ts
498
- const app = createWfApp<{
499
- name?: string
500
- email?: string
501
- plan?: string
502
- }>()
503
-
504
- app.step('get-name', { input: 'string', handler: 'ctx.name = input' })
505
- app.step('get-email', { input: 'string', handler: 'ctx.email = input' })
506
- app.step('get-plan', { input: 'string', handler: 'ctx.plan = input' })
507
- app.step('confirm', {
508
- handler: (ctx) => {
509
- console.log(`Name: ${ctx.name}, Email: ${ctx.email}, Plan: ${ctx.plan}`)
510
- },
511
- })
512
-
513
- app.flow('signup', [{ id: 'get-name' }, { id: 'get-email' }, { id: 'get-plan' }, { id: 'confirm' }])
514
-
515
- // Each step pauses for user input
516
- let output = await app.start('signup', {})
517
- output = await app.resume(output.state, { input: 'Alice' }) // name
518
- output = await app.resume(output.state, { input: 'a@b.com' }) // email
519
- output = await app.resume(output.state, { input: 'pro' }) // plan
520
- // output.finished === true
521
- ```
522
-
523
- ### Pattern: Retry loop
524
-
525
- ```ts
526
- const app = createWfApp<{ attempts: number; success: boolean; data?: unknown }>()
527
-
528
- app.step('attempt', {
529
- handler: async (ctx) => {
530
- ctx.attempts++
531
- try {
532
- ctx.data = await unreliableApi()
533
- ctx.success = true
534
- } catch {
535
- ctx.success = false
536
- }
537
- },
538
- })
539
-
540
- app.flow('resilient-fetch', [
541
- {
542
- while: 'attempts < 5 && !success',
543
- steps: [{ id: 'attempt' }, { break: 'success' }],
544
- },
545
- ])
546
-
547
- const output = await app.start('resilient-fetch', {
548
- attempts: 0,
549
- success: false,
550
- })
551
- ```
552
-
553
- ## Best Practices
554
-
555
- - **Use typed contexts** — Always provide the generic `<T>` to `createWfApp<T>()` and `ctx<T>()` for type safety.
556
- - **Use string handlers for simple mutations** — `'ctx.result += input'` is cleaner and more portable than a function for trivial operations.
557
- - **Use function handlers for complex logic** — Anything needing composables, async operations, or imports should use function handlers.
558
- - **Use parametric steps** — Instead of creating separate steps for `add/5`, `add/10`, etc., create one `add/:n` step and reference it in flows.
559
- - **Use conditions for branching** — Don't implement branching in step handlers. Use schema-level conditions to keep flow logic declarative.
560
- - **Serialize state for long-running workflows** — Use `output.state` to persist workflow progress to a database.
561
- - **Use spies for observability** — Attach spies for logging, metrics, or debugging rather than adding instrumentation inside steps.
562
-
563
- ## Gotchas
564
-
565
- - **Conditions access context properties directly** — The condition `'result > 10'` checks `context.result`, not a local variable. The entire context is the evaluation scope.
566
- - **Input is only for the first step on start** — When calling `app.start(id, ctx, { input })`, the `input` is consumed by the first step. After that, `input` is cleared. Subsequent steps only get input if the workflow pauses and resumes.
567
- - **String handlers are sandboxed** — No access to Node.js APIs, `require`, `import`, `console`, etc. Only `ctx` and `input` are available.
568
- - **Step IDs are router paths** — A step ID `'process/items'` is treated as two path segments. Use `'process-items'` if you want a flat ID.
569
- - **Flow `init` runs in context** — The init function has access to composables (`useWfState()`, `useRouteParams()`, etc.) because it runs inside the async event context.
570
- - **`resume()` requires the exact state object** — The `state` from `output.state` contains `indexes` that track the workflow's position. Don't modify it.