@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 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.5",
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.8"
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.8",
56
- "wooks": "^0.7.8",
57
- "moost": "^0.6.5"
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
  }
@@ -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>`).