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.
Files changed (32) hide show
  1. package/README.md +251 -0
  2. package/dist/module.d.mts +14 -0
  3. package/dist/module.json +9 -0
  4. package/dist/module.mjs +202 -0
  5. package/dist/runtime/composables/useWorkflow.d.ts +13 -0
  6. package/dist/runtime/composables/useWorkflow.js +79 -0
  7. package/dist/runtime/handler.d.ts +9 -0
  8. package/dist/runtime/handler.js +22 -0
  9. package/dist/runtime/server/api/workflows/[name].get.d.ts +2 -0
  10. package/dist/runtime/server/api/workflows/[name].get.js +17 -0
  11. package/dist/runtime/server/api/workflows/index.get.d.ts +2 -0
  12. package/dist/runtime/server/api/workflows/index.get.js +6 -0
  13. package/dist/runtime/server/plugins/workflow-provider.d.ts +2 -0
  14. package/dist/runtime/server/plugins/workflow-provider.js +7 -0
  15. package/dist/runtime/server/provider.d.ts +3 -0
  16. package/dist/runtime/server/provider.js +12 -0
  17. package/dist/runtime/server/providers/file.d.ts +2 -0
  18. package/dist/runtime/server/providers/file.js +29 -0
  19. package/dist/runtime/server/tsconfig.json +3 -0
  20. package/dist/runtime/workflow.d.ts +52 -0
  21. package/dist/runtime/workflow.js +16 -0
  22. package/dist/types.d.mts +3 -0
  23. package/package.json +68 -0
  24. package/src/runtime/composables/useWorkflow.ts +129 -0
  25. package/src/runtime/handler.ts +40 -0
  26. package/src/runtime/server/api/workflows/[name].get.ts +20 -0
  27. package/src/runtime/server/api/workflows/index.get.ts +7 -0
  28. package/src/runtime/server/plugins/workflow-provider.ts +10 -0
  29. package/src/runtime/server/provider.ts +16 -0
  30. package/src/runtime/server/providers/file.ts +33 -0
  31. package/src/runtime/server/tsconfig.json +3 -0
  32. 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
+ <!-- - [📖 &nbsp;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 };
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "ablauf",
3
+ "configKey": "workflow",
4
+ "version": "0.0.1",
5
+ "builder": {
6
+ "@nuxt/module-builder": "1.0.2",
7
+ "unbuild": "unknown"
8
+ }
9
+ }
@@ -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,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../../workflow.js").Workflow<Record<string, unknown>>>>;
2
+ export default _default;
@@ -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,2 @@
1
+ declare const _default: import("h3").EventHandler<import("h3").EventHandlerRequest, Promise<import("../../../workflow.js").Workflow<Record<string, unknown>>[]>>;
2
+ export default _default;
@@ -0,0 +1,6 @@
1
+ import { defineEventHandler } from "h3";
2
+ import { useWorkflowProvider } from "../../provider.js";
3
+ export default defineEventHandler(async () => {
4
+ const provider = useWorkflowProvider();
5
+ return provider.listWorkflows();
6
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("nitropack/types").NitroAppPlugin;
2
+ export default _default;
@@ -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,3 @@
1
+ import type { WorkflowProvider } from 'ablauf/runtime/workflow';
2
+ export declare function setWorkflowProvider(provider: WorkflowProvider): void;
3
+ export declare function useWorkflowProvider(): WorkflowProvider;
@@ -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,2 @@
1
+ import type { WorkflowProvider } from 'ablauf/runtime/workflow';
2
+ export declare function createFileWorkflowProvider(dir: string): WorkflowProvider;
@@ -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,3 @@
1
+ {
2
+ "extends": "../../../.nuxt/tsconfig.server.json",
3
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ export { default } from './module.mjs'
2
+
3
+ export { type ModuleOptions } from './module.mjs'
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,7 @@
1
+ import { defineEventHandler } from 'h3'
2
+ import { useWorkflowProvider } from '../../provider'
3
+
4
+ export default defineEventHandler(async () => {
5
+ const provider = useWorkflowProvider()
6
+ return provider.listWorkflows()
7
+ })
@@ -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,3 @@
1
+ {
2
+ "extends": "../../../.nuxt/tsconfig.server.json",
3
+ }
@@ -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
+ }