@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.
- package/README.md +26 -3
- package/dist/index.cjs +38 -5
- package/dist/index.d.ts +37 -6
- package/dist/index.mjs +34 -7
- package/package.json +43 -34
- package/scripts/setup-skills.js +70 -0
- package/skills/wooksjs-event-wf/SKILL.md +42 -0
- package/skills/wooksjs-event-wf/core.md +399 -0
- package/skills/wooksjs-event-wf/event-core.md +562 -0
- package/skills/wooksjs-event-wf/workflows.md +579 -0
|
@@ -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.
|