@moostjs/event-wf 0.5.33 → 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.
@@ -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>`).