ablauf 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +251 -0
- package/dist/module.d.mts +14 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +202 -0
- package/dist/runtime/composables/useWorkflow.d.ts +13 -0
- package/dist/runtime/composables/useWorkflow.js +79 -0
- package/dist/runtime/handler.d.ts +9 -0
- package/dist/runtime/handler.js +22 -0
- package/dist/runtime/server/api/workflows/[name].get.d.ts +2 -0
- package/dist/runtime/server/api/workflows/[name].get.js +17 -0
- package/dist/runtime/server/api/workflows/index.get.d.ts +2 -0
- package/dist/runtime/server/api/workflows/index.get.js +6 -0
- package/dist/runtime/server/plugins/workflow-provider.d.ts +2 -0
- package/dist/runtime/server/plugins/workflow-provider.js +7 -0
- package/dist/runtime/server/provider.d.ts +3 -0
- package/dist/runtime/server/provider.js +12 -0
- package/dist/runtime/server/providers/file.d.ts +2 -0
- package/dist/runtime/server/providers/file.js +29 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/workflow.d.ts +52 -0
- package/dist/runtime/workflow.js +16 -0
- package/dist/types.d.mts +3 -0
- package/package.json +68 -0
- package/src/runtime/composables/useWorkflow.ts +129 -0
- package/src/runtime/handler.ts +40 -0
- package/src/runtime/server/api/workflows/[name].get.ts +20 -0
- package/src/runtime/server/api/workflows/index.get.ts +7 -0
- package/src/runtime/server/plugins/workflow-provider.ts +10 -0
- package/src/runtime/server/provider.ts +16 -0
- package/src/runtime/server/providers/file.ts +33 -0
- package/src/runtime/server/tsconfig.json +3 -0
- package/src/runtime/workflow.ts +101 -0
package/README.md
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
# Ablauf
|
|
2
|
+
|
|
3
|
+
[![npm version][npm-version-src]][npm-version-href]
|
|
4
|
+
[![npm downloads][npm-downloads-src]][npm-downloads-href]
|
|
5
|
+
[![License][license-src]][license-href]
|
|
6
|
+
[![Nuxt][nuxt-src]][nuxt-href]
|
|
7
|
+
|
|
8
|
+
A Nuxt module for defining state machines with typed states, directional transitions, and pluggable handler pipelines.
|
|
9
|
+
|
|
10
|
+
- [✨ Release Notes](/CHANGELOG.md)
|
|
11
|
+
<!-- - [🏀 Online playground](https://stackblitz.com/github/your-org/ablauf?file=playground%2Fapp.vue) -->
|
|
12
|
+
<!-- - [📖 Documentation](https://example.com) -->
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Define workflows as JSON with states, transitions, and directions
|
|
17
|
+
- Transition handler pipelines — run validation, confirmation, or side-effects before a state change
|
|
18
|
+
- Generic `conditions` bag on transitions for custom domain logic (e.g. `nativeOnly`, `billed`)
|
|
19
|
+
- Pluggable storage via `WorkflowProvider` — built-in file provider, or bring your own (database, API, etc.)
|
|
20
|
+
- `useWorkflow` composable with `getNextStates`, `findTransition`, `transition`, and more
|
|
21
|
+
- Auto-imported types, composables, and server utilities
|
|
22
|
+
- Hot-reload for workflow definitions and transition handlers in dev
|
|
23
|
+
|
|
24
|
+
## Quick Setup
|
|
25
|
+
|
|
26
|
+
Install the module:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
npx nuxt module add ablauf
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Add it to your `nuxt.config.ts`:
|
|
33
|
+
|
|
34
|
+
```ts
|
|
35
|
+
export default defineNuxtConfig({
|
|
36
|
+
modules: ['ablauf'],
|
|
37
|
+
|
|
38
|
+
workflow: {
|
|
39
|
+
provider: 'file', // 'file' (default) or 'custom'
|
|
40
|
+
workflowsDir: 'server/workflows', // where JSON definitions live
|
|
41
|
+
exposeApi: true, // register /api/_workflow routes
|
|
42
|
+
},
|
|
43
|
+
})
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Defining a Workflow
|
|
47
|
+
|
|
48
|
+
Create a JSON file in your `workflowsDir`:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
// server/workflows/default.json
|
|
52
|
+
{
|
|
53
|
+
"name": "default",
|
|
54
|
+
"description": "Issue Tracker",
|
|
55
|
+
"states": [
|
|
56
|
+
{ "slug": "backlog", "name": "Backlog", "color": "#6366F1", "category": "start" },
|
|
57
|
+
{ "slug": "todo", "name": "Todo", "color": "#3B82F6", "category": "in-progress" },
|
|
58
|
+
{ "slug": "in-progress", "name": "In Progress", "color": "#F59E0B", "category": "in-progress" },
|
|
59
|
+
{ "slug": "review", "name": "Review", "color": "#8B5CF6", "category": "in-progress" },
|
|
60
|
+
{ "slug": "done", "name": "Done", "color": "#10B981", "category": "end" }
|
|
61
|
+
],
|
|
62
|
+
"transitions": [
|
|
63
|
+
{ "from": "backlog", "to": "todo", "direction": "forward" },
|
|
64
|
+
{ "from": "todo", "to": "in-progress", "direction": "forward",
|
|
65
|
+
"handler": [{ "name": "permission", "params": { "permission": "start-work" } }] },
|
|
66
|
+
{ "from": "in-progress", "to": "review", "direction": "forward" },
|
|
67
|
+
{ "from": "review", "to": "done", "direction": "forward",
|
|
68
|
+
"handler": [{ "name": "confirm", "params": { "message": "Mark as done?" } }] },
|
|
69
|
+
{ "from": "review", "to": "in-progress", "direction": "backward" },
|
|
70
|
+
{ "from": "todo", "to": "backlog", "direction": "backward" }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Each state has a `category` (`start`, `in-progress`, or `end`) that controls behavior — for example, `end` states have no outgoing transitions.
|
|
76
|
+
|
|
77
|
+
## Using the Composable
|
|
78
|
+
|
|
79
|
+
`useWorkflow` is auto-imported and fetches a workflow by name:
|
|
80
|
+
|
|
81
|
+
```vue
|
|
82
|
+
<script setup>
|
|
83
|
+
const workflow = await useWorkflow('default')
|
|
84
|
+
|
|
85
|
+
// Get all states
|
|
86
|
+
workflow.getStates()
|
|
87
|
+
|
|
88
|
+
// Get possible next states from a given state
|
|
89
|
+
workflow.getNextStates('todo', 'forward')
|
|
90
|
+
// => ['in-progress']
|
|
91
|
+
|
|
92
|
+
// Find a specific transition rule
|
|
93
|
+
workflow.findTransition('todo', 'in-progress')
|
|
94
|
+
|
|
95
|
+
// Execute a transition (runs handler pipeline)
|
|
96
|
+
const result = await workflow.transition('todo', 'in-progress', {
|
|
97
|
+
issue: currentIssue,
|
|
98
|
+
})
|
|
99
|
+
// Returns the TransitionRule on success, or false if a handler blocked it
|
|
100
|
+
</script>
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Transition Handlers
|
|
104
|
+
|
|
105
|
+
Handlers are TypeScript files in `app/transitions/` that run during a transition. They can validate, prompt the user, or enrich the transition args.
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
// app/transitions/confirm.ts
|
|
109
|
+
export default defineTransitionHandler({
|
|
110
|
+
name: 'confirm',
|
|
111
|
+
friendlyName: 'Confirm Action',
|
|
112
|
+
description: 'Asks the user to confirm before proceeding.',
|
|
113
|
+
run: async (params, _args) => {
|
|
114
|
+
const message = (params?.message as string) ?? 'Are you sure?'
|
|
115
|
+
if (!window.confirm(message)) {
|
|
116
|
+
return false // blocks the transition
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
})
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Handlers are referenced by name in the workflow JSON:
|
|
123
|
+
|
|
124
|
+
```json
|
|
125
|
+
{ "from": "review", "to": "done", "direction": "forward",
|
|
126
|
+
"handler": [{ "name": "confirm", "params": { "message": "Mark as done?" } }] }
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Multiple handlers run in sequence. A handler can:
|
|
130
|
+
- Return `false` to **block** the transition
|
|
131
|
+
- Return an object to **merge data** into the args for subsequent handlers
|
|
132
|
+
- Return nothing to **allow** the transition to proceed
|
|
133
|
+
|
|
134
|
+
Set `global: true` on a handler to run it on every transition automatically.
|
|
135
|
+
|
|
136
|
+
## Custom Workflow Provider
|
|
137
|
+
|
|
138
|
+
The built-in file provider reads JSON from disk. For database-backed workflows, use the `custom` provider:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
// nuxt.config.ts
|
|
142
|
+
export default defineNuxtConfig({
|
|
143
|
+
workflow: {
|
|
144
|
+
provider: 'custom',
|
|
145
|
+
},
|
|
146
|
+
})
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
Then register your provider in a Nitro plugin:
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
// server/plugins/workflow.ts
|
|
153
|
+
export default defineNitroPlugin(() => {
|
|
154
|
+
setWorkflowProvider({
|
|
155
|
+
async getWorkflow(name) {
|
|
156
|
+
return await db.workflows.findOne({ name })
|
|
157
|
+
},
|
|
158
|
+
async listWorkflows() {
|
|
159
|
+
return await db.workflows.findMany()
|
|
160
|
+
},
|
|
161
|
+
// Optional: enable write operations
|
|
162
|
+
async saveWorkflow(workflow) { /* ... */ },
|
|
163
|
+
async deleteWorkflow(name) { /* ... */ },
|
|
164
|
+
})
|
|
165
|
+
})
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`setWorkflowProvider` and `useWorkflowProvider` are auto-imported in server routes.
|
|
169
|
+
|
|
170
|
+
## Typed Conditions
|
|
171
|
+
|
|
172
|
+
Transitions support a generic `conditions` bag for domain-specific filtering. Define your conditions shape, then pass a `conditionFilter` that decides which transitions are available based on runtime context:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
interface MyConditions {
|
|
176
|
+
role?: string
|
|
177
|
+
feature?: string
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const workflow = await useWorkflow<MyConditions>('default', {
|
|
181
|
+
conditionFilter: (rule, context) => {
|
|
182
|
+
if (rule.conditions?.role && rule.conditions.role !== context.userRole) return false
|
|
183
|
+
if (rule.conditions?.feature && !context.enabledFeatures?.includes(rule.conditions.feature)) return false
|
|
184
|
+
return true
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
// Only transitions matching the current context are returned
|
|
189
|
+
workflow.getNextStates('todo', 'forward', {
|
|
190
|
+
userRole: 'admin',
|
|
191
|
+
enabledFeatures: ['beta'],
|
|
192
|
+
})
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
In the workflow JSON, attach conditions to any transition:
|
|
196
|
+
|
|
197
|
+
```json
|
|
198
|
+
{ "from": "review", "to": "done", "direction": "forward",
|
|
199
|
+
"conditions": { "role": "admin" } }
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
## API Routes
|
|
203
|
+
|
|
204
|
+
When `exposeApi` is enabled (default), two routes are registered:
|
|
205
|
+
|
|
206
|
+
| Route | Description |
|
|
207
|
+
| --- | --- |
|
|
208
|
+
| `GET /api/_workflow` | List all workflows |
|
|
209
|
+
| `GET /api/_workflow/:name` | Get a workflow by name |
|
|
210
|
+
|
|
211
|
+
## Contribution
|
|
212
|
+
|
|
213
|
+
<details>
|
|
214
|
+
<summary>Local development</summary>
|
|
215
|
+
|
|
216
|
+
```bash
|
|
217
|
+
# Install dependencies
|
|
218
|
+
npm install
|
|
219
|
+
|
|
220
|
+
# Generate type stubs
|
|
221
|
+
npm run dev:prepare
|
|
222
|
+
|
|
223
|
+
# Develop with the playground
|
|
224
|
+
npm run dev
|
|
225
|
+
|
|
226
|
+
# Build the playground
|
|
227
|
+
npm run dev:build
|
|
228
|
+
|
|
229
|
+
# Run ESLint
|
|
230
|
+
npm run lint
|
|
231
|
+
|
|
232
|
+
# Run Vitest
|
|
233
|
+
npm run test
|
|
234
|
+
npm run test:watch
|
|
235
|
+
|
|
236
|
+
# Release new version
|
|
237
|
+
npm run release
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
</details>
|
|
241
|
+
|
|
242
|
+
<!-- Badges -->
|
|
243
|
+
|
|
244
|
+
[npm-version-src]: https://img.shields.io/npm/v/ablauf/latest.svg?style=flat&colorA=020420&colorB=00DC82
|
|
245
|
+
[npm-version-href]: https://npmjs.com/package/ablauf
|
|
246
|
+
[npm-downloads-src]: https://img.shields.io/npm/dm/ablauf.svg?style=flat&colorA=020420&colorB=00DC82
|
|
247
|
+
[npm-downloads-href]: https://npm.chart.dev/ablauf
|
|
248
|
+
[license-src]: https://img.shields.io/npm/l/ablauf.svg?style=flat&colorA=020420&colorB=00DC82
|
|
249
|
+
[license-href]: https://npmjs.com/package/ablauf
|
|
250
|
+
[nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt
|
|
251
|
+
[nuxt-href]: https://nuxt.com
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
|
|
3
|
+
interface ModuleOptions {
|
|
4
|
+
/** Directory for workflow definition JSON files (relative to srcDir). Default: 'server/workflows' */
|
|
5
|
+
workflowsDir?: string;
|
|
6
|
+
/** Whether to register API routes for workflows. Default: true */
|
|
7
|
+
exposeApi?: boolean;
|
|
8
|
+
/** Provider type. 'file' uses the built-in file provider. 'custom' expects you to call setWorkflowProvider() in a Nitro plugin. Default: 'file' */
|
|
9
|
+
provider?: 'file' | 'custom';
|
|
10
|
+
}
|
|
11
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
12
|
+
|
|
13
|
+
export { _default as default };
|
|
14
|
+
export type { ModuleOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addImports, addServerImports, addTemplate, addServerHandler, updateTemplates } from '@nuxt/kit';
|
|
2
|
+
import { readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, relative } from 'node:path';
|
|
4
|
+
import { consola } from 'consola';
|
|
5
|
+
|
|
6
|
+
const module$1 = defineNuxtModule({
|
|
7
|
+
meta: {
|
|
8
|
+
name: "ablauf",
|
|
9
|
+
configKey: "workflow"
|
|
10
|
+
},
|
|
11
|
+
defaults: {
|
|
12
|
+
workflowsDir: "server/workflows",
|
|
13
|
+
exposeApi: true,
|
|
14
|
+
provider: "file"
|
|
15
|
+
},
|
|
16
|
+
setup(options, nuxt) {
|
|
17
|
+
const resolver = createResolver(import.meta.url);
|
|
18
|
+
const workflowRuntimeSpecifier = "ablauf/runtime/workflow";
|
|
19
|
+
addImports([
|
|
20
|
+
{ name: "defineTransitionHandler", from: workflowRuntimeSpecifier },
|
|
21
|
+
{
|
|
22
|
+
name: "TransitionHandlerParams",
|
|
23
|
+
from: workflowRuntimeSpecifier,
|
|
24
|
+
type: true
|
|
25
|
+
},
|
|
26
|
+
{ name: "StateArgs", from: workflowRuntimeSpecifier, type: true },
|
|
27
|
+
{
|
|
28
|
+
name: "TransitionHandlerDefinition",
|
|
29
|
+
from: workflowRuntimeSpecifier,
|
|
30
|
+
type: true
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: "TransitionRule",
|
|
34
|
+
from: workflowRuntimeSpecifier,
|
|
35
|
+
type: true
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "TransitionHandler",
|
|
39
|
+
from: workflowRuntimeSpecifier,
|
|
40
|
+
type: true
|
|
41
|
+
},
|
|
42
|
+
{ name: "Workflow", from: workflowRuntimeSpecifier, type: true },
|
|
43
|
+
{
|
|
44
|
+
name: "WorkflowStateDefinition",
|
|
45
|
+
from: workflowRuntimeSpecifier,
|
|
46
|
+
type: true
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "WorkflowProvider",
|
|
50
|
+
from: workflowRuntimeSpecifier,
|
|
51
|
+
type: true
|
|
52
|
+
},
|
|
53
|
+
{ name: "Direction", from: workflowRuntimeSpecifier, type: true },
|
|
54
|
+
{
|
|
55
|
+
name: "TransitionConditionFilter",
|
|
56
|
+
from: workflowRuntimeSpecifier,
|
|
57
|
+
type: true
|
|
58
|
+
},
|
|
59
|
+
{ name: "Directions", from: workflowRuntimeSpecifier },
|
|
60
|
+
{
|
|
61
|
+
name: "TransitionHandlerError",
|
|
62
|
+
from: workflowRuntimeSpecifier
|
|
63
|
+
}
|
|
64
|
+
]);
|
|
65
|
+
addImports([
|
|
66
|
+
{
|
|
67
|
+
name: "useWorkflow",
|
|
68
|
+
from: resolver.resolve("./runtime/composables/useWorkflow")
|
|
69
|
+
}
|
|
70
|
+
]);
|
|
71
|
+
const handlerSpecifier = resolver.resolve("./runtime/handler");
|
|
72
|
+
addImports([
|
|
73
|
+
{ name: "getTransitionHandler", from: handlerSpecifier },
|
|
74
|
+
{ name: "getGlobalTransitionHandlers", from: handlerSpecifier },
|
|
75
|
+
{ name: "runTransitionHandler", from: handlerSpecifier },
|
|
76
|
+
{ name: "runGlobalTransitionHandlers", from: handlerSpecifier }
|
|
77
|
+
]);
|
|
78
|
+
const providerSpecifier = resolver.resolve("./runtime/server/provider");
|
|
79
|
+
addServerImports([
|
|
80
|
+
{ name: "setWorkflowProvider", from: providerSpecifier },
|
|
81
|
+
{ name: "useWorkflowProvider", from: providerSpecifier }
|
|
82
|
+
]);
|
|
83
|
+
addServerImports([
|
|
84
|
+
{
|
|
85
|
+
name: "createFileWorkflowProvider",
|
|
86
|
+
from: resolver.resolve("./runtime/server/providers/file")
|
|
87
|
+
}
|
|
88
|
+
]);
|
|
89
|
+
const transitionsDir = join(nuxt.options.srcDir, "transitions");
|
|
90
|
+
async function scanTransitions() {
|
|
91
|
+
try {
|
|
92
|
+
const entries = await readdir(transitionsDir, { recursive: true });
|
|
93
|
+
return entries.filter((f) => f.endsWith(".ts") && !f.endsWith(".d.ts"));
|
|
94
|
+
} catch {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function generateNuxtTransitionsCode(files) {
|
|
99
|
+
const buildDir = nuxt.options.buildDir;
|
|
100
|
+
const imports = files.map((file, i) => {
|
|
101
|
+
const absolute = join(transitionsDir, file);
|
|
102
|
+
let rel = relative(buildDir, absolute).replace(/\\/g, "/");
|
|
103
|
+
if (!rel.startsWith(".")) {
|
|
104
|
+
rel = `./${rel}`;
|
|
105
|
+
}
|
|
106
|
+
return `import transition${i} from '${rel}'`;
|
|
107
|
+
});
|
|
108
|
+
const refs = files.map((_, i) => `transition${i}`);
|
|
109
|
+
return [
|
|
110
|
+
...imports,
|
|
111
|
+
"",
|
|
112
|
+
`const transitions = [${refs.join(", ")}]`,
|
|
113
|
+
"",
|
|
114
|
+
"export function getTransition(name) {",
|
|
115
|
+
" return transitions.find(t => t.name === name)",
|
|
116
|
+
"}",
|
|
117
|
+
"",
|
|
118
|
+
"export function getAllTransitions() {",
|
|
119
|
+
" return transitions",
|
|
120
|
+
"}"
|
|
121
|
+
].join("\n");
|
|
122
|
+
}
|
|
123
|
+
function generateNitroTransitionsCode(files) {
|
|
124
|
+
const imports = files.map((file, i) => {
|
|
125
|
+
const importPath = join(transitionsDir, file);
|
|
126
|
+
return `import transition${i} from '${importPath}'`;
|
|
127
|
+
});
|
|
128
|
+
const refs = files.map((_, i) => `transition${i}`);
|
|
129
|
+
return [
|
|
130
|
+
...imports,
|
|
131
|
+
"",
|
|
132
|
+
`const transitions = [${refs.join(", ")}]`,
|
|
133
|
+
"",
|
|
134
|
+
"export function getTransition(name) {",
|
|
135
|
+
" return transitions.find(t => t.name === name)",
|
|
136
|
+
"}",
|
|
137
|
+
"",
|
|
138
|
+
"export function getAllTransitions() {",
|
|
139
|
+
" return transitions",
|
|
140
|
+
"}"
|
|
141
|
+
].join("\n");
|
|
142
|
+
}
|
|
143
|
+
const template = addTemplate({
|
|
144
|
+
filename: "transitions.mjs",
|
|
145
|
+
write: true,
|
|
146
|
+
getContents: async () => {
|
|
147
|
+
const files = await scanTransitions();
|
|
148
|
+
consola.info(`Loaded ${files.length} transitions`);
|
|
149
|
+
return generateNuxtTransitionsCode(files);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
nuxt.options.alias["#transitions"] = template.dst;
|
|
153
|
+
addImports([
|
|
154
|
+
{ name: "getTransition", from: template.dst },
|
|
155
|
+
{ name: "getAllTransitions", from: template.dst }
|
|
156
|
+
]);
|
|
157
|
+
nuxt.hook("nitro:config", async (nitroConfig) => {
|
|
158
|
+
const files = await scanTransitions();
|
|
159
|
+
nitroConfig.virtual = nitroConfig.virtual || {};
|
|
160
|
+
nitroConfig.virtual["#transitions"] = generateNitroTransitionsCode(files);
|
|
161
|
+
});
|
|
162
|
+
const workflowsDir = join(
|
|
163
|
+
nuxt.options.rootDir,
|
|
164
|
+
options.workflowsDir || "server/workflows"
|
|
165
|
+
);
|
|
166
|
+
if (options.provider === "file") {
|
|
167
|
+
nuxt.hook("nitro:config", (nitroConfig) => {
|
|
168
|
+
nitroConfig.virtual = nitroConfig.virtual || {};
|
|
169
|
+
nitroConfig.virtual["#ablauf-options"] = `export const workflowsDir = ${JSON.stringify(workflowsDir)}`;
|
|
170
|
+
nitroConfig.plugins = nitroConfig.plugins || [];
|
|
171
|
+
nitroConfig.plugins.push(
|
|
172
|
+
resolver.resolve("./runtime/server/plugins/workflow-provider")
|
|
173
|
+
);
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
if (options.exposeApi !== false) {
|
|
177
|
+
addServerHandler({
|
|
178
|
+
route: "/api/_workflow",
|
|
179
|
+
method: "get",
|
|
180
|
+
handler: resolver.resolve("./runtime/server/api/workflows/index.get")
|
|
181
|
+
});
|
|
182
|
+
addServerHandler({
|
|
183
|
+
route: "/api/_workflow/:name",
|
|
184
|
+
method: "get",
|
|
185
|
+
handler: resolver.resolve("./runtime/server/api/workflows/[name].get")
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
nuxt.hook("builder:watch", async (_event, relativePath) => {
|
|
189
|
+
if (relativePath.startsWith("transitions/") || relativePath.startsWith("transitions\\")) {
|
|
190
|
+
await updateTemplates({
|
|
191
|
+
filter: (t) => t.filename === "transitions.mjs"
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
const workflowsDirRel = options.workflowsDir || "server/workflows";
|
|
195
|
+
if (relativePath.startsWith(workflowsDirRel + "/") || relativePath.startsWith(workflowsDirRel + "\\")) {
|
|
196
|
+
consola.info("Workflow definition changed:", relativePath);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Direction, TransitionConditionFilter, TransitionRule, Workflow, WorkflowStateDefinition } from 'ablauf/runtime/workflow';
|
|
2
|
+
export interface UseWorkflowOptions<TConditions extends Record<string, unknown> = Record<string, unknown>> {
|
|
3
|
+
conditionFilter?: TransitionConditionFilter<TConditions>;
|
|
4
|
+
}
|
|
5
|
+
export declare function useWorkflow<TConditions extends Record<string, unknown> = Record<string, unknown>>(name: string, options?: UseWorkflowOptions<TConditions>): Promise<{
|
|
6
|
+
workflow: Workflow<TConditions>;
|
|
7
|
+
getStates: () => WorkflowStateDefinition[];
|
|
8
|
+
getState: (slug: string) => WorkflowStateDefinition | undefined;
|
|
9
|
+
getStateColor: (slug: string) => string;
|
|
10
|
+
getNextStates: (currentState: string, direction: Direction, context?: Record<string, unknown>) => string[];
|
|
11
|
+
findTransition: (from: string, to: string) => TransitionRule<TConditions> | undefined;
|
|
12
|
+
transition: (currentState: string, newState: string, args: Record<string, unknown>) => Promise<TransitionRule<TConditions> | false>;
|
|
13
|
+
}>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import {
|
|
2
|
+
runGlobalTransitionHandlers,
|
|
3
|
+
runTransitionHandler
|
|
4
|
+
} from "../handler.js";
|
|
5
|
+
export async function useWorkflow(name, options) {
|
|
6
|
+
const workflow = await $fetch(
|
|
7
|
+
`/api/_workflow/${name}`
|
|
8
|
+
);
|
|
9
|
+
if (!workflow) {
|
|
10
|
+
throw new Error(`Workflow "${name}" not found`);
|
|
11
|
+
}
|
|
12
|
+
function getStates() {
|
|
13
|
+
return workflow.states;
|
|
14
|
+
}
|
|
15
|
+
function getState(slug) {
|
|
16
|
+
return workflow.states.find((s) => s.slug === slug);
|
|
17
|
+
}
|
|
18
|
+
function getStateColor(slug) {
|
|
19
|
+
return getState(slug)?.color ?? "#9E9E9E";
|
|
20
|
+
}
|
|
21
|
+
function getNextStates(currentState, direction, context) {
|
|
22
|
+
const stateDef = getState(currentState);
|
|
23
|
+
if (stateDef?.category === "end") {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
return workflow.transitions.filter((rule) => {
|
|
27
|
+
if (rule.from === "all") {
|
|
28
|
+
return currentState !== rule.to && rule.direction === direction;
|
|
29
|
+
}
|
|
30
|
+
return rule.from === currentState && rule.direction === direction;
|
|
31
|
+
}).filter((rule) => {
|
|
32
|
+
if (!options?.conditionFilter || !context) return true;
|
|
33
|
+
return options.conditionFilter(rule, context);
|
|
34
|
+
}).map((rule) => rule.to);
|
|
35
|
+
}
|
|
36
|
+
function findTransition(from, to) {
|
|
37
|
+
return workflow.transitions.find(
|
|
38
|
+
(rule) => (rule.from === from || rule.from === "all") && rule.to === to
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
async function transition(currentState, newState, args) {
|
|
42
|
+
if (currentState === newState) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const rule = findTransition(currentState, newState);
|
|
46
|
+
if (!rule) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`No transition from "${currentState}" to "${newState}"`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const fullArgs = { ...args, transition: rule };
|
|
52
|
+
await runGlobalTransitionHandlers(fullArgs);
|
|
53
|
+
if (rule.handler) {
|
|
54
|
+
for (const h of rule.handler) {
|
|
55
|
+
const result = await runTransitionHandler({
|
|
56
|
+
name: h.name,
|
|
57
|
+
params: h.params,
|
|
58
|
+
args: fullArgs
|
|
59
|
+
});
|
|
60
|
+
if (typeof result === "boolean" && result === false) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
if (typeof result === "object" && result !== null) {
|
|
64
|
+
Object.assign(fullArgs, result);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return rule;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
workflow,
|
|
72
|
+
getStates,
|
|
73
|
+
getState,
|
|
74
|
+
getStateColor,
|
|
75
|
+
getNextStates,
|
|
76
|
+
findTransition,
|
|
77
|
+
transition
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { TransitionHandlerDefinition, TransitionHandlerParams, StateArgs } from 'ablauf/runtime/workflow';
|
|
2
|
+
export declare function getTransitionHandler(name: string): TransitionHandlerDefinition | undefined;
|
|
3
|
+
export declare function getGlobalTransitionHandlers(): TransitionHandlerDefinition[];
|
|
4
|
+
export declare function runTransitionHandler(options: {
|
|
5
|
+
name: string;
|
|
6
|
+
params: TransitionHandlerParams;
|
|
7
|
+
args: StateArgs;
|
|
8
|
+
}): Promise<boolean | Record<string, unknown> | undefined>;
|
|
9
|
+
export declare function runGlobalTransitionHandlers(args: StateArgs): Promise<void>;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getAllTransitions } from "#transitions";
|
|
2
|
+
export function getTransitionHandler(name) {
|
|
3
|
+
const transitions = getAllTransitions();
|
|
4
|
+
return transitions.find((t) => t.name === name);
|
|
5
|
+
}
|
|
6
|
+
export function getGlobalTransitionHandlers() {
|
|
7
|
+
const transitions = getAllTransitions();
|
|
8
|
+
return transitions.filter((t) => t.global);
|
|
9
|
+
}
|
|
10
|
+
export async function runTransitionHandler(options) {
|
|
11
|
+
const handler = getTransitionHandler(options.name);
|
|
12
|
+
if (!handler) {
|
|
13
|
+
throw new Error(`Transition handler "${options.name}" not found`);
|
|
14
|
+
}
|
|
15
|
+
return handler.run(options.params, options.args);
|
|
16
|
+
}
|
|
17
|
+
export async function runGlobalTransitionHandlers(args) {
|
|
18
|
+
const globalHandlers = getGlobalTransitionHandlers();
|
|
19
|
+
for (const handler of globalHandlers) {
|
|
20
|
+
await runTransitionHandler({ name: handler.name, params: {}, args });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineEventHandler, getRouterParam, createError } from "h3";
|
|
2
|
+
import { useWorkflowProvider } from "../../provider.js";
|
|
3
|
+
export default defineEventHandler(async (event) => {
|
|
4
|
+
const name = getRouterParam(event, "name");
|
|
5
|
+
if (!name) {
|
|
6
|
+
throw createError({ statusCode: 400, message: "Workflow name is required" });
|
|
7
|
+
}
|
|
8
|
+
const provider = useWorkflowProvider();
|
|
9
|
+
try {
|
|
10
|
+
return await provider.getWorkflow(name);
|
|
11
|
+
} catch {
|
|
12
|
+
throw createError({
|
|
13
|
+
statusCode: 404,
|
|
14
|
+
message: `Workflow "${name}" not found`
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { defineNitroPlugin } from "nitropack/runtime";
|
|
2
|
+
import { createFileWorkflowProvider } from "../providers/file.js";
|
|
3
|
+
import { setWorkflowProvider } from "../provider.js";
|
|
4
|
+
import { workflowsDir } from "#ablauf-options";
|
|
5
|
+
export default defineNitroPlugin(() => {
|
|
6
|
+
setWorkflowProvider(createFileWorkflowProvider(workflowsDir));
|
|
7
|
+
});
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
let _provider = null;
|
|
2
|
+
export function setWorkflowProvider(provider) {
|
|
3
|
+
_provider = provider;
|
|
4
|
+
}
|
|
5
|
+
export function useWorkflowProvider() {
|
|
6
|
+
if (!_provider) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
"No workflow provider configured. Call setWorkflowProvider() in a Nitro plugin or set provider option in module config."
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
return _provider;
|
|
12
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export function createFileWorkflowProvider(dir) {
|
|
4
|
+
return {
|
|
5
|
+
async getWorkflow(name) {
|
|
6
|
+
const filePath = join(dir, `${name}.json`);
|
|
7
|
+
try {
|
|
8
|
+
const content = await readFile(filePath, "utf-8");
|
|
9
|
+
return JSON.parse(content);
|
|
10
|
+
} catch {
|
|
11
|
+
throw new Error(`Workflow "${name}" not found at ${filePath}`);
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
async listWorkflows() {
|
|
15
|
+
try {
|
|
16
|
+
const entries = await readdir(dir);
|
|
17
|
+
const jsonFiles = entries.filter((f) => f.endsWith(".json"));
|
|
18
|
+
const workflows = [];
|
|
19
|
+
for (const file of jsonFiles) {
|
|
20
|
+
const content = await readFile(join(dir, file), "utf-8");
|
|
21
|
+
workflows.push(JSON.parse(content));
|
|
22
|
+
}
|
|
23
|
+
return workflows;
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface WorkflowStateDefinition {
|
|
2
|
+
slug: string;
|
|
3
|
+
name: string;
|
|
4
|
+
color: string;
|
|
5
|
+
category: 'start' | 'in-progress' | 'end';
|
|
6
|
+
}
|
|
7
|
+
export declare const Directions: {
|
|
8
|
+
readonly FORWARD: "forward";
|
|
9
|
+
readonly BACKWARD: "backward";
|
|
10
|
+
readonly OTHER: "other";
|
|
11
|
+
};
|
|
12
|
+
export type Direction = (typeof Directions)[keyof typeof Directions];
|
|
13
|
+
export type TransitionHandler = {
|
|
14
|
+
name: string;
|
|
15
|
+
params?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
export type TransitionHandlerParams = Record<string, unknown> | undefined;
|
|
18
|
+
export type StateArgs = Record<string, unknown> & {
|
|
19
|
+
transition: TransitionRule;
|
|
20
|
+
};
|
|
21
|
+
export interface TransitionHandlerDefinition {
|
|
22
|
+
name: string;
|
|
23
|
+
friendlyName: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
run: (params: TransitionHandlerParams, args: StateArgs) => Promise<boolean | Record<string, unknown> | undefined>;
|
|
26
|
+
global?: boolean;
|
|
27
|
+
}
|
|
28
|
+
export interface TransitionRule<TConditions extends Record<string, unknown> = Record<string, unknown>> {
|
|
29
|
+
from: string | 'all';
|
|
30
|
+
to: string;
|
|
31
|
+
direction: Direction;
|
|
32
|
+
handler?: TransitionHandler[];
|
|
33
|
+
conditions?: TConditions;
|
|
34
|
+
}
|
|
35
|
+
export interface Workflow<TConditions extends Record<string, unknown> = Record<string, unknown>> {
|
|
36
|
+
name: string;
|
|
37
|
+
description?: string;
|
|
38
|
+
states: WorkflowStateDefinition[];
|
|
39
|
+
transitions: TransitionRule<TConditions>[];
|
|
40
|
+
}
|
|
41
|
+
export interface WorkflowProvider {
|
|
42
|
+
getWorkflow: (name: string) => Promise<Workflow>;
|
|
43
|
+
listWorkflows: () => Promise<Workflow[]>;
|
|
44
|
+
saveWorkflow?: (workflow: Workflow) => Promise<Workflow>;
|
|
45
|
+
deleteWorkflow?: (name: string) => Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
export type TransitionConditionFilter<TConditions extends Record<string, unknown> = Record<string, unknown>> = (rule: TransitionRule<TConditions>, context: Record<string, unknown>) => boolean;
|
|
48
|
+
export declare class TransitionHandlerError extends Error {
|
|
49
|
+
options: Record<string, unknown>;
|
|
50
|
+
constructor(message: string, options?: Record<string, unknown>);
|
|
51
|
+
}
|
|
52
|
+
export declare function defineTransitionHandler(def: TransitionHandlerDefinition): TransitionHandlerDefinition;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export const Directions = {
|
|
2
|
+
FORWARD: "forward",
|
|
3
|
+
BACKWARD: "backward",
|
|
4
|
+
OTHER: "other"
|
|
5
|
+
};
|
|
6
|
+
export class TransitionHandlerError extends Error {
|
|
7
|
+
options;
|
|
8
|
+
constructor(message, options) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "TransitionHandlerError";
|
|
11
|
+
this.options = options ?? {};
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function defineTransitionHandler(def) {
|
|
15
|
+
return def;
|
|
16
|
+
}
|
package/dist/types.d.mts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "ablauf",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Workflow engine for Nuxt",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/niki2k1/ablauf.git"
|
|
8
|
+
},
|
|
9
|
+
"license": "MIT",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/types.d.mts",
|
|
14
|
+
"import": "./dist/module.mjs"
|
|
15
|
+
},
|
|
16
|
+
"./runtime/workflow": {
|
|
17
|
+
"types": "./src/runtime/workflow.ts",
|
|
18
|
+
"import": "./src/runtime/workflow.ts",
|
|
19
|
+
"default": "./src/runtime/workflow.ts"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"main": "./dist/module.mjs",
|
|
23
|
+
"typesVersions": {
|
|
24
|
+
"*": {
|
|
25
|
+
".": [
|
|
26
|
+
"./dist/types.d.mts"
|
|
27
|
+
]
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"src/runtime"
|
|
33
|
+
],
|
|
34
|
+
"workspaces": [
|
|
35
|
+
"playground"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"prepack": "nuxt-module-build build",
|
|
39
|
+
"typecheck": "vue-tsc --noEmit",
|
|
40
|
+
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
41
|
+
"dev:build": "nuxt build playground",
|
|
42
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
43
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
44
|
+
"lint": "eslint .",
|
|
45
|
+
"test": "vitest run",
|
|
46
|
+
"test:watch": "vitest watch",
|
|
47
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
48
|
+
},
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@nuxt/kit": "^4.3.0",
|
|
51
|
+
"consola": "^3.4.2"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@nuxt/devtools": "^3.1.1",
|
|
55
|
+
"@nuxt/eslint-config": "^1.13.0",
|
|
56
|
+
"@nuxt/module-builder": "^1.0.2",
|
|
57
|
+
"@nuxt/schema": "^4.3.0",
|
|
58
|
+
"@nuxt/test-utils": "^3.23.0",
|
|
59
|
+
"@types/node": "latest",
|
|
60
|
+
"changelogen": "^0.6.2",
|
|
61
|
+
"eslint": "^9.39.2",
|
|
62
|
+
"nuxt": "^4.3.0",
|
|
63
|
+
"typescript": "~5.9.3",
|
|
64
|
+
"vitest": "^4.0.18",
|
|
65
|
+
"vue-tsc": "^3.2.4"
|
|
66
|
+
},
|
|
67
|
+
"packageManager": "pnpm@9.15.5+sha512.845196026aab1cc3f098a0474b64dfbab2afe7a1b4e91dd86895d8e4aa32a7a6d03049e2d0ad770bbe4de023a7122fb68c1a1d6e0d033c7076085f9d5d4800d4"
|
|
68
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Direction,
|
|
3
|
+
StateArgs,
|
|
4
|
+
TransitionConditionFilter,
|
|
5
|
+
TransitionRule,
|
|
6
|
+
Workflow,
|
|
7
|
+
WorkflowStateDefinition,
|
|
8
|
+
} from 'ablauf/runtime/workflow'
|
|
9
|
+
import {
|
|
10
|
+
runGlobalTransitionHandlers,
|
|
11
|
+
runTransitionHandler,
|
|
12
|
+
} from '../handler'
|
|
13
|
+
|
|
14
|
+
export interface UseWorkflowOptions<
|
|
15
|
+
TConditions extends Record<string, unknown> = Record<string, unknown>,
|
|
16
|
+
> {
|
|
17
|
+
conditionFilter?: TransitionConditionFilter<TConditions>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function useWorkflow<
|
|
21
|
+
TConditions extends Record<string, unknown> = Record<string, unknown>,
|
|
22
|
+
>(name: string, options?: UseWorkflowOptions<TConditions>) {
|
|
23
|
+
const workflow = await $fetch<Workflow<TConditions>>(
|
|
24
|
+
`/api/_workflow/${name}`,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if (!workflow) {
|
|
28
|
+
throw new Error(`Workflow "${name}" not found`)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getStates(): WorkflowStateDefinition[] {
|
|
32
|
+
return workflow.states
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getState(slug: string): WorkflowStateDefinition | undefined {
|
|
36
|
+
return workflow.states.find((s) => s.slug === slug)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function getStateColor(slug: string): string {
|
|
40
|
+
return getState(slug)?.color ?? '#9E9E9E'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getNextStates(
|
|
44
|
+
currentState: string,
|
|
45
|
+
direction: Direction,
|
|
46
|
+
context?: Record<string, unknown>,
|
|
47
|
+
): string[] {
|
|
48
|
+
const stateDef = getState(currentState)
|
|
49
|
+
if (stateDef?.category === 'end') {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return workflow.transitions
|
|
54
|
+
.filter((rule) => {
|
|
55
|
+
if (rule.from === 'all') {
|
|
56
|
+
return currentState !== rule.to && rule.direction === direction
|
|
57
|
+
}
|
|
58
|
+
return rule.from === currentState && rule.direction === direction
|
|
59
|
+
})
|
|
60
|
+
.filter((rule) => {
|
|
61
|
+
if (!options?.conditionFilter || !context) return true
|
|
62
|
+
return options.conditionFilter(rule, context)
|
|
63
|
+
})
|
|
64
|
+
.map((rule) => rule.to)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findTransition(
|
|
68
|
+
from: string,
|
|
69
|
+
to: string,
|
|
70
|
+
): TransitionRule<TConditions> | undefined {
|
|
71
|
+
return workflow.transitions.find(
|
|
72
|
+
(rule) =>
|
|
73
|
+
(rule.from === from || rule.from === 'all') && rule.to === to,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function transition(
|
|
78
|
+
currentState: string,
|
|
79
|
+
newState: string,
|
|
80
|
+
args: Record<string, unknown>,
|
|
81
|
+
): Promise<TransitionRule<TConditions> | false> {
|
|
82
|
+
if (currentState === newState) {
|
|
83
|
+
return false
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const rule = findTransition(currentState, newState)
|
|
87
|
+
if (!rule) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`No transition from "${currentState}" to "${newState}"`,
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const fullArgs: StateArgs = { ...args, transition: rule }
|
|
94
|
+
|
|
95
|
+
// Run global transition handlers first
|
|
96
|
+
await runGlobalTransitionHandlers(fullArgs)
|
|
97
|
+
|
|
98
|
+
// Run per-transition handlers
|
|
99
|
+
if (rule.handler) {
|
|
100
|
+
for (const h of rule.handler) {
|
|
101
|
+
const result = await runTransitionHandler({
|
|
102
|
+
name: h.name,
|
|
103
|
+
params: h.params,
|
|
104
|
+
args: fullArgs,
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
if (typeof result === 'boolean' && result === false) {
|
|
108
|
+
return false
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof result === 'object' && result !== null) {
|
|
112
|
+
Object.assign(fullArgs, result)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return rule
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
workflow,
|
|
122
|
+
getStates,
|
|
123
|
+
getState,
|
|
124
|
+
getStateColor,
|
|
125
|
+
getNextStates,
|
|
126
|
+
findTransition,
|
|
127
|
+
transition,
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
TransitionHandlerDefinition,
|
|
3
|
+
TransitionHandlerParams,
|
|
4
|
+
StateArgs,
|
|
5
|
+
} from 'ablauf/runtime/workflow'
|
|
6
|
+
// @ts-expect-error virtual module generated by ablauf
|
|
7
|
+
import { getAllTransitions } from '#transitions'
|
|
8
|
+
|
|
9
|
+
export function getTransitionHandler(
|
|
10
|
+
name: string,
|
|
11
|
+
): TransitionHandlerDefinition | undefined {
|
|
12
|
+
const transitions = getAllTransitions() as TransitionHandlerDefinition[]
|
|
13
|
+
return transitions.find((t) => t.name === name)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function getGlobalTransitionHandlers(): TransitionHandlerDefinition[] {
|
|
17
|
+
const transitions = getAllTransitions() as TransitionHandlerDefinition[]
|
|
18
|
+
return transitions.filter((t) => t.global)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runTransitionHandler(options: {
|
|
22
|
+
name: string
|
|
23
|
+
params: TransitionHandlerParams
|
|
24
|
+
args: StateArgs
|
|
25
|
+
}): Promise<boolean | Record<string, unknown> | undefined> {
|
|
26
|
+
const handler = getTransitionHandler(options.name)
|
|
27
|
+
if (!handler) {
|
|
28
|
+
throw new Error(`Transition handler "${options.name}" not found`)
|
|
29
|
+
}
|
|
30
|
+
return handler.run(options.params, options.args)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function runGlobalTransitionHandlers(
|
|
34
|
+
args: StateArgs,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
const globalHandlers = getGlobalTransitionHandlers()
|
|
37
|
+
for (const handler of globalHandlers) {
|
|
38
|
+
await runTransitionHandler({ name: handler.name, params: {}, args })
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { defineEventHandler, getRouterParam, createError } from 'h3'
|
|
2
|
+
import { useWorkflowProvider } from '../../provider'
|
|
3
|
+
|
|
4
|
+
export default defineEventHandler(async (event) => {
|
|
5
|
+
const name = getRouterParam(event, 'name')
|
|
6
|
+
if (!name) {
|
|
7
|
+
throw createError({ statusCode: 400, message: 'Workflow name is required' })
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const provider = useWorkflowProvider()
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
return await provider.getWorkflow(name)
|
|
14
|
+
} catch {
|
|
15
|
+
throw createError({
|
|
16
|
+
statusCode: 404,
|
|
17
|
+
message: `Workflow "${name}" not found`,
|
|
18
|
+
})
|
|
19
|
+
}
|
|
20
|
+
})
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { defineNitroPlugin } from 'nitropack/runtime'
|
|
2
|
+
import { createFileWorkflowProvider } from '../providers/file.js'
|
|
3
|
+
import { setWorkflowProvider } from '../provider.js'
|
|
4
|
+
|
|
5
|
+
// @ts-expect-error virtual module injected by ablauf
|
|
6
|
+
import { workflowsDir } from '#ablauf-options'
|
|
7
|
+
|
|
8
|
+
export default defineNitroPlugin(() => {
|
|
9
|
+
setWorkflowProvider(createFileWorkflowProvider(workflowsDir))
|
|
10
|
+
})
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { WorkflowProvider } from 'ablauf/runtime/workflow'
|
|
2
|
+
|
|
3
|
+
let _provider: WorkflowProvider | null = null
|
|
4
|
+
|
|
5
|
+
export function setWorkflowProvider(provider: WorkflowProvider) {
|
|
6
|
+
_provider = provider
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function useWorkflowProvider(): WorkflowProvider {
|
|
10
|
+
if (!_provider) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
'No workflow provider configured. Call setWorkflowProvider() in a Nitro plugin or set provider option in module config.',
|
|
13
|
+
)
|
|
14
|
+
}
|
|
15
|
+
return _provider
|
|
16
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { Workflow, WorkflowProvider } from 'ablauf/runtime/workflow'
|
|
4
|
+
|
|
5
|
+
export function createFileWorkflowProvider(dir: string): WorkflowProvider {
|
|
6
|
+
return {
|
|
7
|
+
async getWorkflow(name: string): Promise<Workflow> {
|
|
8
|
+
const filePath = join(dir, `${name}.json`)
|
|
9
|
+
try {
|
|
10
|
+
const content = await readFile(filePath, 'utf-8')
|
|
11
|
+
return JSON.parse(content) as Workflow
|
|
12
|
+
} catch {
|
|
13
|
+
throw new Error(`Workflow "${name}" not found at ${filePath}`)
|
|
14
|
+
}
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
async listWorkflows(): Promise<Workflow[]> {
|
|
18
|
+
try {
|
|
19
|
+
const entries = await readdir(dir)
|
|
20
|
+
const jsonFiles = entries.filter((f) => f.endsWith('.json'))
|
|
21
|
+
|
|
22
|
+
const workflows: Workflow[] = []
|
|
23
|
+
for (const file of jsonFiles) {
|
|
24
|
+
const content = await readFile(join(dir, file), 'utf-8')
|
|
25
|
+
workflows.push(JSON.parse(content) as Workflow)
|
|
26
|
+
}
|
|
27
|
+
return workflows
|
|
28
|
+
} catch {
|
|
29
|
+
return []
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// --- States ---
|
|
2
|
+
|
|
3
|
+
export interface WorkflowStateDefinition {
|
|
4
|
+
slug: string
|
|
5
|
+
name: string
|
|
6
|
+
color: string
|
|
7
|
+
category: 'start' | 'in-progress' | 'end'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// --- Directions ---
|
|
11
|
+
|
|
12
|
+
export const Directions = {
|
|
13
|
+
FORWARD: 'forward',
|
|
14
|
+
BACKWARD: 'backward',
|
|
15
|
+
OTHER: 'other',
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export type Direction = (typeof Directions)[keyof typeof Directions]
|
|
19
|
+
|
|
20
|
+
// --- Transition Handlers ---
|
|
21
|
+
|
|
22
|
+
export type TransitionHandler = {
|
|
23
|
+
name: string
|
|
24
|
+
params?: Record<string, unknown>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TransitionHandlerParams = Record<string, unknown> | undefined
|
|
28
|
+
|
|
29
|
+
export type StateArgs = Record<string, unknown> & {
|
|
30
|
+
transition: TransitionRule
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface TransitionHandlerDefinition {
|
|
34
|
+
name: string
|
|
35
|
+
friendlyName: string
|
|
36
|
+
description?: string
|
|
37
|
+
run: (
|
|
38
|
+
params: TransitionHandlerParams,
|
|
39
|
+
args: StateArgs,
|
|
40
|
+
) => Promise<boolean | Record<string, unknown> | undefined>
|
|
41
|
+
global?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// --- Transition Rules ---
|
|
45
|
+
|
|
46
|
+
export interface TransitionRule<
|
|
47
|
+
TConditions extends Record<string, unknown> = Record<string, unknown>,
|
|
48
|
+
> {
|
|
49
|
+
from: string | 'all'
|
|
50
|
+
to: string
|
|
51
|
+
direction: Direction
|
|
52
|
+
handler?: TransitionHandler[]
|
|
53
|
+
conditions?: TConditions
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Workflow ---
|
|
57
|
+
|
|
58
|
+
export interface Workflow<
|
|
59
|
+
TConditions extends Record<string, unknown> = Record<string, unknown>,
|
|
60
|
+
> {
|
|
61
|
+
name: string
|
|
62
|
+
description?: string
|
|
63
|
+
states: WorkflowStateDefinition[]
|
|
64
|
+
transitions: TransitionRule<TConditions>[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --- Workflow Provider ---
|
|
68
|
+
|
|
69
|
+
export interface WorkflowProvider {
|
|
70
|
+
getWorkflow: (name: string) => Promise<Workflow>
|
|
71
|
+
listWorkflows: () => Promise<Workflow[]>
|
|
72
|
+
saveWorkflow?: (workflow: Workflow) => Promise<Workflow>
|
|
73
|
+
deleteWorkflow?: (name: string) => Promise<void>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// --- Condition Filter ---
|
|
77
|
+
|
|
78
|
+
export type TransitionConditionFilter<
|
|
79
|
+
TConditions extends Record<string, unknown> = Record<string, unknown>,
|
|
80
|
+
> = (
|
|
81
|
+
rule: TransitionRule<TConditions>,
|
|
82
|
+
context: Record<string, unknown>,
|
|
83
|
+
) => boolean
|
|
84
|
+
|
|
85
|
+
// --- Errors ---
|
|
86
|
+
|
|
87
|
+
export class TransitionHandlerError extends Error {
|
|
88
|
+
options: Record<string, unknown>
|
|
89
|
+
|
|
90
|
+
constructor(message: string, options?: Record<string, unknown>) {
|
|
91
|
+
super(message)
|
|
92
|
+
this.name = 'TransitionHandlerError'
|
|
93
|
+
this.options = options ?? {}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// --- Utilities ---
|
|
98
|
+
|
|
99
|
+
export function defineTransitionHandler(def: TransitionHandlerDefinition) {
|
|
100
|
+
return def
|
|
101
|
+
}
|