@moostjs/event-wf 0.6.5 → 0.6.7
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/dist/index.cjs +2 -1
- package/dist/index.mjs +2 -1
- package/package.json +7 -13
- package/scripts/setup-skills.js +0 -78
- package/skills/moostjs-event-wf/SKILL.md +0 -40
- package/skills/moostjs-event-wf/core.md +0 -138
- package/skills/moostjs-event-wf/decorators.md +0 -179
- package/skills/moostjs-event-wf/execution.md +0 -325
- package/skills/moostjs-event-wf/integration.md +0 -180
- package/skills/moostjs-event-wf/schemas.md +0 -203
package/dist/index.cjs
CHANGED
|
@@ -239,6 +239,7 @@ const CONTEXT_TYPE = "WF";
|
|
|
239
239
|
resolveArgs: opts.resolveArgs,
|
|
240
240
|
manualUnscope: true,
|
|
241
241
|
targetPath,
|
|
242
|
+
controllerPrefix: opts.prefix,
|
|
242
243
|
handlerType: handler.type
|
|
243
244
|
});
|
|
244
245
|
if (handler.type === "WF_STEP") {
|
|
@@ -262,7 +263,7 @@ const CONTEXT_TYPE = "WF";
|
|
|
262
263
|
let wfSchema = mate.read(opts.fakeInstance, opts.method)?.wfSchema;
|
|
263
264
|
if (!wfSchema) wfSchema = mate.read(opts.fakeInstance)?.wfSchema;
|
|
264
265
|
const _fn = async () => {
|
|
265
|
-
(0, moost.setControllerContext)(this.moost, "bindHandler", targetPath);
|
|
266
|
+
(0, moost.setControllerContext)(this.moost, "bindHandler", targetPath, { prefix: opts.prefix });
|
|
266
267
|
return fn();
|
|
267
268
|
};
|
|
268
269
|
this.toInit.push(() => {
|
package/dist/index.mjs
CHANGED
|
@@ -216,6 +216,7 @@ const CONTEXT_TYPE = "WF";
|
|
|
216
216
|
resolveArgs: opts.resolveArgs,
|
|
217
217
|
manualUnscope: true,
|
|
218
218
|
targetPath,
|
|
219
|
+
controllerPrefix: opts.prefix,
|
|
219
220
|
handlerType: handler.type
|
|
220
221
|
});
|
|
221
222
|
if (handler.type === "WF_STEP") {
|
|
@@ -239,7 +240,7 @@ const CONTEXT_TYPE = "WF";
|
|
|
239
240
|
let wfSchema = mate.read(opts.fakeInstance, opts.method)?.wfSchema;
|
|
240
241
|
if (!wfSchema) wfSchema = mate.read(opts.fakeInstance)?.wfSchema;
|
|
241
242
|
const _fn = async () => {
|
|
242
|
-
setControllerContext(this.moost, "bindHandler", targetPath);
|
|
243
|
+
setControllerContext(this.moost, "bindHandler", targetPath, { prefix: opts.prefix });
|
|
243
244
|
return fn();
|
|
244
245
|
};
|
|
245
246
|
this.toInit.push(() => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@moostjs/event-wf",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.7",
|
|
4
4
|
"description": "@moostjs/event-wf",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"composables",
|
|
@@ -21,13 +21,8 @@
|
|
|
21
21
|
"url": "git+https://github.com/moostjs/moostjs.git",
|
|
22
22
|
"directory": "packages/event-wf"
|
|
23
23
|
},
|
|
24
|
-
"bin": {
|
|
25
|
-
"moostjs-event-wf-skill": "./scripts/setup-skills.js"
|
|
26
|
-
},
|
|
27
24
|
"files": [
|
|
28
|
-
"dist"
|
|
29
|
-
"skills",
|
|
30
|
-
"scripts/setup-skills.js"
|
|
25
|
+
"dist"
|
|
31
26
|
],
|
|
32
27
|
"type": "module",
|
|
33
28
|
"sideEffects": false,
|
|
@@ -44,7 +39,7 @@
|
|
|
44
39
|
},
|
|
45
40
|
"dependencies": {
|
|
46
41
|
"@prostojs/wf": "^0.1.1",
|
|
47
|
-
"@wooksjs/event-wf": "^0.7.
|
|
42
|
+
"@wooksjs/event-wf": "^0.7.9"
|
|
48
43
|
},
|
|
49
44
|
"devDependencies": {
|
|
50
45
|
"vitest": "3.2.4"
|
|
@@ -52,13 +47,12 @@
|
|
|
52
47
|
"peerDependencies": {
|
|
53
48
|
"@prostojs/infact": "^0.4.1",
|
|
54
49
|
"@prostojs/mate": "^0.4.0",
|
|
55
|
-
"@wooksjs/event-core": "^0.7.
|
|
56
|
-
"wooks": "^0.7.
|
|
57
|
-
"moost": "^0.6.
|
|
50
|
+
"@wooksjs/event-core": "^0.7.9",
|
|
51
|
+
"wooks": "^0.7.9",
|
|
52
|
+
"moost": "^0.6.7"
|
|
58
53
|
},
|
|
59
54
|
"scripts": {
|
|
60
55
|
"pub": "pnpm publish --access public",
|
|
61
|
-
"test": "vitest"
|
|
62
|
-
"setup-skills": "node ./scripts/setup-skills.js"
|
|
56
|
+
"test": "vitest"
|
|
63
57
|
}
|
|
64
58
|
}
|
package/scripts/setup-skills.js
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
/* prettier-ignore */
|
|
3
|
-
import fs from 'fs'
|
|
4
|
-
import path from 'path'
|
|
5
|
-
import os from 'os'
|
|
6
|
-
import { fileURLToPath } from 'url'
|
|
7
|
-
|
|
8
|
-
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
9
|
-
|
|
10
|
-
const SKILL_NAME = 'moostjs-event-wf'
|
|
11
|
-
const SKILL_SRC = path.join(__dirname, '..', 'skills', SKILL_NAME)
|
|
12
|
-
|
|
13
|
-
if (!fs.existsSync(SKILL_SRC)) {
|
|
14
|
-
console.error(`No skills found at ${SKILL_SRC}`)
|
|
15
|
-
console.error('Add your SKILL.md files to the skills/' + SKILL_NAME + '/ directory first.')
|
|
16
|
-
process.exit(1)
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const AGENTS = {
|
|
20
|
-
'Claude Code': { dir: '.claude/skills', global: path.join(os.homedir(), '.claude', 'skills') },
|
|
21
|
-
'Cursor': { dir: '.cursor/skills', global: path.join(os.homedir(), '.cursor', 'skills') },
|
|
22
|
-
'Windsurf': { dir: '.windsurf/skills', global: path.join(os.homedir(), '.windsurf', 'skills') },
|
|
23
|
-
'Codex': { dir: '.codex/skills', global: path.join(os.homedir(), '.codex', 'skills') },
|
|
24
|
-
'OpenCode': { dir: '.opencode/skills', global: path.join(os.homedir(), '.opencode', 'skills') },
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const args = process.argv.slice(2)
|
|
28
|
-
const isGlobal = args.includes('--global') || args.includes('-g')
|
|
29
|
-
const isPostinstall = args.includes('--postinstall')
|
|
30
|
-
let installed = 0, skipped = 0
|
|
31
|
-
const installedDirs = []
|
|
32
|
-
|
|
33
|
-
for (const [agentName, cfg] of Object.entries(AGENTS)) {
|
|
34
|
-
const targetBase = isGlobal ? cfg.global : path.join(process.cwd(), cfg.dir)
|
|
35
|
-
const agentRootDir = path.dirname(cfg.global) // Check if the agent has ever been installed globally
|
|
36
|
-
|
|
37
|
-
// In postinstall mode: silently skip agents that aren't set up globally
|
|
38
|
-
if (isPostinstall || isGlobal) {
|
|
39
|
-
if (!fs.existsSync(agentRootDir)) { skipped++; continue }
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const dest = path.join(targetBase, SKILL_NAME)
|
|
43
|
-
try {
|
|
44
|
-
fs.mkdirSync(dest, { recursive: true })
|
|
45
|
-
fs.cpSync(SKILL_SRC, dest, { recursive: true })
|
|
46
|
-
console.log(`✅ ${agentName}: installed to ${dest}`)
|
|
47
|
-
installed++
|
|
48
|
-
if (!isGlobal) installedDirs.push(cfg.dir + '/' + SKILL_NAME)
|
|
49
|
-
} catch (err) {
|
|
50
|
-
console.warn(`⚠️ ${agentName}: failed — ${err.message}`)
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Add locally-installed skill dirs to .gitignore
|
|
55
|
-
if (!isGlobal && installedDirs.length > 0) {
|
|
56
|
-
const gitignorePath = path.join(process.cwd(), '.gitignore')
|
|
57
|
-
let gitignoreContent = ''
|
|
58
|
-
try { gitignoreContent = fs.readFileSync(gitignorePath, 'utf8') } catch {}
|
|
59
|
-
const linesToAdd = installedDirs.filter(d => !gitignoreContent.includes(d))
|
|
60
|
-
if (linesToAdd.length > 0) {
|
|
61
|
-
const hasHeader = gitignoreContent.includes('# AI agent skills')
|
|
62
|
-
const block = (gitignoreContent && !gitignoreContent.endsWith('\n') ? '\n' : '')
|
|
63
|
-
+ (hasHeader ? '' : '\n# AI agent skills (auto-generated by setup-skills)\n')
|
|
64
|
-
+ linesToAdd.join('\n') + '\n'
|
|
65
|
-
fs.appendFileSync(gitignorePath, block)
|
|
66
|
-
console.log(`📝 Added ${linesToAdd.length} entries to .gitignore`)
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (installed === 0 && isPostinstall) {
|
|
71
|
-
// Silence is fine — no agents present, nothing to do
|
|
72
|
-
} else if (installed === 0 && skipped === Object.keys(AGENTS).length) {
|
|
73
|
-
console.log('No agent directories detected. Try --global or run without it for project-local install.')
|
|
74
|
-
} else if (installed === 0) {
|
|
75
|
-
console.log('Nothing installed. Run without --global to install project-locally.')
|
|
76
|
-
} else {
|
|
77
|
-
console.log(`\n✨ Done! Restart your AI agent to pick up the "${SKILL_NAME}" skill.`)
|
|
78
|
-
}
|
|
@@ -1,40 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: moostjs-event-wf
|
|
3
|
-
description: Use this skill when working with @moostjs/event-wf — to define workflow steps with @Step(), create workflow entry points with @Workflow() and @WorkflowSchema(), inject workflow data with @WorkflowParam(), set up MoostWf adapter, start and resume workflows with wf.start()/wf.resume(), handle pause/resume with inputRequired, use StepRetriableError for recoverable failures, observe execution with attachSpy(), access workflow state with useWfState(), build conditional and looping schemas, integrate workflows with HTTP/CLI handlers, or use wfKind for event-type-aware logic.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# @moostjs/event-wf
|
|
7
|
-
|
|
8
|
-
Workflow event adapter for Moost, wrapping `@wooksjs/event-wf` and `@prostojs/wf`. Define workflow steps and flows using decorators, with full Moost DI, interceptors, and pipes.
|
|
9
|
-
|
|
10
|
-
## How to use this skill
|
|
11
|
-
|
|
12
|
-
Read the domain file that matches the task. Do not load all files — only what you need.
|
|
13
|
-
|
|
14
|
-
| Domain | File | Load when... |
|
|
15
|
-
|--------|------|------------|
|
|
16
|
-
| Core concepts & setup | [core.md](core.md) | Starting a new project, installing the package, understanding the mental model, configuring MoostWf |
|
|
17
|
-
| Decorators & composables | [decorators.md](decorators.md) | Using @Step, @Workflow, @WorkflowSchema, @WorkflowParam, useWfState(), wfKind |
|
|
18
|
-
| Schemas & flow control | [schemas.md](schemas.md) | Defining step sequences, conditions, loops, break/continue, nested sub-workflows |
|
|
19
|
-
| Execution & lifecycle | [execution.md](execution.md) | Starting/resuming workflows, reading TFlowOutput, pause/resume, StepRetriableError, spies |
|
|
20
|
-
| Integration | [integration.md](integration.md) | Triggering workflows from HTTP/CLI, using interceptors/pipes/DI in steps, multi-adapter setup |
|
|
21
|
-
|
|
22
|
-
## Quick reference
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
import {
|
|
26
|
-
MoostWf, Step, Workflow, WorkflowSchema, WorkflowParam,
|
|
27
|
-
StepRetriableError, useWfState, wfKind,
|
|
28
|
-
} from '@moostjs/event-wf'
|
|
29
|
-
|
|
30
|
-
// Register adapter
|
|
31
|
-
const wf = new MoostWf()
|
|
32
|
-
app.adapter(wf)
|
|
33
|
-
|
|
34
|
-
// Start / resume
|
|
35
|
-
const result = await wf.start('schema-id', initialContext, input?)
|
|
36
|
-
const resumed = await wf.resume(result.state, newInput)
|
|
37
|
-
|
|
38
|
-
// Observe
|
|
39
|
-
const detach = wf.attachSpy((event, output, flow, ms) => { ... })
|
|
40
|
-
```
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
# Core Concepts & Setup — @moostjs/event-wf
|
|
2
|
-
|
|
3
|
-
> Installation, mental model, adapter configuration, and getting started with Moost workflows.
|
|
4
|
-
|
|
5
|
-
## Concepts
|
|
6
|
-
|
|
7
|
-
Moost Workflows model multi-step processes as composable, typed sequences of operations. The key abstractions:
|
|
8
|
-
|
|
9
|
-
- **Steps** — Individual units of work. Controller methods decorated with `@Step` that read/write shared context.
|
|
10
|
-
- **Schemas** — Declarative arrays defining execution order, conditions, and loops.
|
|
11
|
-
- **Context** — A typed object that holds shared state across all steps in a workflow.
|
|
12
|
-
- **MoostWf** — The adapter class that bridges `@wooksjs/event-wf` into Moost's decorator/DI system.
|
|
13
|
-
- **TFlowOutput** — The result object from `start()`/`resume()` containing state, completion status, and resume functions.
|
|
14
|
-
|
|
15
|
-
Workflows are a good fit when your process has multiple stages, needs branching, can be interrupted for external input, requires auditability, or spans time.
|
|
16
|
-
|
|
17
|
-
## Installation
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npm install @moostjs/event-wf
|
|
21
|
-
# or
|
|
22
|
-
pnpm add @moostjs/event-wf
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
Peer dependencies: `moost`, `@wooksjs/event-core`, `@prostojs/infact`, `@prostojs/mate`, `wooks`.
|
|
26
|
-
|
|
27
|
-
## Getting Started
|
|
28
|
-
|
|
29
|
-
### 1. Define a workflow controller
|
|
30
|
-
|
|
31
|
-
```ts
|
|
32
|
-
import { Controller, Injectable } from 'moost'
|
|
33
|
-
import { Step, Workflow, WorkflowParam, WorkflowSchema } from '@moostjs/event-wf'
|
|
34
|
-
|
|
35
|
-
interface TOrderContext {
|
|
36
|
-
orderId: string
|
|
37
|
-
validated: boolean
|
|
38
|
-
charged: boolean
|
|
39
|
-
shipped: boolean
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
@Injectable('FOR_EVENT')
|
|
43
|
-
@Controller()
|
|
44
|
-
export class OrderController {
|
|
45
|
-
@WorkflowParam('context')
|
|
46
|
-
ctx!: TOrderContext
|
|
47
|
-
|
|
48
|
-
@Workflow('process-order')
|
|
49
|
-
@WorkflowSchema<TOrderContext>(['validate', 'charge', 'ship'])
|
|
50
|
-
processOrder() {}
|
|
51
|
-
|
|
52
|
-
@Step('validate')
|
|
53
|
-
validate() {
|
|
54
|
-
this.ctx.validated = true
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
@Step('charge')
|
|
58
|
-
charge() {
|
|
59
|
-
this.ctx.charged = true
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
@Step('ship')
|
|
63
|
-
ship() {
|
|
64
|
-
this.ctx.shipped = true
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### 2. Register the adapter and start
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
import { Moost } from 'moost'
|
|
73
|
-
import { MoostWf } from '@moostjs/event-wf'
|
|
74
|
-
import { OrderController } from './order.controller'
|
|
75
|
-
|
|
76
|
-
const app = new Moost()
|
|
77
|
-
const wf = new MoostWf()
|
|
78
|
-
|
|
79
|
-
app.adapter(wf)
|
|
80
|
-
app.registerControllers(OrderController)
|
|
81
|
-
await app.init()
|
|
82
|
-
|
|
83
|
-
const result = await wf.start('process-order', {
|
|
84
|
-
orderId: 'ORD-001',
|
|
85
|
-
validated: false,
|
|
86
|
-
charged: false,
|
|
87
|
-
shipped: false,
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
console.log(result.finished) // true
|
|
91
|
-
console.log(result.state.context)
|
|
92
|
-
// { orderId: 'ORD-001', validated: true, charged: true, shipped: true }
|
|
93
|
-
```
|
|
94
|
-
|
|
95
|
-
## MoostWf Constructor
|
|
96
|
-
|
|
97
|
-
```ts
|
|
98
|
-
new MoostWf<T, IR>(opts?: WooksWf<T, IR> | TWooksWfOptions, debug?: boolean)
|
|
99
|
-
```
|
|
100
|
-
|
|
101
|
-
| Parameter | Type | Description |
|
|
102
|
-
|-----------|------|-------------|
|
|
103
|
-
| `opts` | `WooksWf \| TWooksWfOptions \| undefined` | Pre-existing WooksWf instance, config options, or undefined for defaults |
|
|
104
|
-
| `debug` | `boolean` | Enable error logging |
|
|
105
|
-
|
|
106
|
-
**TWooksWfOptions fields:**
|
|
107
|
-
|
|
108
|
-
| Field | Type | Description |
|
|
109
|
-
|-------|------|-------------|
|
|
110
|
-
| `onError` | `(e: Error) => void` | Global error handler |
|
|
111
|
-
| `onNotFound` | `TWooksHandler` | Handler for unregistered steps |
|
|
112
|
-
| `onUnknownFlow` | `(schemaId: string, raiseError: () => void) => unknown` | Handler for unknown workflow schemas |
|
|
113
|
-
| `logger` | `TConsoleBase` | Custom logger instance |
|
|
114
|
-
| `eventOptions` | `EventContextOptions` | Event context configuration |
|
|
115
|
-
| `router` | `TWooksOptions['router']` | Router configuration |
|
|
116
|
-
|
|
117
|
-
You can also pass an existing `WooksWf` instance to share the workflow engine with non-Moost code:
|
|
118
|
-
|
|
119
|
-
```ts
|
|
120
|
-
const wooksWf = createWfApp()
|
|
121
|
-
const wf = new MoostWf(wooksWf)
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
## Lifecycle Overview
|
|
125
|
-
|
|
126
|
-
1. `app.adapter(wf)` registers the adapter
|
|
127
|
-
2. `app.registerControllers(...)` registers controllers
|
|
128
|
-
3. `app.init()` processes decorators and binds `@Step`/`@Workflow` handlers
|
|
129
|
-
4. `wf.start(schemaId, context)` starts workflow execution
|
|
130
|
-
5. Steps execute in sequence, reading/mutating shared context
|
|
131
|
-
6. Workflow completes or pauses (if a step needs input)
|
|
132
|
-
|
|
133
|
-
## Best Practices
|
|
134
|
-
|
|
135
|
-
- Use `@Injectable('FOR_EVENT')` on workflow controllers to get fresh instances per step execution. Without it, the controller is a singleton — class properties would be shared across concurrent workflows.
|
|
136
|
-
- Keep the `@Workflow` entry point method body empty — all logic belongs in steps.
|
|
137
|
-
- Define a TypeScript interface for your context type and pass it as a generic to `@WorkflowSchema<T>()` for type-safe conditions.
|
|
138
|
-
- The workflow `state` object is plain JSON — you can serialize and store it for later resumption.
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
# Decorators & Composables — @moostjs/event-wf
|
|
2
|
-
|
|
3
|
-
> All decorators and composables exported by @moostjs/event-wf for defining and accessing workflow handlers.
|
|
4
|
-
|
|
5
|
-
## API Reference
|
|
6
|
-
|
|
7
|
-
### `@Step(path?)`
|
|
8
|
-
|
|
9
|
-
Marks a controller method as a workflow step handler.
|
|
10
|
-
|
|
11
|
-
- **`path`** *(string, optional)* — Step identifier used in schemas. Defaults to the method name.
|
|
12
|
-
|
|
13
|
-
```ts
|
|
14
|
-
import { Step, WorkflowParam } from '@moostjs/event-wf'
|
|
15
|
-
|
|
16
|
-
@Step('validate')
|
|
17
|
-
validate(@WorkflowParam('context') ctx: TOrderContext) {
|
|
18
|
-
ctx.validated = true
|
|
19
|
-
}
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
Steps can have parametric paths (like HTTP routes):
|
|
23
|
-
|
|
24
|
-
```ts
|
|
25
|
-
import { Param } from 'moost'
|
|
26
|
-
|
|
27
|
-
@Step('notify/:channel(email|sms)')
|
|
28
|
-
notify(@Param('channel') channel: 'email' | 'sms') {
|
|
29
|
-
// Reference in schema: { id: 'notify/email' }
|
|
30
|
-
}
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
### `@Workflow(path?)`
|
|
34
|
-
|
|
35
|
-
Marks a controller method as a workflow entry point. Must be paired with `@WorkflowSchema`.
|
|
36
|
-
|
|
37
|
-
- **`path`** *(string, optional)* — Workflow identifier. Defaults to the method name.
|
|
38
|
-
|
|
39
|
-
The effective schema ID combines the controller prefix and the workflow path. For `@Controller('admin')` + `@Workflow('order')`, the schema ID is `admin/order`.
|
|
40
|
-
|
|
41
|
-
```ts
|
|
42
|
-
@Workflow('order-processing')
|
|
43
|
-
@WorkflowSchema<TOrderContext>(['validate', 'charge', 'ship'])
|
|
44
|
-
processOrder() {}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
### `@WorkflowSchema<T>(schema)`
|
|
48
|
-
|
|
49
|
-
Attaches a workflow schema (step sequence) to a `@Workflow` method.
|
|
50
|
-
|
|
51
|
-
- **`T`** *(generic)* — Workflow context type for type-checked conditions.
|
|
52
|
-
- **`schema`** *(`TWorkflowSchema<T>`)* — Array of step definitions.
|
|
53
|
-
|
|
54
|
-
```ts
|
|
55
|
-
@WorkflowSchema<TMyContext>([
|
|
56
|
-
'step-a',
|
|
57
|
-
{ condition: (ctx) => ctx.ready, id: 'step-b' },
|
|
58
|
-
{ while: 'retries < 3', steps: ['attempt', 'increment'] },
|
|
59
|
-
])
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
See [schemas.md](schemas.md) for full schema syntax.
|
|
63
|
-
|
|
64
|
-
### `WorkflowParam(name)`
|
|
65
|
-
|
|
66
|
-
Parameter and property decorator that injects workflow runtime values.
|
|
67
|
-
|
|
68
|
-
| Name | Type | Description |
|
|
69
|
-
|------|------|-------------|
|
|
70
|
-
| `'context'` | `T` | Shared workflow context object |
|
|
71
|
-
| `'input'` | `I \| undefined` | Input passed to `start()` or `resume()` |
|
|
72
|
-
| `'stepId'` | `string \| null` | Current step ID (`null` in entry point) |
|
|
73
|
-
| `'schemaId'` | `string` | Active workflow schema identifier |
|
|
74
|
-
| `'indexes'` | `number[] \| undefined` | Current position in nested schemas |
|
|
75
|
-
| `'resume'` | `boolean` | `true` when resuming, `false` on first run |
|
|
76
|
-
| `'state'` | `object` | Full state object from `useWfState()` |
|
|
77
|
-
|
|
78
|
-
**As method parameter:**
|
|
79
|
-
|
|
80
|
-
```ts
|
|
81
|
-
@Step('process')
|
|
82
|
-
process(
|
|
83
|
-
@WorkflowParam('context') ctx: TMyContext,
|
|
84
|
-
@WorkflowParam('input') input: TMyInput | undefined,
|
|
85
|
-
@WorkflowParam('resume') isResume: boolean,
|
|
86
|
-
) {
|
|
87
|
-
if (isResume) {
|
|
88
|
-
console.log('Resuming with new input:', input)
|
|
89
|
-
}
|
|
90
|
-
ctx.processed = true
|
|
91
|
-
}
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
**As class property** (requires `@Injectable('FOR_EVENT')`):
|
|
95
|
-
|
|
96
|
-
```ts
|
|
97
|
-
@Injectable('FOR_EVENT')
|
|
98
|
-
@Controller()
|
|
99
|
-
export class MyController {
|
|
100
|
-
@WorkflowParam('context')
|
|
101
|
-
ctx!: TMyContext
|
|
102
|
-
|
|
103
|
-
@Step('review')
|
|
104
|
-
review() {
|
|
105
|
-
this.ctx.status = 'reviewed'
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
### `useWfState()`
|
|
111
|
-
|
|
112
|
-
Composable that returns the current workflow execution state. Available inside step and entry point handlers.
|
|
113
|
-
|
|
114
|
-
```ts
|
|
115
|
-
import { useWfState } from '@moostjs/event-wf'
|
|
116
|
-
|
|
117
|
-
const state = useWfState()
|
|
118
|
-
state.ctx<TMyContext>() // workflow context
|
|
119
|
-
state.input<TMyInput>() // step input (or undefined)
|
|
120
|
-
state.schemaId // workflow schema ID
|
|
121
|
-
state.stepId() // current step ID (or null)
|
|
122
|
-
state.indexes // position in nested schemas
|
|
123
|
-
state.resume // boolean: is this a resume?
|
|
124
|
-
```
|
|
125
|
-
|
|
126
|
-
In most cases, prefer `@WorkflowParam` decorators. The composable is useful for advanced scenarios like custom interceptors or utilities that need workflow context.
|
|
127
|
-
|
|
128
|
-
### `wfKind`
|
|
129
|
-
|
|
130
|
-
Event kind marker for workflow events. Used internally by the adapter and useful for building custom event-type-aware logic.
|
|
131
|
-
|
|
132
|
-
```ts
|
|
133
|
-
import { wfKind } from '@moostjs/event-wf'
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
## Common Patterns
|
|
137
|
-
|
|
138
|
-
### Pattern: Class Property Injection
|
|
139
|
-
|
|
140
|
-
When using `@Injectable('FOR_EVENT')`, inject context as a class property. Multiple steps in the same controller share the property declaration but get separate instances per execution.
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
@Injectable('FOR_EVENT')
|
|
144
|
-
@Controller()
|
|
145
|
-
export class OnboardingController {
|
|
146
|
-
@WorkflowParam('context')
|
|
147
|
-
ctx!: TOnboardingContext
|
|
148
|
-
|
|
149
|
-
@Step('verify-email')
|
|
150
|
-
verifyEmail() { this.ctx.emailVerified = true }
|
|
151
|
-
|
|
152
|
-
@Step('collect-profile')
|
|
153
|
-
collectProfile() { this.ctx.profileComplete = true }
|
|
154
|
-
}
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Pattern: Reusing Steps Across Workflows
|
|
158
|
-
|
|
159
|
-
Steps are resolved by their path through the Wooks router. Any workflow schema can reference any registered step:
|
|
160
|
-
|
|
161
|
-
```ts
|
|
162
|
-
@Controller()
|
|
163
|
-
export class SharedSteps {
|
|
164
|
-
@Step('send-notification')
|
|
165
|
-
sendNotification(@WorkflowParam('context') ctx: { email: string, message: string }) {
|
|
166
|
-
sendEmail(ctx.email, ctx.message)
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Referenced from multiple schemas:
|
|
171
|
-
@WorkflowSchema(['validate', 'process', 'send-notification'])
|
|
172
|
-
@WorkflowSchema(['review', 'approve', 'send-notification'])
|
|
173
|
-
```
|
|
174
|
-
|
|
175
|
-
## Gotchas
|
|
176
|
-
|
|
177
|
-
- `WorkflowParam('resume')` returns a **boolean**, not a function. It indicates whether the current execution is a resume. The resume *function* is on the `TFlowOutput` object returned by `start()` or `resume()`.
|
|
178
|
-
- Without `@Injectable('FOR_EVENT')`, the controller is a singleton. Using class property injection in a singleton causes state to leak between concurrent workflows — use method parameters instead.
|
|
179
|
-
- If you omit the `path` argument from `@Step()` or `@Workflow()`, the method name is used as the identifier.
|
|
@@ -1,325 +0,0 @@
|
|
|
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.
|
|
@@ -1,180 +0,0 @@
|
|
|
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'`).
|
|
@@ -1,203 +0,0 @@
|
|
|
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>`).
|