@wooksjs/event-wf 0.6.2 → 0.6.3

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.
@@ -0,0 +1,399 @@
1
+ # Core Concepts — @wooksjs/event-wf
2
+
3
+ > Covers workflow app creation, starting and resuming workflows, how the workflow adapter integrates with the event context system, error handling, spies, testing, and logging.
4
+
5
+ For the underlying event context store API (`init`, `get`, `set`, `hook`, etc.) and how to create custom composables, see [event-core.md](event-core.md).
6
+
7
+ ## Mental Model
8
+
9
+ `@wooksjs/event-wf` is the workflow adapter for Wooks. It wraps the `@prostojs/wf` workflow engine, adding composable context management via `AsyncLocalStorage`. Each workflow execution gets its own isolated context store, and step handlers can call composable functions (`useWfState()`, `useRouteParams()`, etc.) from anywhere.
10
+
11
+ Key principles:
12
+ 1. **Steps are route handlers** — Steps are registered with IDs that are resolved via the Wooks router, supporting parametric step IDs (`:param`), wildcards, and regex constraints.
13
+ 2. **Flows are schemas** — Flows define the execution order of steps, with conditions, loops, and branching.
14
+ 3. **Pause and resume** — Workflows can pause for user input and resume from saved state.
15
+ 4. **String-based handlers** — Step handlers can be JavaScript strings (e.g., `'ctx.result += input'`), making them storable in databases.
16
+
17
+ ## Installation
18
+
19
+ ```bash
20
+ npm install wooks @wooksjs/event-wf
21
+ ```
22
+
23
+ ## Creating a Workflow App
24
+
25
+ ```ts
26
+ import { createWfApp } from '@wooksjs/event-wf'
27
+
28
+ const app = createWfApp<{ result: number }>()
29
+
30
+ app.step('increment', {
31
+ handler: (ctx) => { ctx.result++ },
32
+ })
33
+
34
+ app.flow('my-flow', [{ id: 'increment' }])
35
+
36
+ const output = await app.start('my-flow', { result: 0 })
37
+ console.log(output.state.context.result) // 1
38
+ ```
39
+
40
+ `createWfApp<T>(opts?, wooks?)` returns a `WooksWf<T>` instance. The generic `T` is the workflow context type.
41
+
42
+ Options:
43
+
44
+ ```ts
45
+ interface TWooksWfOptions {
46
+ onError?: (e: Error) => void // custom error handler
47
+ onNotFound?: TWooksHandler // handler when flow not found
48
+ onUnknownFlow?: (schemaId: string, raiseError: () => void) => unknown
49
+ logger?: TConsoleBase // custom logger
50
+ eventOptions?: TEventOptions // event-level logger config
51
+ router?: {
52
+ ignoreTrailingSlash?: boolean
53
+ ignoreCase?: boolean
54
+ cacheLimit?: number
55
+ }
56
+ }
57
+ ```
58
+
59
+ ## Starting a Workflow
60
+
61
+ ### `app.start(schemaId, inputContext, input?, spy?, cleanup?)`
62
+
63
+ Starts a new workflow execution from the beginning:
64
+
65
+ ```ts
66
+ const output = await app.start('my-flow', { result: 0 })
67
+ ```
68
+
69
+ **Parameters:**
70
+ - `schemaId` — The flow ID registered with `app.flow()`
71
+ - `inputContext` — The initial context object (`T`)
72
+ - `input` — Optional input for the first step
73
+ - `spy` — Optional spy function to observe step execution
74
+ - `cleanup` — Optional cleanup function called when execution ends
75
+
76
+ **Return value (`TFlowOutput<T, I, IR>`):**
77
+
78
+ ```ts
79
+ interface TFlowOutput<T, I, IR> {
80
+ finished: boolean // true if workflow completed
81
+ state: {
82
+ schemaId: string // flow ID
83
+ indexes: number[] // position in schema (for resume)
84
+ context: T // final context state
85
+ }
86
+ inputRequired?: { // present if paused for input
87
+ type: string // expected input type
88
+ schemaId: string // step requiring input
89
+ }
90
+ stepResult?: IR // last step's return value
91
+ resume?: (input?: I) => Promise<TFlowOutput<T, I, IR>> // resume function
92
+ }
93
+ ```
94
+
95
+ ### Checking completion
96
+
97
+ ```ts
98
+ const output = await app.start('my-flow', { result: 0 })
99
+
100
+ if (output.finished) {
101
+ console.log('Final result:', output.state.context)
102
+ } else if (output.inputRequired) {
103
+ console.log('Workflow paused, needs:', output.inputRequired.type)
104
+ // Save output.state for later resume
105
+ }
106
+ ```
107
+
108
+ ## Resuming a Workflow
109
+
110
+ ### `app.resume(state, input?, spy?, cleanup?)`
111
+
112
+ Resumes a previously paused workflow from saved state:
113
+
114
+ ```ts
115
+ // Resume with user-provided input
116
+ const resumed = await app.resume(output.state, userInput)
117
+ ```
118
+
119
+ ### Using the `resume()` function on output
120
+
121
+ The output object includes a convenience `resume()` method:
122
+
123
+ ```ts
124
+ const output = await app.start('login-flow', {})
125
+ if (!output.finished && output.resume) {
126
+ const final = await output.resume(userCredentials)
127
+ }
128
+ ```
129
+
130
+ ### Full pause/resume pattern
131
+
132
+ ```ts
133
+ const app = createWfApp<{ username?: string; authenticated?: boolean }>()
134
+
135
+ app.step('get-credentials', {
136
+ input: '{ username: string, password: string }',
137
+ handler: (ctx, input) => {
138
+ ctx.username = input.username
139
+ ctx.authenticated = validate(input.username, input.password)
140
+ },
141
+ })
142
+
143
+ app.step('welcome', {
144
+ handler: (ctx) => console.log(`Welcome, ${ctx.username}!`),
145
+ })
146
+
147
+ app.flow('login', [
148
+ { id: 'get-credentials' },
149
+ { id: 'welcome' },
150
+ ])
151
+
152
+ // Start — pauses at get-credentials because input is required
153
+ const output = await app.start('login', {})
154
+ // output.finished === false
155
+ // output.inputRequired === { type: '{ username: string, password: string }', schemaId: 'get-credentials' }
156
+
157
+ // Save state (e.g., to database)
158
+ const savedState = JSON.stringify(output.state)
159
+
160
+ // Later, resume with user input
161
+ const state = JSON.parse(savedState)
162
+ const final = await app.resume(state, { username: 'alice', password: 'secret' })
163
+ // final.finished === true
164
+ ```
165
+
166
+ ## How Workflow Context Works
167
+
168
+ When `start()` or `resume()` is called, the adapter creates a workflow-specific event context:
169
+
170
+ ```
171
+ app.start(schemaId, inputContext)
172
+ → createWfContext({ inputContext, schemaId, stepId, indexes, input }, options)
173
+ → AsyncLocalStorage.run(wfContextStore, handler)
174
+ → router matches flow ID → handler runs
175
+ → workflow engine executes steps sequentially
176
+ → each step can call useWfState(), useRouteParams(), etc.
177
+ → composables call useWFContext()
178
+ → reads/writes the WF context store
179
+ ```
180
+
181
+ ### The WF Context Store
182
+
183
+ ```ts
184
+ interface TWFContextStore {
185
+ resume: boolean // true if this is a resumed execution
186
+ }
187
+
188
+ interface TWFEventData {
189
+ schemaId: string // flow ID being executed
190
+ stepId: string | null // current step ID (set during step execution)
191
+ inputContext: unknown // the workflow context object (T)
192
+ indexes?: number[] // position for resume
193
+ input?: unknown // input for current step
194
+ type: 'WF'
195
+ }
196
+ ```
197
+
198
+ ### Extending the WF Store for Custom Composables
199
+
200
+ ```ts
201
+ import { useWFContext } from '@wooksjs/event-wf'
202
+
203
+ interface TMyStore {
204
+ metrics?: {
205
+ startTime?: number
206
+ stepCount?: number
207
+ }
208
+ }
209
+
210
+ export function useWorkflowMetrics() {
211
+ const { store } = useWFContext<TMyStore>()
212
+ const { init, get, set } = store('metrics')
213
+
214
+ const startTimer = () => init('startTime', () => Date.now())
215
+ const incrementSteps = () => set('stepCount', (get('stepCount') || 0) + 1)
216
+ const getElapsed = () => Date.now() - (get('startTime') || Date.now())
217
+
218
+ return { startTimer, incrementSteps, getElapsed }
219
+ }
220
+ ```
221
+
222
+ For the full context store API and composable patterns, see [event-core.md](event-core.md).
223
+
224
+ ## Workflow Spies
225
+
226
+ Spies observe step execution without modifying behavior. Attach globally or per-execution:
227
+
228
+ ### Global spy (all workflows)
229
+
230
+ ```ts
231
+ const spy = (event, data) => {
232
+ console.log(`[${event}]`, data)
233
+ }
234
+
235
+ app.attachSpy(spy)
236
+
237
+ // Later, remove it:
238
+ app.detachSpy(spy)
239
+ ```
240
+
241
+ ### Per-execution spy
242
+
243
+ ```ts
244
+ const output = await app.start('my-flow', { result: 0 }, undefined, (event, ...args) => {
245
+ if (event === 'step') {
246
+ console.log('Step executed:', args)
247
+ }
248
+ })
249
+ ```
250
+
251
+ The spy function receives:
252
+ - `event` — Event type (e.g., `'step'`)
253
+ - Additional arguments vary by event type
254
+
255
+ ## Error Handling
256
+
257
+ ### Default behavior
258
+
259
+ By default, errors call `console.error` and `process.exit(1)`.
260
+
261
+ ### Custom error handler
262
+
263
+ ```ts
264
+ const app = createWfApp({
265
+ onError: (error) => {
266
+ console.error(`Workflow error: ${error.message}`)
267
+ // Don't exit — handle gracefully
268
+ },
269
+ })
270
+ ```
271
+
272
+ ### Errors in workflows
273
+
274
+ Errors thrown in step handlers propagate up from `app.start()` / `app.resume()`:
275
+
276
+ ```ts
277
+ try {
278
+ const output = await app.start('my-flow', { result: 0 })
279
+ } catch (error) {
280
+ console.error('Workflow failed:', error.message)
281
+ }
282
+ ```
283
+
284
+ ### `StepRetriableError`
285
+
286
+ A special error type that signals the workflow can be retried with input:
287
+
288
+ ```ts
289
+ import { StepRetriableError } from '@wooksjs/event-wf'
290
+
291
+ app.step('validate', {
292
+ handler: (ctx) => {
293
+ if (!ctx.token) {
294
+ throw new StepRetriableError('Token required', {
295
+ inputRequired: { type: 'string', schemaId: 'validate' },
296
+ })
297
+ }
298
+ },
299
+ })
300
+ ```
301
+
302
+ ## Sharing Router Between Adapters
303
+
304
+ Multiple adapters can share the same Wooks router:
305
+
306
+ ```ts
307
+ import { Wooks } from 'wooks'
308
+ import { createWfApp } from '@wooksjs/event-wf'
309
+
310
+ const wooks = new Wooks()
311
+ const app1 = createWfApp({}, wooks)
312
+ const app2 = createWfApp({}, wooks) // shares the same routes
313
+ ```
314
+
315
+ Or share with another adapter (e.g., HTTP):
316
+
317
+ ```ts
318
+ import { createHttpApp } from '@wooksjs/event-http'
319
+ import { createWfApp } from '@wooksjs/event-wf'
320
+
321
+ const httpApp = createHttpApp()
322
+ const wfApp = createWfApp({}, httpApp) // shares httpApp's router
323
+ ```
324
+
325
+ ## Testing
326
+
327
+ Test workflows by calling `app.start()` directly with explicit contexts:
328
+
329
+ ```ts
330
+ import { createWfApp } from '@wooksjs/event-wf'
331
+
332
+ const app = createWfApp<{ count: number }>()
333
+
334
+ app.step('increment', {
335
+ handler: (ctx) => { ctx.count++ },
336
+ })
337
+
338
+ app.flow('test-flow', [
339
+ { id: 'increment' },
340
+ { id: 'increment' },
341
+ ])
342
+
343
+ // Test:
344
+ const output = await app.start('test-flow', { count: 0 })
345
+ expect(output.state.context.count).toBe(2)
346
+ expect(output.finished).toBe(true)
347
+ ```
348
+
349
+ ### Testing resume
350
+
351
+ ```ts
352
+ app.step('needs-input', {
353
+ input: 'number',
354
+ handler: 'ctx.count += input',
355
+ })
356
+
357
+ app.flow('resume-flow', [{ id: 'needs-input' }])
358
+
359
+ const output = await app.start('resume-flow', { count: 0 })
360
+ expect(output.finished).toBe(false)
361
+
362
+ const final = await app.resume(output.state, 42)
363
+ expect(final.state.context.count).toBe(42)
364
+ expect(final.finished).toBe(true)
365
+ ```
366
+
367
+ ## Logging
368
+
369
+ Inside a step handler, use the event-scoped logger:
370
+
371
+ ```ts
372
+ import { useEventLogger } from '@wooksjs/event-core'
373
+
374
+ app.step('process', {
375
+ handler: (ctx) => {
376
+ const logger = useEventLogger('process-step')
377
+ logger.log('Processing...')
378
+ ctx.processed = true
379
+ },
380
+ })
381
+ ```
382
+
383
+ ## Best Practices
384
+
385
+ - **Use `createWfApp<T>()` with a typed context** — The generic `T` gives type safety for all step handlers and flow output.
386
+ - **Use string handlers for storable logic** — When workflows are defined in a database, use string handlers like `'ctx.result += input'`.
387
+ - **Use function handlers for complex logic** — When handlers need imports, async operations, or composables, use function handlers.
388
+ - **Save state for resume** — `output.state` is serializable. Store it in a database to resume later.
389
+ - **Use spies for logging/monitoring** — Don't add logging inside every step; attach a spy instead.
390
+ - **Use `flow` init functions** — The optional `init` callback in `app.flow()` runs before the first step, useful for context setup.
391
+
392
+ ## Gotchas
393
+
394
+ - **Composables must be called within a step handler** (inside the async context). Calling them at module load time throws.
395
+ - **`start()` and `resume()` return promises** — Always `await` them.
396
+ - **Input is cleared after the first step** — When starting with `input`, it's only available to the first step. Subsequent steps don't see it unless the workflow pauses and resumes with new input.
397
+ - **String handlers run in a restricted environment** — They can't access `require`, `import`, `process`, or other Node.js globals. Use function handlers for those.
398
+ - **Step resolution uses the router** — Step IDs are looked up via the Wooks router. If a step ID contains `/`, it's treated as path segments for routing.
399
+ - **Flow IDs also use routing** — You can have parametric flow IDs like `'process/:type'` and use `useRouteParams()` inside the flow's init function.