@moostjs/event-wf 0.5.32 → 0.6.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.
- package/README.md +62 -0
- package/dist/index.cjs +96 -22
- package/dist/index.d.ts +82 -2
- package/dist/index.mjs +95 -21
- package/package.json +40 -34
- package/scripts/setup-skills.js +78 -0
- package/skills/moostjs-event-wf/SKILL.md +40 -0
- package/skills/moostjs-event-wf/core.md +138 -0
- package/skills/moostjs-event-wf/decorators.md +179 -0
- package/skills/moostjs-event-wf/execution.md +325 -0
- package/skills/moostjs-event-wf/integration.md +180 -0
- package/skills/moostjs-event-wf/schemas.md +203 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
# Execution & Lifecycle — @moostjs/event-wf
|
|
2
|
+
|
|
3
|
+
> Starting and resuming workflows, reading output, pause/resume patterns, error handling, and spies.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
Workflows execute via `wf.start()` and produce a `TFlowOutput` describing the result. A workflow can complete, pause for input, or fail. Paused workflows can be resumed from serialized state. Spies observe execution in real time.
|
|
8
|
+
|
|
9
|
+
## API Reference
|
|
10
|
+
|
|
11
|
+
### `wf.start<I>(schemaId, initialContext, input?)`
|
|
12
|
+
|
|
13
|
+
Starts a new workflow execution.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
start<I>(
|
|
17
|
+
schemaId: string,
|
|
18
|
+
initialContext: T,
|
|
19
|
+
input?: I,
|
|
20
|
+
): Promise<TFlowOutput<T, I, IR>>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Parameter | Description |
|
|
24
|
+
|-----------|-------------|
|
|
25
|
+
| `schemaId` | Workflow identifier (matching `@Workflow` path, including controller prefix) |
|
|
26
|
+
| `initialContext` | Initial context object passed to steps |
|
|
27
|
+
| `input` | Optional input for the first step |
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
const result = await wf.start('process-order', {
|
|
31
|
+
orderId: '123',
|
|
32
|
+
status: 'new',
|
|
33
|
+
})
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### `wf.resume<I>(state, input?)`
|
|
37
|
+
|
|
38
|
+
Resumes a paused workflow from a saved state.
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
resume<I>(
|
|
42
|
+
state: { schemaId: string, context: T, indexes: number[] },
|
|
43
|
+
input?: I,
|
|
44
|
+
): Promise<TFlowOutput<T, I, IR>>
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| Parameter | Description |
|
|
48
|
+
|-----------|-------------|
|
|
49
|
+
| `state` | State object from a previous `TFlowOutput.state` |
|
|
50
|
+
| `input` | Input for the paused step |
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
const resumed = await wf.resume(previousResult.state, { answer: 'yes' })
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### `wf.attachSpy<I>(fn)`
|
|
57
|
+
|
|
58
|
+
Attaches a spy function to observe workflow execution. Returns a detach function.
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const detach = wf.attachSpy((event, eventOutput, flowOutput, ms) => {
|
|
62
|
+
console.log(event, flowOutput.stepId, ms)
|
|
63
|
+
})
|
|
64
|
+
detach() // stop observing
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### `wf.detachSpy<I>(fn)`
|
|
68
|
+
|
|
69
|
+
Removes a previously attached spy function.
|
|
70
|
+
|
|
71
|
+
### `wf.getWfApp()`
|
|
72
|
+
|
|
73
|
+
Returns the underlying `WooksWf` instance for advanced low-level access.
|
|
74
|
+
|
|
75
|
+
## TFlowOutput
|
|
76
|
+
|
|
77
|
+
Returned by `start()` and `resume()`. Describes the workflow's current state.
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
interface TFlowOutput<T, I, IR> {
|
|
81
|
+
state: {
|
|
82
|
+
schemaId: string // workflow identifier
|
|
83
|
+
context: T // current context (with all mutations)
|
|
84
|
+
indexes: number[] // position in schema (for resume)
|
|
85
|
+
}
|
|
86
|
+
finished: boolean // true if workflow completed all steps
|
|
87
|
+
stepId: string // last executed step ID
|
|
88
|
+
inputRequired?: IR // present when a step needs input
|
|
89
|
+
interrupt?: boolean // true when paused (input or retriable error)
|
|
90
|
+
break?: boolean // true if a break condition ended the flow
|
|
91
|
+
resume?: (input: I) => Promise<TFlowOutput<T, unknown, IR>>
|
|
92
|
+
retry?: (input?: I) => Promise<TFlowOutput<T, unknown, IR>>
|
|
93
|
+
error?: Error // error if step threw
|
|
94
|
+
errorList?: unknown // structured error details from StepRetriableError
|
|
95
|
+
expires?: number // TTL in ms (if set by the step)
|
|
96
|
+
}
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Key interpretation:
|
|
100
|
+
- `finished: true` — workflow completed all steps
|
|
101
|
+
- `finished: false` + `interrupt: true` — workflow is paused (input needed or retriable error)
|
|
102
|
+
- `finished: true` + `error` — workflow failed with an unrecoverable error
|
|
103
|
+
|
|
104
|
+
## Pause & Resume
|
|
105
|
+
|
|
106
|
+
### Pausing a Workflow
|
|
107
|
+
|
|
108
|
+
A step pauses the workflow by returning an object with `inputRequired`:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
@Step('collect-address')
|
|
112
|
+
collectAddress(
|
|
113
|
+
@WorkflowParam('input') input?: TAddress,
|
|
114
|
+
@WorkflowParam('context') ctx: TRegistrationContext,
|
|
115
|
+
) {
|
|
116
|
+
if (!input) {
|
|
117
|
+
return { inputRequired: true } // pauses the workflow
|
|
118
|
+
}
|
|
119
|
+
ctx.address = input
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The `inputRequired` value can be anything — a boolean, a form schema, a structured object. Moost passes it through without interpretation.
|
|
124
|
+
|
|
125
|
+
### Reading Paused Output
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const result = await wf.start('registration', initialContext)
|
|
129
|
+
|
|
130
|
+
if (!result.finished) {
|
|
131
|
+
console.log(result.interrupt) // true
|
|
132
|
+
console.log(result.inputRequired) // whatever the step returned
|
|
133
|
+
console.log(result.stepId) // 'collect-address'
|
|
134
|
+
console.log(result.state) // serializable state
|
|
135
|
+
}
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### Resuming with Convenience Function
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
if (result.inputRequired && result.resume) {
|
|
142
|
+
const resumed = await result.resume({ street: '123 Main St', city: 'Springfield' })
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Resuming from Stored State
|
|
147
|
+
|
|
148
|
+
For workflows that span time, serialize the state and resume later:
|
|
149
|
+
|
|
150
|
+
```ts
|
|
151
|
+
// Store
|
|
152
|
+
await db.save('pending', { state: result.state, inputRequired: result.inputRequired })
|
|
153
|
+
|
|
154
|
+
// Later, resume
|
|
155
|
+
const saved = await db.load('pending', workflowId)
|
|
156
|
+
const resumed = await wf.resume(saved.state, userInput)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Multi-Step Pause/Resume
|
|
160
|
+
|
|
161
|
+
A workflow can pause and resume multiple times:
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
let result = await wf.start('registration', emptyContext)
|
|
165
|
+
result = await wf.resume(result.state, { name: 'Alice' })
|
|
166
|
+
result = await wf.resume(result.state, { email: 'alice@example.com' })
|
|
167
|
+
result = await wf.resume(result.state, { street: '123 Main' })
|
|
168
|
+
console.log(result.finished) // true
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
### Expiration
|
|
172
|
+
|
|
173
|
+
Steps can set an expiration time (in milliseconds) for paused state:
|
|
174
|
+
|
|
175
|
+
```ts
|
|
176
|
+
@Step('collect-payment')
|
|
177
|
+
collectPayment(@WorkflowParam('input') input?: TPayment) {
|
|
178
|
+
if (!input) {
|
|
179
|
+
return { inputRequired: { type: 'payment-form' }, expires: 15 * 60 * 1000 }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
The workflow engine does not enforce expiration — it provides the value for your application to check.
|
|
185
|
+
|
|
186
|
+
## Error Handling
|
|
187
|
+
|
|
188
|
+
### Regular Errors
|
|
189
|
+
|
|
190
|
+
Throwing a standard error fails the workflow immediately:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
@Step('validate')
|
|
194
|
+
validate(@WorkflowParam('context') ctx: TCtx) {
|
|
195
|
+
if (!ctx.paymentMethodId) throw new Error('No payment method')
|
|
196
|
+
}
|
|
197
|
+
// Output: { finished: true, error: Error(...) } — no resume/retry available
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
### StepRetriableError
|
|
201
|
+
|
|
202
|
+
Signals a recoverable failure. The workflow pauses instead of failing:
|
|
203
|
+
|
|
204
|
+
```ts
|
|
205
|
+
import { StepRetriableError } from '@moostjs/event-wf'
|
|
206
|
+
|
|
207
|
+
@Step('charge-card')
|
|
208
|
+
chargeCard(@WorkflowParam('input') input?: TPaymentInput) {
|
|
209
|
+
try {
|
|
210
|
+
processPayment(input)
|
|
211
|
+
} catch (e) {
|
|
212
|
+
throw new StepRetriableError(
|
|
213
|
+
e as Error, // original error
|
|
214
|
+
[{ code: 'CARD_DECLINED', message: 'Card declined' }], // errorList
|
|
215
|
+
{ type: 'payment-form', hint: 'Try a different card' }, // inputRequired
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
**Constructor:**
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
new StepRetriableError(
|
|
225
|
+
originalError: Error, // the underlying error
|
|
226
|
+
errorList?: unknown, // structured error details (any shape)
|
|
227
|
+
inputRequired?: IR, // what input is needed to retry
|
|
228
|
+
expires?: number, // optional TTL in ms
|
|
229
|
+
)
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
**Retriable output:**
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
{
|
|
236
|
+
finished: false, // not done — can be retried
|
|
237
|
+
interrupt: true, // execution paused
|
|
238
|
+
error: Error('...'),
|
|
239
|
+
errorList: [...],
|
|
240
|
+
inputRequired: { ... },
|
|
241
|
+
retry: [Function], // retry from the same step
|
|
242
|
+
resume: [Function], // same as retry for retriable errors
|
|
243
|
+
state: { ... },
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Retrying:**
|
|
248
|
+
|
|
249
|
+
```ts
|
|
250
|
+
const result = await wf.start('payment', paymentContext)
|
|
251
|
+
if (result.error && result.retry) {
|
|
252
|
+
const retried = await result.retry(newPaymentInput)
|
|
253
|
+
}
|
|
254
|
+
// Or from stored state:
|
|
255
|
+
const retried = await wf.resume(result.state, newInput)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### When to Use Which
|
|
259
|
+
|
|
260
|
+
| Scenario | Approach |
|
|
261
|
+
|----------|----------|
|
|
262
|
+
| Invalid configuration, programming bug | `throw new Error(...)` |
|
|
263
|
+
| External service temporarily unavailable | `throw new StepRetriableError(...)` |
|
|
264
|
+
| User input fails validation | `StepRetriableError` with `errorList` and `inputRequired` |
|
|
265
|
+
| Rate limit hit | `StepRetriableError` with `expires` |
|
|
266
|
+
|
|
267
|
+
## Spies & Observability
|
|
268
|
+
|
|
269
|
+
### Attaching a Spy
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
const detach = wf.attachSpy((event, eventOutput, flowOutput, ms) => {
|
|
273
|
+
console.log(`[${event}] step=${flowOutput.stepId} (${ms}ms)`)
|
|
274
|
+
})
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Spy Callback Signature
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
type TWorkflowSpy<T, I, IR> = (
|
|
281
|
+
event: string,
|
|
282
|
+
eventOutput: string | undefined | { fn: string | Function, result: boolean },
|
|
283
|
+
flowOutput: TFlowOutput<T, I, IR>,
|
|
284
|
+
ms?: number,
|
|
285
|
+
) => void
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Spy Events
|
|
289
|
+
|
|
290
|
+
| Event | When | eventOutput |
|
|
291
|
+
|-------|------|-------------|
|
|
292
|
+
| `'workflow-start'` | Workflow begins | `undefined` |
|
|
293
|
+
| `'subflow-start'` | Nested sub-workflow begins | `undefined` |
|
|
294
|
+
| `'step'` | A step finishes executing | Step ID (string) |
|
|
295
|
+
| `'eval-condition-fn'` | Step condition evaluated | `{ fn, result }` |
|
|
296
|
+
| `'eval-while-cond'` | Loop condition evaluated | `{ fn, result }` |
|
|
297
|
+
| `'eval-break-fn'` | Break condition evaluated | `{ fn, result }` |
|
|
298
|
+
| `'eval-continue-fn'` | Continue condition evaluated | `{ fn, result }` |
|
|
299
|
+
| `'workflow-end'` | Workflow completes | `undefined` |
|
|
300
|
+
| `'workflow-interrupt'` | Workflow pauses | `undefined` |
|
|
301
|
+
| `'subflow-end'` | Sub-workflow completes | `undefined` |
|
|
302
|
+
|
|
303
|
+
### Detaching
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
// Option 1: returned function
|
|
307
|
+
const detach = wf.attachSpy(mySpy)
|
|
308
|
+
detach()
|
|
309
|
+
|
|
310
|
+
// Option 2: explicit
|
|
311
|
+
wf.detachSpy(mySpy)
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## Best Practices
|
|
315
|
+
|
|
316
|
+
- A `retry()` or `resume()` call re-executes only the paused/failed step and continues — it does not re-run previously completed steps.
|
|
317
|
+
- Keep spy callbacks lightweight — they run synchronously during execution. Buffer heavy processing.
|
|
318
|
+
- The `state` object is plain JSON — safe for serialization, database storage, and API responses.
|
|
319
|
+
- Type the `MoostWf` instance generically (`MoostWf<TMyContext>`) to get typed output.
|
|
320
|
+
|
|
321
|
+
## Gotchas
|
|
322
|
+
|
|
323
|
+
- `finished: true` with an `error` means unrecoverable failure (regular `throw`). `finished: false` with `error` means retriable (`StepRetriableError`).
|
|
324
|
+
- The `expires` field is informational — the engine does not enforce it. Your application must check and reject stale resumes.
|
|
325
|
+
- `result.resume` and `result.retry` are convenience functions for in-process use. For cross-process resumption, use `wf.resume(state, input)` instead.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Integration — @moostjs/event-wf
|
|
2
|
+
|
|
3
|
+
> Triggering workflows from HTTP/CLI handlers, using Moost features in steps, and multi-adapter setup.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
Workflow steps are regular Moost event handlers. The same interceptor, pipe, and DI mechanisms that work with HTTP and CLI handlers work with workflows. This page covers cross-event-type integration.
|
|
8
|
+
|
|
9
|
+
## Triggering Workflows from HTTP
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Controller, Param } from 'moost'
|
|
13
|
+
import { Post, Body } from '@moostjs/event-http'
|
|
14
|
+
import { MoostWf } from '@moostjs/event-wf'
|
|
15
|
+
|
|
16
|
+
interface TTicketContext {
|
|
17
|
+
ticketId: string
|
|
18
|
+
description: string
|
|
19
|
+
status: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
@Controller('tickets')
|
|
23
|
+
export class TicketController {
|
|
24
|
+
constructor(private wf: MoostWf<TTicketContext>) {}
|
|
25
|
+
|
|
26
|
+
@Post()
|
|
27
|
+
async createTicket(@Body() body: { description: string }) {
|
|
28
|
+
const result = await this.wf.start('support-ticket', {
|
|
29
|
+
ticketId: generateId(),
|
|
30
|
+
description: body.description,
|
|
31
|
+
status: 'new',
|
|
32
|
+
})
|
|
33
|
+
return {
|
|
34
|
+
ticketId: result.state.context.ticketId,
|
|
35
|
+
finished: result.finished,
|
|
36
|
+
inputRequired: result.inputRequired,
|
|
37
|
+
state: result.finished ? undefined : result.state,
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Post(':id/resume')
|
|
42
|
+
async resumeTicket(
|
|
43
|
+
@Param('id') id: string,
|
|
44
|
+
@Body() body: { state: any, input: any },
|
|
45
|
+
) {
|
|
46
|
+
const result = await this.wf.resume(body.state, body.input)
|
|
47
|
+
return { ticketId: id, finished: result.finished }
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The workflow state is plain JSON — return it directly to clients or store in a database.
|
|
53
|
+
|
|
54
|
+
## Triggering Workflows from CLI
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { Controller } from 'moost'
|
|
58
|
+
import { Cli, CliOption } from '@moostjs/event-cli'
|
|
59
|
+
import { MoostWf } from '@moostjs/event-wf'
|
|
60
|
+
|
|
61
|
+
@Controller()
|
|
62
|
+
export class DeployCommand {
|
|
63
|
+
constructor(private wf: MoostWf) {}
|
|
64
|
+
|
|
65
|
+
@Cli('deploy :env')
|
|
66
|
+
async deploy(
|
|
67
|
+
@Param('env') env: string,
|
|
68
|
+
@CliOption('dry-run', 'Simulate without applying') dryRun?: boolean,
|
|
69
|
+
) {
|
|
70
|
+
const result = await this.wf.start('deploy', {
|
|
71
|
+
environment: env,
|
|
72
|
+
dryRun: !!dryRun,
|
|
73
|
+
})
|
|
74
|
+
return result.finished
|
|
75
|
+
? `Deployed to ${env} successfully`
|
|
76
|
+
: `Deploy paused: ${JSON.stringify(result.inputRequired)}`
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Multi-Adapter Setup
|
|
82
|
+
|
|
83
|
+
Register both adapters in a single application:
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
import { Moost } from 'moost'
|
|
87
|
+
import { MoostHttp } from '@moostjs/event-http'
|
|
88
|
+
import { MoostWf } from '@moostjs/event-wf'
|
|
89
|
+
|
|
90
|
+
const app = new Moost()
|
|
91
|
+
|
|
92
|
+
app.adapter(new MoostHttp()).listen(3000)
|
|
93
|
+
app.adapter(new MoostWf())
|
|
94
|
+
|
|
95
|
+
app.registerControllers(
|
|
96
|
+
TicketController, // HTTP handlers
|
|
97
|
+
TicketWfController, // Workflow steps
|
|
98
|
+
).init()
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Interceptors on Workflow Steps
|
|
102
|
+
|
|
103
|
+
Use `@Intercept` for pre/post logic on steps:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
import { Intercept } from 'moost'
|
|
107
|
+
|
|
108
|
+
@Controller()
|
|
109
|
+
export class TicketWfController {
|
|
110
|
+
@Step('assign')
|
|
111
|
+
@Intercept(LogStepExecution)
|
|
112
|
+
assign(@WorkflowParam('context') ctx: TTicketContext) {
|
|
113
|
+
ctx.assignee = findAvailableAgent()
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
All interceptor priority levels work — guards, error handlers, `AFTER_ALL` cleanup. Global interceptors registered with Moost also apply to workflow steps.
|
|
119
|
+
|
|
120
|
+
## Pipes for Validation
|
|
121
|
+
|
|
122
|
+
Use `@Pipe` to transform or validate data flowing into steps:
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
import { Pipe } from 'moost'
|
|
126
|
+
|
|
127
|
+
@Step('process-data')
|
|
128
|
+
processData(
|
|
129
|
+
@WorkflowParam('input')
|
|
130
|
+
@Pipe(validateInput)
|
|
131
|
+
input: TProcessInput,
|
|
132
|
+
) {
|
|
133
|
+
// input is validated before reaching this point
|
|
134
|
+
}
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## Dependency Injection
|
|
138
|
+
|
|
139
|
+
Workflow controllers support the same DI as the rest of Moost:
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
@Injectable('FOR_EVENT')
|
|
143
|
+
@Controller()
|
|
144
|
+
export class TicketWfController {
|
|
145
|
+
constructor(
|
|
146
|
+
private emailService: EmailService,
|
|
147
|
+
private ticketRepo: TicketRepository,
|
|
148
|
+
) {}
|
|
149
|
+
|
|
150
|
+
@Step('notify-assignee')
|
|
151
|
+
async notifyAssignee(@WorkflowParam('context') ctx: TTicketContext) {
|
|
152
|
+
await this.emailService.send(ctx.assignee!, `Ticket ${ctx.ticketId} assigned`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
@Step('save-ticket')
|
|
156
|
+
async saveTicket(@WorkflowParam('context') ctx: TTicketContext) {
|
|
157
|
+
await this.ticketRepo.update(ctx.ticketId, { status: ctx.status })
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Accessing the Underlying WooksWf
|
|
163
|
+
|
|
164
|
+
For advanced scenarios:
|
|
165
|
+
|
|
166
|
+
```ts
|
|
167
|
+
const wf = new MoostWf()
|
|
168
|
+
const wooksWf = wf.getWfApp()
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
## Best Practices
|
|
172
|
+
|
|
173
|
+
- Inject `MoostWf` via constructor DI to use it from HTTP/CLI controllers — the adapter instance is registered in the DI container.
|
|
174
|
+
- Keep workflow controllers separate from HTTP/CLI controllers for clarity, even though they can be combined.
|
|
175
|
+
- Global interceptors apply to all event types uniformly — use `contextType` checks if you need event-type-specific behavior.
|
|
176
|
+
|
|
177
|
+
## Gotchas
|
|
178
|
+
|
|
179
|
+
- When using `MoostWf` from a constructor-injected service, ensure `app.init()` has been called before starting workflows — steps must be registered first.
|
|
180
|
+
- The adapter name is `'workflow'` (`wf.name === 'workflow'`).
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# Schemas & Flow Control — @moostjs/event-wf
|
|
2
|
+
|
|
3
|
+
> Defining step sequences, conditional execution, loops, break/continue, and nested sub-workflows.
|
|
4
|
+
|
|
5
|
+
## Concepts
|
|
6
|
+
|
|
7
|
+
A workflow schema is an array attached to a `@Workflow` entry point via `@WorkflowSchema`. It declares which steps run, in what order, and under what conditions. Schemas support linear sequences, conditional steps, loops, break/continue flow control, and nesting.
|
|
8
|
+
|
|
9
|
+
## Linear Schemas
|
|
10
|
+
|
|
11
|
+
The simplest schema is a flat list of step IDs:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
@Workflow('deploy')
|
|
15
|
+
@WorkflowSchema(['build', 'test', 'publish'])
|
|
16
|
+
deploy() {}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Steps run one after another in order.
|
|
20
|
+
|
|
21
|
+
## Conditional Steps
|
|
22
|
+
|
|
23
|
+
Add a `condition` to run a step only when it evaluates to `true`:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
@WorkflowSchema<TCampaignContext>([
|
|
27
|
+
'prepare-audience',
|
|
28
|
+
{ condition: (ctx) => ctx.audienceSize > 0, id: 'request-approval' },
|
|
29
|
+
{ condition: (ctx) => ctx.approved, id: 'send-emails' },
|
|
30
|
+
'generate-report',
|
|
31
|
+
])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
If a condition returns `false`, that step is skipped and execution continues.
|
|
35
|
+
|
|
36
|
+
## String Conditions
|
|
37
|
+
|
|
38
|
+
Conditions can be strings evaluated against the workflow context using a `with(ctx)` scope. This makes them serializable for database storage:
|
|
39
|
+
|
|
40
|
+
```ts
|
|
41
|
+
@WorkflowSchema<TCampaignContext>([
|
|
42
|
+
'prepare-audience',
|
|
43
|
+
{ condition: 'audienceSize > 0', id: 'request-approval' },
|
|
44
|
+
{ condition: 'approved', id: 'send-emails' },
|
|
45
|
+
])
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
String conditions reference context fields directly: `'amount > 100'`, `'status === "active"'`, etc. They are evaluated using `new Function()` with context properties as globals.
|
|
49
|
+
|
|
50
|
+
## Loops
|
|
51
|
+
|
|
52
|
+
Use `while` with a nested `steps` array to repeat steps until the condition becomes `false`:
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
@WorkflowSchema<TContext>([
|
|
56
|
+
{
|
|
57
|
+
while: (ctx) => !ctx.sent && ctx.retries < 3,
|
|
58
|
+
steps: ['attempt-send', 'increment-retries'],
|
|
59
|
+
},
|
|
60
|
+
{ condition: (ctx) => !ctx.sent, id: 'log-failure' },
|
|
61
|
+
])
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
The `while` condition (function or string) is checked before each iteration.
|
|
65
|
+
|
|
66
|
+
## Break and Continue
|
|
67
|
+
|
|
68
|
+
Inside a loop's `steps` array:
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
@WorkflowSchema<TProcessContext>([
|
|
72
|
+
{
|
|
73
|
+
while: 'running',
|
|
74
|
+
steps: [
|
|
75
|
+
'check-temperature',
|
|
76
|
+
{ break: 'temperature > safeLimit' }, // exits the loop
|
|
77
|
+
'process-batch',
|
|
78
|
+
{ continue: 'skipCooling' }, // skips to next iteration
|
|
79
|
+
'cool-down',
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
'shutdown',
|
|
83
|
+
])
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- **`break`** — exits the loop immediately when the condition is `true`
|
|
87
|
+
- **`continue`** — skips remaining steps in the current iteration and starts the next one
|
|
88
|
+
|
|
89
|
+
Both accept function or string conditions.
|
|
90
|
+
|
|
91
|
+
## Nested Sub-Workflows
|
|
92
|
+
|
|
93
|
+
Group steps with a `steps` array without `while` to apply a shared condition to a block:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
@WorkflowSchema<TCampaignContext>([
|
|
97
|
+
'prepare-audience',
|
|
98
|
+
{
|
|
99
|
+
condition: (ctx) => ctx.audienceSize > 1000,
|
|
100
|
+
steps: ['segment-audience', 'schedule-batches', 'warm-up-ips'],
|
|
101
|
+
},
|
|
102
|
+
'send-emails',
|
|
103
|
+
])
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
## Input in Schema
|
|
107
|
+
|
|
108
|
+
Pass input to a specific step directly from the schema:
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
@WorkflowSchema([
|
|
112
|
+
{ id: 'notify', input: { channel: 'email' } },
|
|
113
|
+
{ id: 'notify', input: { channel: 'sms' } },
|
|
114
|
+
])
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
The step receives this via `@WorkflowParam('input')`.
|
|
118
|
+
|
|
119
|
+
## Schema Type Reference
|
|
120
|
+
|
|
121
|
+
A schema is an array of items (`TWorkflowSchema<T>`). Each item can be:
|
|
122
|
+
|
|
123
|
+
| Form | Description |
|
|
124
|
+
|------|-------------|
|
|
125
|
+
| `'stepId'` | Run the step unconditionally |
|
|
126
|
+
| `{ id: 'stepId' }` | Run the step (same as string form) |
|
|
127
|
+
| `{ id: 'stepId', condition: fn \| string }` | Run only if condition passes |
|
|
128
|
+
| `{ id: 'stepId', input: value }` | Run with specific input |
|
|
129
|
+
| `{ steps: [...] }` | Nested group of steps |
|
|
130
|
+
| `{ steps: [...], condition: fn \| string }` | Conditional group |
|
|
131
|
+
| `{ steps: [...], while: fn \| string }` | Loop until condition is false |
|
|
132
|
+
| `{ break: fn \| string }` | Exit enclosing loop if condition passes |
|
|
133
|
+
| `{ continue: fn \| string }` | Skip to next loop iteration if condition passes |
|
|
134
|
+
|
|
135
|
+
## TypeScript Types
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
type TWorkflowSchema<T> = TWorkflowItem<T>[]
|
|
139
|
+
|
|
140
|
+
type TWorkflowItem<T> =
|
|
141
|
+
| string // simple step ID
|
|
142
|
+
| TWorkflowStepSchemaObj<T, any> // step with condition/input
|
|
143
|
+
| TSubWorkflowSchemaObj<T> // nested steps
|
|
144
|
+
| TWorkflowControl<T> // break or continue
|
|
145
|
+
|
|
146
|
+
interface TWorkflowStepSchemaObj<T, I> {
|
|
147
|
+
id: string
|
|
148
|
+
condition?: string | ((ctx: T) => boolean | Promise<boolean>)
|
|
149
|
+
input?: I
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
interface TSubWorkflowSchemaObj<T> {
|
|
153
|
+
steps: TWorkflowSchema<T>
|
|
154
|
+
condition?: string | ((ctx: T) => boolean | Promise<boolean>)
|
|
155
|
+
while?: string | ((ctx: T) => boolean | Promise<boolean>)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
type TWorkflowControl<T> =
|
|
159
|
+
| { continue: string | ((ctx: T) => boolean | Promise<boolean>) }
|
|
160
|
+
| { break: string | ((ctx: T) => boolean | Promise<boolean>) }
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Common Patterns
|
|
164
|
+
|
|
165
|
+
### Pattern: Retry Loop with Break
|
|
166
|
+
|
|
167
|
+
```ts
|
|
168
|
+
@WorkflowSchema<TContext>([
|
|
169
|
+
{
|
|
170
|
+
while: (ctx) => ctx.attempts < ctx.maxAttempts,
|
|
171
|
+
steps: [
|
|
172
|
+
'attempt-operation',
|
|
173
|
+
{ break: 'succeeded' },
|
|
174
|
+
'wait-before-retry',
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
{ condition: (ctx) => ctx.succeeded, id: 'report-success' },
|
|
178
|
+
{ condition: (ctx) => !ctx.succeeded, id: 'escalate-failure' },
|
|
179
|
+
])
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
### Pattern: Type-Safe Conditions
|
|
183
|
+
|
|
184
|
+
Pass your context type as a generic to get autocomplete and compile-time checks on conditions:
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
@WorkflowSchema<TOnboardingContext>([
|
|
188
|
+
'verify-email',
|
|
189
|
+
{ condition: (ctx) => ctx.emailVerified, id: 'collect-profile' }, // ctx is typed
|
|
190
|
+
{ condition: (ctx) => ctx.profileComplete, id: 'send-welcome' },
|
|
191
|
+
])
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## Best Practices
|
|
195
|
+
|
|
196
|
+
- Use function conditions for type safety; use string conditions when schemas are stored in a database.
|
|
197
|
+
- Keep schemas flat when possible — deep nesting reduces readability.
|
|
198
|
+
- The `@Workflow` entry point method body should be empty — all logic lives in the steps.
|
|
199
|
+
|
|
200
|
+
## Gotchas
|
|
201
|
+
|
|
202
|
+
- String conditions are evaluated with `new Function()` and `with(ctx)`. Only context properties are available as globals — no access to imports or closures.
|
|
203
|
+
- Condition functions can be async (return `Promise<boolean>`).
|