├── .husky ├── pre-commit └── commit-msg ├── .prettierignore ├── .gitignore ├── .commitlintrc.json ├── .vscode ├── settings.json └── extensions.json ├── doc └── img │ ├── flows.png │ └── flows-example.png ├── .prettierrc.json ├── .lintstagedrc ├── src ├── work │ ├── work-status.ts │ ├── work.ts │ ├── work-report.ts │ ├── predicate.ts │ ├── success-work-report.ts │ ├── broken-work-report.ts │ ├── failure-work-report.ts │ ├── work-report-predicate.ts │ ├── no-op-work.ts │ ├── default-work-report.ts │ ├── parallel-work-report.ts │ └── work-context.ts ├── workflow │ ├── work-flow.ts │ ├── abstract-work-flow.ts │ ├── parallel-flow.ts │ ├── sequential-flow.ts │ ├── repeat-flow.ts │ └── conditional-flow.ts ├── utils │ └── lib-util.ts ├── engine │ ├── work-flow-engine.ts │ ├── work-flow-engine-builder.ts │ └── work-flow-engine-impl.ts └── index.ts ├── .github ├── release.yml ├── dependabot.yml ├── ISSUE_TEMPLATE │ ├── 2-feature-request.md │ ├── 1-bug-report.md │ └── 3-question.md ├── workflows │ └── ci.yml └── pull_request_template.md ├── tsconfig.json ├── .versionrc.json ├── test ├── work │ ├── no-op-work.test.ts │ └── work-context.test.ts ├── workflow │ ├── repeat-flow.test.ts │ ├── parallel-flow.test.ts │ ├── conditional-flow.test.ts │ └── sequential-flow.test.ts ├── engine │ └── work-flow-engine-impl.test.ts └── mock.ts ├── LICENSE ├── CHANGELOG.md ├── package.json ├── eslint.config.mjs └── README.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | pnpm exec lint-staged -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | pnpm dlx commitlint --edit $1 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | *-lock.json -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | *.log -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "prettier.configPath": ".prettierrc.json" 3 | } 4 | -------------------------------------------------------------------------------- /doc/img/flows.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstanziale/ez-flow/HEAD/doc/img/flows.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "arrowParens": "avoid" 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "vitest.explorer"] 3 | } 4 | -------------------------------------------------------------------------------- /doc/img/flows-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstanziale/ez-flow/HEAD/doc/img/flows-example.png -------------------------------------------------------------------------------- /.lintstagedrc: -------------------------------------------------------------------------------- 1 | { 2 | "*.(js|ts)": ["prettier --write", "eslint"], 3 | "*.(json)": ["prettier --write"] 4 | } 5 | -------------------------------------------------------------------------------- /src/work/work-status.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines a status for a work unit 3 | * 4 | * @author R.Stanziale 5 | * @version 1.0 6 | */ 7 | export enum WorkStatus { 8 | FAILED = -1, 9 | BROKEN = 0, 10 | COMPLETED = 1, 11 | } 12 | -------------------------------------------------------------------------------- /src/workflow/work-flow.ts: -------------------------------------------------------------------------------- 1 | import { Work } from '../work/work'; 2 | 3 | /** 4 | * Defines a workflow entity interface 5 | * 6 | * @author R.Stanziale 7 | * @version 1.0 8 | */ 9 | export interface WorkFlow extends Work {} 10 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - dependencies 5 | categories: 6 | - title: New Features 7 | labels: 8 | - feat 9 | - title: Bug Fixes 10 | labels: 11 | - fix 12 | -------------------------------------------------------------------------------- /src/utils/lib-util.ts: -------------------------------------------------------------------------------- 1 | import * as uuid from 'uuid'; 2 | 3 | /** 4 | * General utilities for this library 5 | */ 6 | export class LibUtil { 7 | /** 8 | * Get an unique identifier as RFC4122 v4 UUID 9 | * @returns string 10 | */ 11 | static getUUID(): string { 12 | return uuid.v4(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "ES6", 5 | "module": "CommonJS", 6 | "noImplicitAny": true, 7 | "declaration": true, 8 | "strict": true, 9 | "removeComments": true 10 | }, 11 | "include": ["src"], 12 | "exclude": ["node_modules"] 13 | } 14 | -------------------------------------------------------------------------------- /src/work/work.ts: -------------------------------------------------------------------------------- 1 | import { WorkContext } from './work-context'; 2 | import { WorkReport } from './work-report'; 3 | 4 | /** 5 | * Defines a work unit 6 | * 7 | * @author R.Stanziale 8 | * @version 1.0 9 | */ 10 | export interface Work { 11 | getName(): string; 12 | call(workContext: WorkContext): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/work/work-report.ts: -------------------------------------------------------------------------------- 1 | import { WorkContext } from './work-context'; 2 | import { WorkStatus } from './work-status'; 3 | 4 | /** 5 | * Defines a work repot unit 6 | * 7 | * @author R.Stanziale 8 | * @version 1.0 9 | */ 10 | export interface WorkReport { 11 | getWorkStatus(): WorkStatus; 12 | getWorkContext(): WorkContext; 13 | getError(): undefined | Error | Error[]; 14 | } 15 | -------------------------------------------------------------------------------- /src/engine/work-flow-engine.ts: -------------------------------------------------------------------------------- 1 | import { WorkContext } from '../work/work-context'; 2 | import { WorkReport } from '../work/work-report'; 3 | import { WorkFlow } from '../workflow/work-flow'; 4 | 5 | /** 6 | * Defines an interface for a workflow engine 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export interface WorkFlowEngine { 12 | run(workFlow: WorkFlow, workContext: WorkContext): Promise; 13 | } 14 | -------------------------------------------------------------------------------- /src/work/predicate.ts: -------------------------------------------------------------------------------- 1 | import { WorkReport } from './work-report'; 2 | 3 | /** 4 | * Defines a general predicate 5 | * 6 | * @author R.Stanziale 7 | * @version 1.0 8 | */ 9 | export interface Predicate { 10 | /** 11 | * Apply a predicate.
12 | * Return true or false 13 | * @param workReport work report 14 | * @return Promise true|false 15 | */ 16 | apply(workReport: WorkReport): Promise; 17 | } 18 | -------------------------------------------------------------------------------- /src/work/success-work-report.ts: -------------------------------------------------------------------------------- 1 | import { DefaultWorkReport } from './default-work-report'; 2 | import { WorkContext } from './work-context'; 3 | import { WorkStatus } from './work-status'; 4 | 5 | /** 6 | * Work report for successful operations 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export class SuccessWorkReport extends DefaultWorkReport { 12 | /** 13 | * Constructor 14 | * @param workContext work context 15 | */ 16 | constructor(workContext: WorkContext) { 17 | super(WorkStatus.COMPLETED, workContext); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "monthly" 12 | -------------------------------------------------------------------------------- /.versionrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "types": [ 3 | { "type": "feat", "section": "New Features" }, 4 | { "type": "fix", "section": "Bug Fixes" }, 5 | { "type": "chore", "hidden": true }, 6 | { "type": "docs", "hidden": true }, 7 | { "type": "style", "hidden": true }, 8 | { "type": "refactor", "hidden": true }, 9 | { "type": "perf", "hidden": true }, 10 | { "type": "test", "hidden": true } 11 | ], 12 | "commitUrlFormat": "https://github.com/rstanziale/ez-flow/commits/{{hash}}", 13 | "compareUrlFormat": "https://github.com/rstanziale/ez-flow/compare/{{previousTag}}...{{currentTag}}" 14 | } 15 | -------------------------------------------------------------------------------- /src/work/broken-work-report.ts: -------------------------------------------------------------------------------- 1 | import { DefaultWorkReport } from './default-work-report'; 2 | import { WorkContext } from './work-context'; 3 | import { WorkStatus } from './work-status'; 4 | 5 | /** 6 | * Work report for broken operations 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export class BrokenWorkReport extends DefaultWorkReport { 12 | /** 13 | * Constructor 14 | * @param workContext work context 15 | * @param err error object 16 | */ 17 | constructor(workContext: WorkContext, err: Error) { 18 | super(WorkStatus.BROKEN, workContext, err); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/work/failure-work-report.ts: -------------------------------------------------------------------------------- 1 | import { DefaultWorkReport } from './default-work-report'; 2 | import { WorkContext } from './work-context'; 3 | import { WorkStatus } from './work-status'; 4 | 5 | /** 6 | * Work report for failed operations 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export class FailureWorkReport extends DefaultWorkReport { 12 | /** 13 | * Constructor 14 | * @param workContext work context 15 | * @param err error object 16 | */ 17 | constructor(workContext: WorkContext, err: Error) { 18 | super(WorkStatus.FAILED, workContext, err); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/engine/work-flow-engine-builder.ts: -------------------------------------------------------------------------------- 1 | import { WorkFlowEngine } from './work-flow-engine'; 2 | import { WorkFlowEngineImpl } from './work-flow-engine-impl'; 3 | 4 | /** 5 | * Defines a workflow engine builder 6 | * 7 | * @author R.Stanziale 8 | * @version 1.0 9 | */ 10 | export class WorkFlowEngineBuilder { 11 | /** 12 | * Get a builder instance 13 | */ 14 | public static newBuilder(): WorkFlowEngineBuilder { 15 | return new WorkFlowEngineBuilder(); 16 | } 17 | 18 | /** 19 | * Build a workflow engine 20 | */ 21 | public build(): WorkFlowEngine { 22 | return new WorkFlowEngineImpl(); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/work/work-report-predicate.ts: -------------------------------------------------------------------------------- 1 | import { Predicate } from './predicate'; 2 | import { WorkReport } from './work-report'; 3 | import { WorkStatus } from './work-status'; 4 | 5 | /** 6 | * Defines a predicate for work report 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export class WorkReportPredicate implements Predicate { 12 | /** 13 | * Apply a predicate to work report.
14 | * Return true if COMPLETED, false if FAILED 15 | * @param workReport work report 16 | * @return Promise true|false 17 | */ 18 | apply(workReport: WorkReport): Promise { 19 | return Promise.resolve(workReport.getWorkStatus() === WorkStatus.COMPLETED); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/2-feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "✨ Feature request" 3 | about: Suggest an idea for this project 4 | title: "[✨]" 5 | labels: feat 6 | assignees: rstanziale 7 | 8 | --- 9 | 10 | ### Is your feature request related to a problem? Please describe. 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | ### Describe the solution you'd like 14 | A clear and concise description of what you want to happen. 15 | 16 | ### Describe alternatives you've considered 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | ### Additional context 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/1-bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐞 Bug report" 3 | about: Create a report to help us improve 4 | title: "[🐞]" 5 | labels: bug 6 | assignees: rstanziale 7 | 8 | --- 9 | 10 | ### Describe the bug 11 | A clear and concise description of what the bug is. 12 | 13 | ### To Reproduce 14 | Steps to reproduce the behavior: 15 | 1. Step 1 16 | 2. Step 2 17 | 3. ... 18 | 19 | ### Expected behavior 20 | A clear and concise description of what you expected to happen. 21 | 22 | ### Environment (please complete the following information): 23 | - OS: [e.g., Windows 10, macOS 13.1, Ubuntu 22.04] 24 | - Node.js version: [e.g., 18.16.1] 25 | - Package manager: [e.g., npm 9.7.2, yarn 3.x] 26 | 27 | ### Additional context 28 | Add any other context about the problem here. 29 | -------------------------------------------------------------------------------- /test/work/no-op-work.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, test, expect } from 'vitest'; 2 | import { NoOpWork } from '../../src/work/no-op-work'; 3 | import { WorkContext } from '../../src/work/work-context'; 4 | import { WorkReport } from '../../src/work/work-report'; 5 | import { WorkStatus } from '../../src/work/work-status'; 6 | 7 | let work: NoOpWork; 8 | 9 | beforeEach(() => { 10 | work = new NoOpWork(); 11 | }); 12 | 13 | describe('No operation work', () => { 14 | test('has name not null', () => { 15 | expect(work.getName()).not.toBeNull(); 16 | }); 17 | 18 | test('its status is COMPLETED', async () => { 19 | const workReport: WorkReport = await work.call(new WorkContext()); 20 | expect(workReport.getWorkStatus()).toEqual(WorkStatus.COMPLETED); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/engine/work-flow-engine-impl.ts: -------------------------------------------------------------------------------- 1 | import { WorkContext } from '../work/work-context'; 2 | import { WorkReport } from '../work/work-report'; 3 | import { WorkFlow } from '../workflow/work-flow'; 4 | import { WorkFlowEngine } from './work-flow-engine'; 5 | 6 | /** 7 | * Implements a workflow engine 8 | * 9 | * @author R.Stanziale 10 | * @version 1.0 11 | */ 12 | export class WorkFlowEngineImpl implements WorkFlowEngine { 13 | /** 14 | * Constructor 15 | */ 16 | constructor() {} 17 | 18 | /** 19 | * Runs a workflow 20 | * @param workFlow workflow 21 | * @param workContext work context 22 | * @returns work report promise 23 | */ 24 | async run(workFlow: WorkFlow, workContext: WorkContext): Promise { 25 | return workFlow.call(workContext); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/work/work-context.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, test, expect } from 'vitest'; 2 | import { WorkContext } from '../../src/work/work-context'; 3 | 4 | let work: WorkContext; 5 | 6 | describe('Work context', () => { 7 | test('actions', () => { 8 | const KEY = 'key'; 9 | const VALUE = 'value'; 10 | work = new WorkContext(); 11 | 12 | work.set(KEY, VALUE); 13 | expect(work.asMap().size).toBeGreaterThan(0); 14 | expect(work.has(KEY)).toBeTruthy(); 15 | 16 | work.delete(KEY); 17 | expect(work.asMap().size).toBe(0); 18 | 19 | work.set(KEY, VALUE); 20 | expect(work.get(KEY)).not.toBeNull(); 21 | 22 | work.clear(); 23 | expect(work.asMap().size).toBe(0); 24 | expect(work.isResultSingle()).toBeUndefined(); 25 | 26 | work = new WorkContext(new Map()); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI workflow 2 | on: 3 | push: 4 | branches: [ "main" ] 5 | pull_request: 6 | branches: [ "main" ] 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | node-version: [18.x, 20.x, 22.x] 14 | os: [ubuntu-latest, windows-latest, macOS-latest] 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Install pnpm 18 | uses: pnpm/action-setup@v4 19 | with: 20 | version: 9 21 | - name: Use Node.js ${{ matrix.node-version }} 22 | uses: actions/setup-node@v4 23 | with: 24 | node-version: ${{ matrix.node-version }} 25 | cache: 'pnpm' 26 | - name: Install dependencies 27 | run: pnpm install 28 | - name: Build 29 | run: pnpm build 30 | - name: Run tests 31 | run: pnpm test -------------------------------------------------------------------------------- /src/workflow/abstract-work-flow.ts: -------------------------------------------------------------------------------- 1 | import { WorkContext } from '../work/work-context'; 2 | import { WorkReport } from '../work/work-report'; 3 | import { WorkFlow } from './work-flow'; 4 | 5 | /** 6 | * Defines an abstract workflow 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export abstract class AbstractWorkFlow implements WorkFlow { 12 | /** 13 | * Constructor 14 | * @param name workflow name 15 | */ 16 | constructor(private name: string) {} 17 | 18 | /** 19 | * Execute an action on the given context 20 | * @param workContext work context 21 | * @returns work report promise 22 | */ 23 | abstract call(workContext: WorkContext): Promise; 24 | 25 | /** 26 | * Get work unit unique name 27 | * @returns work unit name 28 | */ 29 | getName(): string { 30 | return this.name; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/3-question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "💭 Question" 3 | about: Question about the project 4 | title: "[💭]" 5 | labels: question 6 | assignees: rstanziale 7 | 8 | --- 9 | 10 | Thank you for taking the time to ask a question about this project! Please provide as much detail as possible to help us understand and address your query. 11 | 12 | ### Your Question 13 | Write your question here. Be as specific as possible to help us understand your issue. 14 | 15 | ### Additional Context (Optional) 16 | Add any other details, screenshots, or references that might help clarify your question. 17 | 18 | ### Steps Taken (Optional) 19 | If applicable, let us know what you've tried so far to solve your question or where you encountered difficulties. 20 | 21 | ### Note 22 | Before submitting, ensure your question hasn't already been answered in the [existing issues](https://github.com/rstanziale/ez-flow/issues). 23 | -------------------------------------------------------------------------------- /src/work/no-op-work.ts: -------------------------------------------------------------------------------- 1 | import { LibUtil } from '../utils/lib-util'; 2 | import { DefaultWorkReport } from './default-work-report'; 3 | import { Work } from './work'; 4 | import { WorkContext } from './work-context'; 5 | import { WorkReport } from './work-report'; 6 | import { WorkStatus } from './work-status'; 7 | 8 | /** 9 | * Defines a work unit that does nothing 10 | * 11 | * @author R.Stanzialee 12 | * @version 1.0 13 | */ 14 | export class NoOpWork implements Work { 15 | /** 16 | * Get work unit unique name 17 | * @returns work unit name 18 | */ 19 | getName(): string { 20 | return LibUtil.getUUID(); 21 | } 22 | 23 | /** 24 | * Execute an action on the given context 25 | * @param workContext work context 26 | * @returns work report 27 | */ 28 | async call(workContext: WorkContext): Promise { 29 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Work items 2 | export * from './work/broken-work-report'; 3 | export * from './work/default-work-report'; 4 | export * from './work/failure-work-report'; 5 | export * from './work/no-op-work'; 6 | export * from './work/parallel-work-report'; 7 | export * from './work/predicate'; 8 | export * from './work/success-work-report'; 9 | export * from './work/work'; 10 | export * from './work/work-context'; 11 | export * from './work/work-report'; 12 | export * from './work/work-report-predicate'; 13 | export * from './work/work-status'; 14 | 15 | // Workflow items 16 | export * from './workflow/abstract-work-flow'; 17 | export * from './workflow/conditional-flow'; 18 | export * from './workflow/parallel-flow'; 19 | export * from './workflow/repeat-flow'; 20 | export * from './workflow/sequential-flow'; 21 | export * from './workflow/work-flow'; 22 | 23 | // Engine items 24 | export * from './engine/work-flow-engine'; 25 | export * from './engine/work-flow-engine-builder'; 26 | export * from './engine/work-flow-engine-impl'; 27 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Pull Request 2 | 3 | ## Type of Change 4 | 5 | - [ ] feat (new feature) 6 | - [ ] fix (bug fix) 7 | - [ ] docs (documentation update) 8 | - [ ] refactor (code refactoring without affecting functionality) 9 | - [ ] test (adding or updating tests) 10 | - [ ] chore (maintenance tasks like dependency updates) 11 | 12 | ## Description 13 | 14 | 15 | ## Tests 16 | 17 | - [ ] Tests added/updated 18 | - [ ] Tests pass 19 | - [ ] N/A (Not applicable) 20 | 21 | ## Checklist 22 | 23 | - [ ] My code follows the repository's style guidelines 24 | - [ ] I have run the code and verified it works as expected 25 | - [ ] I have added/updated tests for any changes to existing behavior 26 | - [ ] Documentation has been updated (if needed) 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Roberto B. Stanziale 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/work/default-work-report.ts: -------------------------------------------------------------------------------- 1 | import { WorkContext } from './work-context'; 2 | import { WorkReport } from './work-report'; 3 | import { WorkStatus } from './work-status'; 4 | 5 | /** 6 | * Defines a default work report implementing the needed interface 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export class DefaultWorkReport implements WorkReport { 12 | /** 13 | * Constructor 14 | * @param workStatus work status 15 | * @param workContext work context 16 | * @param error error (if any) 17 | */ 18 | constructor( 19 | protected workStatus: WorkStatus, 20 | protected workContext: WorkContext, 21 | protected error?: Error, 22 | ) {} 23 | 24 | /** 25 | * Get work status 26 | * @returns work status 27 | */ 28 | getWorkStatus(): WorkStatus { 29 | return this.workStatus; 30 | } 31 | 32 | /** 33 | * Get work context 34 | * @returns work context 35 | */ 36 | getWorkContext(): WorkContext { 37 | return this.workContext; 38 | } 39 | 40 | /** 41 | * Get error (if any) 42 | * @returns error 43 | */ 44 | getError(): Error | undefined { 45 | return this.error; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [1.1.2](https://github.com/rstanziale/ez-flow/compare/v1.1.1...v1.1.2) (2024-03-07) 6 | 7 | ### [1.1.1](https://github.com/rstanziale/ez-flow/compare/v1.1.0...v1.1.1) (2024-02-10) 8 | 9 | ## [1.1.0](https://github.com/rstanziale/ez-flow/compare/v1.0.2...v1.1.0) (2023-12-29) 10 | 11 | ### Bugs fixes 12 | 13 | * minor correction on README file ([8b2cce9](https://github.com/rstanziale/ez-flow/commit/8b2cce9af46e2a266e017bd59a552088f361c5c8)) 14 | 15 | ### [1.0.2](https://github.com/rstanziale/ez-flow/compare/v1.0.0...v1.0.2) (2023-12-27) 16 | 17 | ### Bugs fixes 18 | 19 | * corrected sequential name handling ([7fde3f3](https://github.com/rstanziale/ez-flow/commit/7fde3f332bcb8b8ef3b7a743ec6e4aa59657b3e9)) 20 | 21 | ### [1.0.1](https://github.com/rstanziale/ez-flow/compare/v1.0.0...v1.0.1) (2023-12-27) 22 | 23 | # 1.0.0 (2023-12-27) 24 | 25 | ### New Features 26 | 27 | * define work-flow engine ([6df1a1a](https://github.com/rstanziale/ez-flow/commit/6df1a1ab4ef900e40c31d8ce17ac2812f73ac118)) 28 | * first commit ([f00d5cc](https://github.com/rstanziale/ez-flow/commit/f00d5cccee5cb787ced3852a8b9e0c3a5a2b95da)) -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@rs-box/ez-flow", 3 | "version": "1.1.2", 4 | "description": "Library for a workflow engine", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/rstanziale/ez-flow.git" 8 | }, 9 | "keywords": [ 10 | "workflow", 11 | "typescript" 12 | ], 13 | "scripts": { 14 | "build": "tsc", 15 | "coverage": "vitest run --coverage", 16 | "format": "prettier --write \"**/*.+(js|ts|json)\"", 17 | "lint": "eslint .", 18 | "prepare": "husky", 19 | "release": "standard-version -- --dry-run", 20 | "test": "vitest --run" 21 | }, 22 | "main": "dist/index.js", 23 | "typings": "dist/index.d.ts", 24 | "files": [ 25 | "dist", 26 | "doc" 27 | ], 28 | "author": "Roberto B. Stanziale", 29 | "license": "MIT", 30 | "devDependencies": { 31 | "@commitlint/cli": "^20.1.0", 32 | "@commitlint/config-conventional": "^19.8.1", 33 | "@eslint/eslintrc": "^3.3.1", 34 | "@eslint/js": "^9.39.0", 35 | "@typescript-eslint/eslint-plugin": "^8.46.2", 36 | "@typescript-eslint/parser": "^8.46.2", 37 | "@vitest/coverage-v8": "^4.0.6", 38 | "eslint": "^9.39.0", 39 | "eslint-config-prettier": "^10.1.8", 40 | "globals": "^16.5.0", 41 | "husky": "^9.1.7", 42 | "lint-staged": "^16.2.6", 43 | "prettier": "^3.6.2", 44 | "standard-version": "^9.5.0", 45 | "typescript": "^5.9.3", 46 | "typescript-eslint": "^8.46.2", 47 | "vitest": "^4.0.6" 48 | }, 49 | "peerDependencies": { 50 | "uuid": "^11.0.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /eslint.config.mjs: -------------------------------------------------------------------------------- 1 | import { FlatCompat } from '@eslint/eslintrc'; 2 | import js from '@eslint/js'; 3 | import typescriptEslint from '@typescript-eslint/eslint-plugin'; 4 | import tsParser from '@typescript-eslint/parser'; 5 | import { defineConfig, globalIgnores } from 'eslint/config'; 6 | import globals from 'globals'; 7 | import path from 'node:path'; 8 | import { fileURLToPath } from 'node:url'; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = path.dirname(__filename); 12 | const compat = new FlatCompat({ 13 | baseDirectory: __dirname, 14 | recommendedConfig: js.configs.recommended, 15 | allConfig: js.configs.all, 16 | }); 17 | 18 | export default defineConfig([ 19 | globalIgnores(['**/node_modules', '**/dist', '**/coverage']), 20 | { 21 | files: ['**/*.js', '**/*.ts'], 22 | extends: compat.extends( 23 | 'eslint:recommended', 24 | 'plugin:@typescript-eslint/recommended', 25 | 'prettier', 26 | ), 27 | plugins: { 28 | '@typescript-eslint': typescriptEslint, 29 | }, 30 | languageOptions: { 31 | globals: { ...globals.browser, ...globals.node }, 32 | parser: tsParser, 33 | ecmaVersion: 12, 34 | sourceType: 'module', 35 | }, 36 | rules: { 37 | '@typescript-eslint/no-unused-vars': 'error', 38 | '@typescript-eslint/consistent-type-definitions': 'error', 39 | '@typescript-eslint/naming-convention': [ 40 | 'error', 41 | { 42 | selector: 'default', 43 | format: ['camelCase', 'PascalCase', 'UPPER_CASE'], 44 | }, 45 | ], 46 | '@typescript-eslint/no-empty-object-type': 'off', 47 | '@typescript-eslint/no-explicit-any': 'off', 48 | '@typescript-eslint/no-namespace': 'off', 49 | }, 50 | }, 51 | ]); 52 | -------------------------------------------------------------------------------- /src/work/parallel-work-report.ts: -------------------------------------------------------------------------------- 1 | import { WorkContext } from './work-context'; 2 | import { WorkReport } from './work-report'; 3 | import { WorkStatus } from './work-status'; 4 | 5 | /** 6 | * Defines a work report used in parallel workflow 7 | * 8 | * @author R.Stanziale 9 | * @version 1.0 10 | */ 11 | export class ParallelWorkReport implements WorkReport { 12 | /** 13 | * Constructor 14 | * @param workReportList list of work reports 15 | */ 16 | constructor(private workReportList: WorkReport[]) {} 17 | 18 | /** 19 | * Get error (if any) 20 | * @returns error 21 | */ 22 | getError(): Error[] { 23 | const errors: Error[] = []; 24 | 25 | for (const workReport of this.workReportList) { 26 | const error = workReport.getError(); 27 | if (error != null) { 28 | errors.push(error); 29 | } 30 | } 31 | 32 | return errors; 33 | } 34 | 35 | /** 36 | * Get work context 37 | * @returns work context 38 | */ 39 | getWorkContext(): WorkContext { 40 | const workContext = new WorkContext(); 41 | const multiResult: any[] = []; 42 | 43 | for (const workReport of this.workReportList) { 44 | const tmpWorkContext = workReport.getWorkContext(); 45 | tmpWorkContext.forEach((value, key) => { 46 | // if each parallel unit produced a result, each is collected in a specific array 47 | if (key === WorkContext.CTX_RESULT) { 48 | multiResult.push(value); 49 | } else { 50 | workContext.set(key, value); 51 | } 52 | }); 53 | } 54 | 55 | // If there are multiple results, it returns them in an appropriate key 56 | if (multiResult.length > 0) { 57 | workContext.set(WorkContext.CTX_RESULT_LIST, multiResult); 58 | } 59 | 60 | return workContext; 61 | } 62 | 63 | /** 64 | * Get work status 65 | * @returns work status 66 | */ 67 | getWorkStatus(): WorkStatus { 68 | for (const workReport of this.workReportList) { 69 | const workStatus = workReport.getWorkStatus(); 70 | if ( 71 | workStatus === WorkStatus.FAILED || 72 | workStatus === WorkStatus.BROKEN 73 | ) { 74 | return workStatus; 75 | } 76 | } 77 | 78 | return WorkStatus.COMPLETED; 79 | } 80 | 81 | /** 82 | * Get work reports data 83 | * @returns list of current work reports 84 | */ 85 | getWorkList(): WorkReport[] { 86 | return this.workReportList; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/work/work-context.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines a "work context" as a map 3 | * 4 | * @author R.Stanziale 5 | * @version 1.0 6 | */ 7 | export class WorkContext { 8 | // context special keys 9 | static CTX_RESULT = 'RESULT'; // Used to store the result of a work unit 10 | static CTX_RESULT_LIST = 'RESULTS'; // Used to store parallel flow results 11 | 12 | private map: Map; 13 | 14 | /** 15 | * Constructor 16 | * @param map external map (optional) 17 | */ 18 | constructor(map?: Map) { 19 | this.map = map ? map : new Map(); 20 | } 21 | 22 | /** 23 | * Get value for the given key 24 | * @param key key 25 | * @returns value 26 | */ 27 | get(key: string): Type | undefined { 28 | return this.map.get(key); 29 | } 30 | 31 | /** 32 | * Set a value for the given key 33 | * @param key key 34 | * @param value value to set 35 | */ 36 | set(key: string, value: Type) { 37 | this.map.set(key, value); 38 | } 39 | 40 | /** 41 | * Delete a value for the given key 42 | * @param key key 43 | */ 44 | delete(key: string) { 45 | this.map.delete(key); 46 | } 47 | 48 | /** 49 | * Check if exists a value for the given key 50 | * @param key key 51 | * @returns true|false 52 | */ 53 | has(key: string): boolean { 54 | return this.map.has(key); 55 | } 56 | 57 | /** 58 | * Clear all key/value pairs 59 | */ 60 | clear() { 61 | this.map.clear(); 62 | } 63 | 64 | /** 65 | * Run an action for each item in the map 66 | * @param callbackFn callback to run 67 | */ 68 | forEach( 69 | callbackFn: (value: Type, key: string, map: Map) => void, 70 | ) { 71 | this.map.forEach(callbackFn); 72 | } 73 | 74 | /** 75 | * Get content as map 76 | * @returns content as map 77 | */ 78 | asMap(): Map { 79 | return this.map; 80 | } 81 | 82 | /** 83 | * Check if the work context contains a result 84 | * @returns true|false 85 | */ 86 | hasResult(): boolean { 87 | return ( 88 | this.map.has(WorkContext.CTX_RESULT) || 89 | this.map.has(WorkContext.CTX_RESULT_LIST) 90 | ); 91 | } 92 | 93 | /** 94 | * Check if the work context contains a single or multiple result 95 | * @returns true if single, false if multiple, undefined if doesn't contain any result 96 | */ 97 | isResultSingle(): boolean | undefined { 98 | if (!this.hasResult()) return undefined; 99 | return this.map.has(WorkContext.CTX_RESULT); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /test/workflow/repeat-flow.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, test, expect, vi } from 'vitest'; 2 | import { WorkFlowEngine } from '../../src/engine/work-flow-engine'; 3 | import { WorkFlowEngineBuilder } from '../../src/engine/work-flow-engine-builder'; 4 | import { WorkContext } from '../../src/work/work-context'; 5 | import { RepeatFlow } from '../../src/workflow/repeat-flow'; 6 | import { AlwaysFalsePredicate, BrokenWork, PrintMessageWork } from '../mock'; 7 | 8 | let workFlowEngine: WorkFlowEngine; 9 | 10 | beforeEach(() => { 11 | workFlowEngine = WorkFlowEngineBuilder.newBuilder().build(); 12 | }); 13 | 14 | describe('Repeat flow', () => { 15 | test('test repeat until', async () => { 16 | const work = new PrintMessageWork('Hello'); 17 | const predicate = new AlwaysFalsePredicate(); 18 | 19 | const spyWork = vi.spyOn(work, 'call'); 20 | 21 | const repeatFlow = RepeatFlow.Builder.newFlow() 22 | .withWork(work) 23 | .until(predicate) 24 | .build(); 25 | 26 | const workContext = new WorkContext(); 27 | await workFlowEngine.run(repeatFlow, workContext); 28 | 29 | expect(spyWork).toHaveBeenCalledTimes(1); 30 | }); 31 | 32 | test('test repeat times', async () => { 33 | const work = new PrintMessageWork('Hello'); 34 | 35 | const spyWork = vi.spyOn(work, 'call'); 36 | 37 | const repeatFlow = RepeatFlow.Builder.newFlow() 38 | .withWork(work) 39 | .withTimes(3) 40 | .build(); 41 | 42 | const workContext = new WorkContext(); 43 | await workFlowEngine.run(repeatFlow, workContext); 44 | 45 | expect(spyWork).toHaveBeenCalledTimes(3); 46 | }); 47 | 48 | test('test repeat times with broken units', async () => { 49 | const work = new BrokenWork(); 50 | 51 | const spyWork = vi.spyOn(work, 'call'); 52 | 53 | const repeatFlow = RepeatFlow.Builder.newFlow() 54 | .withWork(work) 55 | .withTimes(3) 56 | .build(); 57 | 58 | const workContext = new WorkContext(); 59 | await workFlowEngine.run(repeatFlow, workContext); 60 | 61 | expect(spyWork).toHaveBeenCalledTimes(1); 62 | }); 63 | 64 | test('test repeat until without defined work', () => { 65 | expect(() => RepeatFlow.Builder.newFlow().withTimes(1).build()).toThrow(); 66 | }); 67 | 68 | test('test repeat without predicate', async () => { 69 | const work = new PrintMessageWork('Hello'); 70 | 71 | const repeatFlow = RepeatFlow.Builder.newFlow().withWork(work).build(); 72 | 73 | const throwThis = async () => { 74 | await workFlowEngine.run(repeatFlow, workContext); 75 | }; 76 | 77 | const workContext = new WorkContext(); 78 | await expect(throwThis()).rejects.toThrow(Error); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/workflow/parallel-flow.ts: -------------------------------------------------------------------------------- 1 | import { LibUtil } from '../utils/lib-util'; 2 | import { ParallelWorkReport } from '../work/parallel-work-report'; 3 | import { Work } from '../work/work'; 4 | import { WorkContext } from '../work/work-context'; 5 | import { WorkReport } from '../work/work-report'; 6 | import { AbstractWorkFlow } from './abstract-work-flow'; 7 | 8 | export class ParallelFlow extends AbstractWorkFlow { 9 | /** 10 | * Constructor 11 | * @param name workflow name 12 | * @param workList list of work units to run 13 | */ 14 | constructor( 15 | name: string, 16 | private workList: Work[], 17 | ) { 18 | super(name); 19 | } 20 | 21 | /** 22 | * Execute parallel actions on the given context 23 | * @param workContext work context 24 | * @returns work report promise 25 | */ 26 | async call(workContext: WorkContext) { 27 | // Calculates and returns the final state after all operations are completed 28 | const workReports: WorkReport[] = await Promise.all( 29 | this.workList.map(work => work.call(workContext)), 30 | ); 31 | 32 | return new ParallelWorkReport(workReports); 33 | } 34 | 35 | // 36 | // INNER CLASS 37 | // 38 | 39 | /** 40 | * Defines a builder 41 | */ 42 | static Builder = class { 43 | name: string; 44 | workList: Work[]; 45 | 46 | /** 47 | * Constructor 48 | */ 49 | constructor() { 50 | this.name = LibUtil.getUUID(); 51 | this.workList = []; 52 | } 53 | 54 | /** 55 | * Get a new flow builder 56 | */ 57 | public static newFlow(): ParallelFlow.Builder { 58 | return new ParallelFlow.Builder(); 59 | } 60 | 61 | /** 62 | * Set name 63 | * @param name name 64 | */ 65 | public withName(name: string): ParallelFlow.Builder { 66 | this.name = name; 67 | return this; 68 | } 69 | 70 | /** 71 | * Add a single work unit to the list must be executed 72 | * @param work work unit 73 | */ 74 | public addWork(work: Work): ParallelFlow.Builder { 75 | this.workList.push(work); 76 | return this; 77 | } 78 | 79 | /** 80 | * Set the list of work units must be executed 81 | * @param workList work unit list 82 | */ 83 | public withWorks(workList: Work[]): ParallelFlow.Builder { 84 | this.workList = workList; 85 | return this; 86 | } 87 | 88 | /** 89 | * Build an instance of ParallelWorkflow 90 | */ 91 | public build(): ParallelFlow { 92 | return new ParallelFlow(this.name, this.workList); 93 | } 94 | }; 95 | } 96 | 97 | export namespace ParallelFlow { 98 | export type Builder = typeof ParallelFlow.Builder.prototype; 99 | } 100 | -------------------------------------------------------------------------------- /src/workflow/sequential-flow.ts: -------------------------------------------------------------------------------- 1 | import { LibUtil } from '../utils/lib-util'; 2 | import { Work } from '../work/work'; 3 | import { WorkContext } from '../work/work-context'; 4 | import { WorkReport } from '../work/work-report'; 5 | import { WorkStatus } from '../work/work-status'; 6 | import { AbstractWorkFlow } from './abstract-work-flow'; 7 | 8 | /** 9 | * Define a sequential workflow.
10 | * It runs a list of work unit one after one until ll are completed or one fails. 11 | * 12 | * @author R.Stanziale 13 | * @version 1.0 14 | */ 15 | export class SequentialFlow extends AbstractWorkFlow { 16 | /** 17 | * Constructor 18 | * @param name workflow name 19 | * @param workList list of work units 20 | */ 21 | constructor( 22 | name: string, 23 | private workList: Work[], 24 | ) { 25 | super(name); 26 | } 27 | 28 | /** 29 | * Execute an action on the given context 30 | * @param workContext work context 31 | * @returns work report promise 32 | */ 33 | async call(workContext: WorkContext): Promise { 34 | let workReport: WorkReport; 35 | 36 | for (const work of this.workList) { 37 | workReport = await work.call(workContext); 38 | const workStatus = workReport.getWorkStatus(); 39 | 40 | if ( 41 | workReport != null && 42 | (workStatus === WorkStatus.FAILED || workStatus === WorkStatus.BROKEN) 43 | ) 44 | break; 45 | } 46 | 47 | return workReport!; 48 | } 49 | 50 | // 51 | // INNER CLASS 52 | // 53 | 54 | /** 55 | * Defines a builder 56 | */ 57 | static Builder = class { 58 | name: string; 59 | workList: Work[]; 60 | 61 | /** 62 | * Constructor 63 | */ 64 | constructor() { 65 | this.name = LibUtil.getUUID(); 66 | this.workList = []; 67 | } 68 | 69 | /** 70 | * Get a new flow builder 71 | */ 72 | public static newFlow(): SequentialFlow.Builder { 73 | return new SequentialFlow.Builder(); 74 | } 75 | 76 | /** 77 | * Set name 78 | * @param name name 79 | */ 80 | public withName(name: string): SequentialFlow.Builder { 81 | this.name = name; 82 | return this; 83 | } 84 | 85 | /** 86 | * Add a single work unit to the list must be executed 87 | * @param work work unit 88 | */ 89 | public addWork(work: Work): SequentialFlow.Builder { 90 | this.workList.push(work); 91 | return this; 92 | } 93 | 94 | /** 95 | * Set the list of work units must be executed 96 | * @param workList work unit list 97 | */ 98 | public withWorks(workList: Work[]): SequentialFlow.Builder { 99 | this.workList = workList; 100 | return this; 101 | } 102 | 103 | /** 104 | * Build an instance of SequentialFlow 105 | */ 106 | public build(): SequentialFlow { 107 | return new SequentialFlow(this.name, this.workList); 108 | } 109 | }; 110 | } 111 | 112 | export namespace SequentialFlow { 113 | export type Builder = typeof SequentialFlow.Builder.prototype; 114 | } 115 | -------------------------------------------------------------------------------- /test/workflow/parallel-flow.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, test, expect, vi } from 'vitest'; 2 | import { ContextWork, ErrorWork, PrintDateCount } from '../mock'; 3 | import { ParallelFlow } from '../../src/workflow/parallel-flow'; 4 | import { WorkContext } from '../../src/work/work-context'; 5 | import { WorkFlowEngine } from '../../src/engine/work-flow-engine'; 6 | import { WorkFlowEngineBuilder } from '../../src/engine/work-flow-engine-builder'; 7 | import { ParallelWorkReport } from '../../src/work/parallel-work-report'; 8 | import { WorkStatus } from '../../src/work/work-status'; 9 | 10 | let workFlowEngine: WorkFlowEngine; 11 | 12 | beforeEach(() => { 13 | workFlowEngine = WorkFlowEngineBuilder.newBuilder().build(); 14 | }); 15 | 16 | describe('Parallel flow', () => { 17 | test('test exectute', async () => { 18 | const work = new PrintDateCount(); 19 | 20 | const spyWork = vi.spyOn(work, 'call'); 21 | 22 | const parallelFlow = ParallelFlow.Builder.newFlow() 23 | .withWorks([work, work]) 24 | .addWork(work) 25 | .build(); 26 | 27 | const workContext = new WorkContext(); 28 | const workReport = await workFlowEngine.run(parallelFlow, workContext); 29 | const resultList: string[] = workReport 30 | .getWorkContext() 31 | .get(WorkContext.CTX_RESULT_LIST); 32 | const everyEquals: boolean = resultList.every(r => r === resultList[0]); 33 | 34 | expect((workReport as ParallelWorkReport).getError().length).toBe(0); 35 | expect(workReport.getWorkContext().isResultSingle()).toBeFalsy(); 36 | expect(spyWork).toHaveBeenCalledTimes(3); 37 | expect(everyEquals).toBeTruthy(); 38 | }); 39 | 40 | test('test exectute without units', async () => { 41 | const work = new ContextWork(); 42 | const parallelFlow = ParallelFlow.Builder.newFlow() 43 | .withWorks([work, work]) 44 | .build(); 45 | 46 | const workContext = new WorkContext(); 47 | const workReport = await workFlowEngine.run(parallelFlow, workContext); 48 | const result = workReport.getWorkContext(); 49 | 50 | expect((workReport as ParallelWorkReport).getError().length).toBe(0); 51 | expect(result.asMap().size).not.toBe(0); 52 | }); 53 | 54 | test('test exectute with errors', async () => { 55 | const work1 = new PrintDateCount(); 56 | const work2 = new ContextWork(); 57 | const errorWork = new ErrorWork(); 58 | 59 | const spyWork = vi.spyOn(work1, 'call'); 60 | 61 | const parallelFlow = ParallelFlow.Builder.newFlow() 62 | .withWorks([work1, work1, work2]) 63 | .addWork(errorWork) 64 | .build(); 65 | 66 | const workContext = new WorkContext(); 67 | const workReport = await workFlowEngine.run(parallelFlow, workContext); 68 | const resultList: string[] = workReport 69 | .getWorkContext() 70 | .get(WorkContext.CTX_RESULT_LIST); 71 | const everyEquals: boolean = resultList.every(r => r === resultList[0]); 72 | 73 | expect((workReport as ParallelWorkReport).getError().length).toBe(1); 74 | expect(workReport.getWorkContext().isResultSingle()).toBeFalsy(); 75 | expect(workReport.getWorkStatus()).not.toBe(WorkStatus.COMPLETED); 76 | expect(spyWork).toHaveBeenCalledTimes(2); 77 | expect(everyEquals).toBeTruthy(); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /test/workflow/conditional-flow.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, test, expect, vi } from 'vitest'; 2 | import { AlwaysFalsePredicate, PrintMessageWork } from '../mock'; 3 | import { ConditionalFlow } from '../../src/workflow/conditional-flow'; 4 | import { WorkContext } from '../../src/work/work-context'; 5 | import { WorkFlowEngine } from '../../src/engine/work-flow-engine'; 6 | import { WorkFlowEngineBuilder } from '../../src/engine/work-flow-engine-builder'; 7 | 8 | let workFlowEngine: WorkFlowEngine; 9 | 10 | beforeEach(() => { 11 | workFlowEngine = WorkFlowEngineBuilder.newBuilder().build(); 12 | }); 13 | 14 | describe('Conditional flow', () => { 15 | test('call on predicate success', async () => { 16 | const work1 = new PrintMessageWork('Start'); 17 | const work2 = new PrintMessageWork('Then'); 18 | const work3 = new PrintMessageWork('Else'); 19 | 20 | const spyWork1 = vi.spyOn(work1, 'call'); 21 | const spyWork2 = vi.spyOn(work2, 'call'); 22 | const spyWork3 = vi.spyOn(work3, 'call'); 23 | 24 | const conditionalFlow = ConditionalFlow.Builder.newFlow() 25 | .withWork(work1) 26 | .then(work2) 27 | .otherwise(work3) 28 | .build(); 29 | 30 | const workContext = new WorkContext(); 31 | await workFlowEngine.run(conditionalFlow, workContext); 32 | 33 | expect(conditionalFlow.getName()).not.toBeNull(); 34 | expect(spyWork1).toHaveBeenCalled(); 35 | expect(spyWork2).toHaveBeenCalled(); 36 | expect(spyWork3).not.toHaveBeenCalled(); 37 | }); 38 | 39 | test('call on predicate failure', async () => { 40 | const work1 = new PrintMessageWork('Start'); 41 | const work2 = new PrintMessageWork('Then'); 42 | const work3 = new PrintMessageWork('Else'); 43 | const predicate = new AlwaysFalsePredicate(); 44 | 45 | const spyWork1 = vi.spyOn(work1, 'call'); 46 | const spyWork2 = vi.spyOn(work2, 'call'); 47 | const spyWork3 = vi.spyOn(work3, 'call'); 48 | 49 | const conditionalFlow = ConditionalFlow.Builder.newFlow() 50 | .withName('conditional flow') 51 | .withWork(work1) 52 | .when(predicate) 53 | .then(work2) 54 | .otherwise(work3) 55 | .build(); 56 | 57 | const workContext = new WorkContext(); 58 | await workFlowEngine.run(conditionalFlow, workContext); 59 | 60 | expect(conditionalFlow.getName()).not.toBeNull(); 61 | expect(spyWork1).toHaveBeenCalled(); 62 | expect(spyWork2).not.toHaveBeenCalled(); 63 | expect(spyWork3).toHaveBeenCalled(); 64 | }); 65 | 66 | test('call on predicate failure without otherwise', async () => { 67 | const work1 = new PrintMessageWork('Start'); 68 | const work2 = new PrintMessageWork('Then'); 69 | const predicate = new AlwaysFalsePredicate(); 70 | 71 | const spyWork1 = vi.spyOn(work1, 'call'); 72 | const spyWork2 = vi.spyOn(work2, 'call'); 73 | 74 | const conditionalFlow = ConditionalFlow.Builder.newFlow() 75 | .withName('conditional flow') 76 | .withWork(work1) 77 | .when(predicate) 78 | .then(work2) 79 | .build(); 80 | 81 | const workContext = new WorkContext(); 82 | await workFlowEngine.run(conditionalFlow, workContext); 83 | 84 | expect(conditionalFlow.getName()).not.toBeNull(); 85 | expect(spyWork1).toHaveBeenCalled(); 86 | expect(spyWork2).not.toHaveBeenCalled(); 87 | }); 88 | 89 | test('call with null work', async () => { 90 | const work1 = new PrintMessageWork('Start'); 91 | const work2 = new PrintMessageWork('Then'); 92 | const work3 = new PrintMessageWork('Else'); 93 | const predicate = new AlwaysFalsePredicate(); 94 | 95 | const spyWork1 = vi.spyOn(work1, 'call'); 96 | const spyWork2 = vi.spyOn(work2, 'call'); 97 | const spyWork3 = vi.spyOn(work3, 'call'); 98 | 99 | const conditionalFlow = ConditionalFlow.Builder.newFlow() 100 | .withName('conditional flow') 101 | .withWork(work1) 102 | .when(predicate) 103 | .then(work2) 104 | .otherwise(work3) 105 | .build(); 106 | 107 | const workContext = new WorkContext(); 108 | await workFlowEngine.run(conditionalFlow, workContext); 109 | 110 | expect(conditionalFlow.getName()).not.toBeNull(); 111 | expect(spyWork1).toHaveBeenCalled(); 112 | expect(spyWork2).not.toHaveBeenCalled(); 113 | expect(spyWork3).toHaveBeenCalled(); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /test/engine/work-flow-engine-impl.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, test, expect } from 'vitest'; 2 | import { 3 | AggregateWordCountsWork, 4 | CompletedPredicate, 5 | PrintMessageWork, 6 | PrintWordCount, 7 | WordCountWork, 8 | } from '../mock'; 9 | import { ConditionalFlow } from '../../src/workflow/conditional-flow'; 10 | import { ParallelFlow } from '../../src/workflow/parallel-flow'; 11 | import { RepeatFlow } from '../../src/workflow/repeat-flow'; 12 | import { SequentialFlow } from '../../src/workflow/sequential-flow'; 13 | import { WorkContext } from '../../src/work/work-context'; 14 | import { WorkFlowEngine } from '../../src/engine/work-flow-engine'; 15 | import { WorkFlowEngineBuilder } from '../../src/engine/work-flow-engine-builder'; 16 | import { WorkStatus } from '../../src/work/work-status'; 17 | 18 | let workFlowEngine: WorkFlowEngine; 19 | 20 | beforeEach(() => { 21 | workFlowEngine = WorkFlowEngineBuilder.newBuilder().build(); 22 | }); 23 | 24 | describe('WorkFlow engine', () => { 25 | test('compose workflow from separate flows and execute it', async () => { 26 | const work1 = new PrintMessageWork('foo'); 27 | const work2 = new PrintMessageWork('hello'); 28 | const work3 = new PrintMessageWork('world'); 29 | const work4 = new PrintMessageWork('done'); 30 | const predicate = new CompletedPredicate(); 31 | 32 | const repeatFlow = RepeatFlow.Builder.newFlow() 33 | .withName('print foo 3 times') 34 | .withWork(work1) 35 | .withTimes(3) 36 | .build(); 37 | 38 | const parallelFlow = ParallelFlow.Builder.newFlow() 39 | .withName("print 'hello' and 'world' in parallel") 40 | .withWorks([work2, work3]) 41 | .build(); 42 | 43 | const conditionalFlow = ConditionalFlow.Builder.newFlow() 44 | .withWork(parallelFlow) 45 | .when(predicate) 46 | .then(work4) 47 | .build(); 48 | 49 | const sequentialFlow = SequentialFlow.Builder.newFlow() 50 | .addWork(repeatFlow) 51 | .addWork(conditionalFlow) 52 | .build(); 53 | 54 | const workContext = new WorkContext(); 55 | const workReport = await workFlowEngine.run(sequentialFlow, workContext); 56 | 57 | expect(workReport.getWorkStatus() === WorkStatus.COMPLETED); 58 | expect(workReport.getError()).not.toBeTruthy(); 59 | }); 60 | 61 | test('define workflow inline and execute it', async () => { 62 | const work1 = new PrintMessageWork('foo'); 63 | const work2 = new PrintMessageWork('hello'); 64 | const work3 = new PrintMessageWork('world'); 65 | const work4 = new PrintMessageWork('done'); 66 | const predicate = new CompletedPredicate(); 67 | 68 | const workflow = SequentialFlow.Builder.newFlow() 69 | .addWork( 70 | RepeatFlow.Builder.newFlow() 71 | .withName('print foo 3 times') 72 | .withWork(work1) 73 | .withTimes(3) 74 | .build(), 75 | ) 76 | .addWork( 77 | ConditionalFlow.Builder.newFlow() 78 | .withWork( 79 | ParallelFlow.Builder.newFlow() 80 | .withName("print 'hello' and 'world' in parallel") 81 | .withWorks([work2, work3]) 82 | .build(), 83 | ) 84 | .when(predicate) 85 | .then(work4) 86 | .build(), 87 | ) 88 | .build(); 89 | 90 | const workContext = new WorkContext(); 91 | const workReport = await workFlowEngine.run(workflow, workContext); 92 | 93 | expect(workReport.getWorkStatus() === WorkStatus.COMPLETED); 94 | expect(workReport.getError()).not.toBeTruthy(); 95 | }); 96 | 97 | test('use work context to pass initial parameters and share data between work units', async () => { 98 | const work1 = new WordCountWork(1); 99 | const work2 = new WordCountWork(2); 100 | const work3 = new AggregateWordCountsWork(); 101 | const work4 = new PrintWordCount(); 102 | 103 | const workflow = SequentialFlow.Builder.newFlow() 104 | .addWork(ParallelFlow.Builder.newFlow().withWorks([work1, work2]).build()) 105 | .addWork(work3) 106 | .addWork(work4) 107 | .build(); 108 | 109 | const workContext = new WorkContext(); 110 | workContext.set('partition1', 'hello foo'); 111 | workContext.set('partition2', 'hello bar'); 112 | 113 | const workReport = await workFlowEngine.run(workflow, workContext); 114 | 115 | expect(workReport.getWorkStatus() === WorkStatus.COMPLETED); 116 | expect(workReport.getWorkContext().asMap().size).toBeGreaterThan(0); 117 | expect(workReport.getError()).not.toBeTruthy(); 118 | }); 119 | }); 120 | -------------------------------------------------------------------------------- /test/mock.ts: -------------------------------------------------------------------------------- 1 | import { DefaultWorkReport } from '../src/work/default-work-report'; 2 | import { BrokenWorkReport } from '../src/work/broken-work-report'; 3 | import { FailureWorkReport } from '../src/work/failure-work-report'; 4 | import { Predicate } from '../src/work/predicate'; 5 | import { Work } from '../src/work/work'; 6 | import { WorkContext } from '../src/work/work-context'; 7 | import { WorkReport } from '../src/work/work-report'; 8 | import { WorkStatus } from '../src/work/work-status'; 9 | 10 | export class AlwaysTruePredicate implements Predicate { 11 | async apply() { 12 | return true; 13 | } 14 | } 15 | 16 | export class AlwaysFalsePredicate implements Predicate { 17 | async apply() { 18 | return false; 19 | } 20 | } 21 | 22 | export class CompletedPredicate implements Predicate { 23 | async apply(workReport: WorkReport) { 24 | return workReport.getWorkStatus() === WorkStatus.COMPLETED; 25 | } 26 | } 27 | 28 | export class BrokenWork implements Work { 29 | getName() { 30 | return 'broken work'; 31 | } 32 | 33 | async call(workContext: WorkContext): Promise { 34 | return new BrokenWorkReport(workContext, new Error('workflow interrupted')); 35 | } 36 | } 37 | 38 | export class ErrorWork implements Work { 39 | getName() { 40 | return 'error work'; 41 | } 42 | 43 | async call(workContext: WorkContext): Promise { 44 | return new FailureWorkReport(workContext, new Error('workflow error')); 45 | } 46 | } 47 | 48 | export class PrintMessageWork implements Work { 49 | private message: string; 50 | 51 | constructor(message: string) { 52 | this.message = message; 53 | } 54 | 55 | getName() { 56 | return 'print message work'; 57 | } 58 | 59 | async call(workContext: WorkContext): Promise { 60 | console.log(this.message); 61 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 62 | } 63 | } 64 | 65 | export class WordCountWork implements Work { 66 | private partition: number; 67 | 68 | constructor(partition: number) { 69 | this.partition = partition; 70 | } 71 | 72 | getName() { 73 | return 'count words in a given string'; 74 | } 75 | 76 | async call(workContext: WorkContext): Promise { 77 | const input: string = workContext.get(`partition${this.partition}`); 78 | workContext.set( 79 | `wordCountInPartition${this.partition}`, 80 | input.split(' ').length, 81 | ); 82 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 83 | } 84 | } 85 | 86 | export class AggregateWordCountsWork implements Work { 87 | getName() { 88 | return 'aggregate word counts from partitions'; 89 | } 90 | 91 | async call(workContext: WorkContext): Promise { 92 | let sum = 0; 93 | workContext.forEach((value: any, key: string) => { 94 | if (key.includes('InPartition')) { 95 | sum += value; 96 | } 97 | }); 98 | 99 | workContext.set('totalCount', sum); 100 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 101 | } 102 | } 103 | 104 | export class PrintWordCount implements Work { 105 | getName() { 106 | return 'print total word count'; 107 | } 108 | 109 | async call(workContext: WorkContext): Promise { 110 | const totalCount: number = workContext.get('totalCount'); 111 | console.log(totalCount); 112 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 113 | } 114 | } 115 | 116 | export class PrintDateCount implements Work { 117 | getName() { 118 | return 'print date'; 119 | } 120 | 121 | async call(workContext: WorkContext): Promise { 122 | const date: string = new Date().toISOString(); 123 | workContext.set(WorkContext.CTX_RESULT, date); 124 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 125 | } 126 | } 127 | 128 | export class ContextWork implements Work { 129 | getName() { 130 | return 'context work'; 131 | } 132 | 133 | async call(workContext: WorkContext): Promise { 134 | workContext.set('test', 'this is a test'); 135 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 136 | } 137 | } 138 | 139 | export class ConcatWordCount implements Work { 140 | private word: string; 141 | 142 | constructor(word: string) { 143 | this.word = word; 144 | } 145 | 146 | getName() { 147 | return 'concat word'; 148 | } 149 | 150 | async call(workContext: WorkContext): Promise { 151 | const previousWord: string = workContext.get(WorkContext.CTX_RESULT) ?? ''; 152 | workContext.set(WorkContext.CTX_RESULT, `${previousWord}${this.word}`); 153 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/workflow/repeat-flow.ts: -------------------------------------------------------------------------------- 1 | import { LibUtil } from '../utils/lib-util'; 2 | import { Predicate } from '../work/predicate'; 3 | import { Work } from '../work/work'; 4 | import { WorkContext } from '../work/work-context'; 5 | import { WorkReport } from '../work/work-report'; 6 | import { WorkReportPredicate } from '../work/work-report-predicate'; 7 | import { AbstractWorkFlow } from './abstract-work-flow'; 8 | 9 | /** 10 | * Defines a repeating workflow.
11 | * It runs more times the work unit, until the predicate becomes false (if no times is set).
12 | * If 'times' is set, then repeat 'times' or breaks on failed predicate 13 | * 14 | * @author R.Stanziale 15 | * @version 1.0 16 | */ 17 | export class RepeatFlow extends AbstractWorkFlow { 18 | /** 19 | * Constructor 20 | * @param name workflow unit name 21 | * @param work work unit 22 | * @param times times to iterate (optional) 23 | * @param predicate predicate function (optional) 24 | */ 25 | constructor( 26 | name: string, 27 | private work: Work, 28 | private times: number, 29 | private predicate?: Predicate, 30 | ) { 31 | super(name); 32 | } 33 | 34 | /** 35 | * Execute an action on the given context 36 | * @param workContext work context 37 | * @returns work report promise 38 | */ 39 | async call(workContext: WorkContext) { 40 | return this.times && this.times > 0 41 | ? this.doFor(workContext) 42 | : this.doLoop(workContext); 43 | } 44 | 45 | // 46 | // PRIVATE 47 | // 48 | 49 | /** 50 | * Execute a loop for 'times' times or breaks on failed predicate 51 | * @param workContext work context 52 | * @returns work report promise 53 | */ 54 | private async doFor(workContext: WorkContext) { 55 | let workReport: WorkReport; 56 | const predicate = new WorkReportPredicate(); 57 | let predicateVal: boolean; 58 | 59 | for (let i = 0; i < this.times; i++) { 60 | workReport = await this.work.call(workContext); 61 | predicateVal = await predicate.apply(workReport); 62 | 63 | if (!predicateVal) { 64 | break; 65 | } 66 | } 67 | 68 | return workReport!; 69 | } 70 | 71 | /** 72 | * Execute a loop until predicate becomes FAILED 73 | * @param workContext work context 74 | * @returns work report promise 75 | */ 76 | private async doLoop(workContext: WorkContext) { 77 | let workReport: WorkReport; 78 | 79 | if (!this.predicate) { 80 | throw new Error('[ERROR] Aborting repeat flow. No predicate defined'); 81 | } 82 | 83 | let predicateVal: boolean; 84 | do { 85 | workReport = await this.work.call(workContext); 86 | predicateVal = await this.predicate.apply(workReport); 87 | } while (predicateVal); 88 | 89 | return workReport; 90 | } 91 | 92 | // 93 | // INNER CLASS 94 | // 95 | 96 | /** 97 | * Defines a builder 98 | */ 99 | static Builder = class { 100 | name: string; 101 | work: Work | undefined; 102 | times: number = 0; 103 | predicate: Predicate | undefined; 104 | 105 | /** 106 | * Constructor 107 | */ 108 | constructor() { 109 | this.name = LibUtil.getUUID(); 110 | } 111 | 112 | /** 113 | * Get a new flow builder 114 | */ 115 | public static newFlow(): RepeatFlow.Builder { 116 | return new RepeatFlow.Builder(); 117 | } 118 | 119 | /** 120 | * Set name 121 | * @param name name 122 | */ 123 | public withName(name: string): RepeatFlow.Builder { 124 | this.name = name; 125 | return this; 126 | } 127 | 128 | /** 129 | * Set the work unit to execute 130 | * @param work work unit 131 | */ 132 | public withWork(work: Work): RepeatFlow.Builder { 133 | this.work = work; 134 | return this; 135 | } 136 | 137 | /** 138 | * Set times to repeat the work unit execution 139 | * @param times times 140 | */ 141 | public withTimes(times: number): RepeatFlow.Builder { 142 | this.times = times; 143 | return this; 144 | } 145 | 146 | /** 147 | * Set predicate function 148 | * @param predicate predicate function 149 | */ 150 | public until(predicate: Predicate): RepeatFlow.Builder { 151 | this.predicate = predicate; 152 | return this; 153 | } 154 | 155 | /** 156 | * Build an instance of RepeatFlow 157 | */ 158 | public build(): RepeatFlow { 159 | if (!this.work) { 160 | throw new Error('[ERROR] Aborting repeat flow. No work defined'); 161 | } 162 | 163 | return this.times && this.times > 0 164 | ? new RepeatFlow(this.name, this.work, this.times) 165 | : new RepeatFlow(this.name, this.work, 0, this.predicate); 166 | } 167 | }; 168 | } 169 | 170 | export namespace RepeatFlow { 171 | export type Builder = typeof RepeatFlow.Builder.prototype; 172 | } 173 | -------------------------------------------------------------------------------- /test/workflow/sequential-flow.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, test, expect, vi } from 'vitest'; 2 | import { ConcatWordCount, BrokenWork, ErrorWork } from '../mock'; 3 | import { SequentialFlow } from '../../src/workflow/sequential-flow'; 4 | import { WorkContext } from '../../src/work/work-context'; 5 | import { WorkFlowEngine } from '../../src/engine/work-flow-engine'; 6 | import { WorkFlowEngineBuilder } from '../../src/engine/work-flow-engine-builder'; 7 | 8 | let workFlowEngine: WorkFlowEngine; 9 | 10 | beforeEach(() => { 11 | workFlowEngine = WorkFlowEngineBuilder.newBuilder().build(); 12 | }); 13 | 14 | describe('Sequential flow', () => { 15 | test('test exectute', async () => { 16 | const work1 = new ConcatWordCount('Hello'); 17 | const work2 = new ConcatWordCount(', '); 18 | const work3 = new ConcatWordCount('World'); 19 | 20 | const spyWork1 = vi.spyOn(work1, 'call'); 21 | const spyWork2 = vi.spyOn(work2, 'call'); 22 | const spyWork3 = vi.spyOn(work3, 'call'); 23 | 24 | const sequentialFlow = SequentialFlow.Builder.newFlow() 25 | .withName('sequential flow') 26 | .addWork(work1) 27 | .addWork(work2) 28 | .addWork(work3) 29 | .build(); 30 | 31 | const workContext = new WorkContext(); 32 | const workReport = await workFlowEngine.run(sequentialFlow, workContext); 33 | const result: string = workReport 34 | .getWorkContext() 35 | .get(WorkContext.CTX_RESULT); 36 | 37 | expect(workReport.getWorkContext().isResultSingle()).toBeTruthy(); 38 | expect(workReport.getWorkContext().hasResult()).toBeTruthy(); 39 | expect(spyWork1).toHaveBeenCalledTimes(1); 40 | expect(spyWork2).toHaveBeenCalledTimes(1); 41 | expect(spyWork3).toHaveBeenCalledTimes(1); 42 | expect(result).toBe('Hello, World'); 43 | }); 44 | 45 | test('test passing multiple units at once', async () => { 46 | const work1 = new ConcatWordCount('Hello'); 47 | const work2 = new ConcatWordCount(', '); 48 | const work3 = new ConcatWordCount('World'); 49 | 50 | const spyWork1 = vi.spyOn(work1, 'call'); 51 | const spyWork2 = vi.spyOn(work2, 'call'); 52 | const spyWork3 = vi.spyOn(work3, 'call'); 53 | 54 | const sequentialFlow = SequentialFlow.Builder.newFlow() 55 | .withWorks([work1, work2, work3]) 56 | .build(); 57 | 58 | const workContext = new WorkContext(); 59 | const workReport = await workFlowEngine.run(sequentialFlow, workContext); 60 | const result: string = workReport 61 | .getWorkContext() 62 | .get(WorkContext.CTX_RESULT); 63 | 64 | expect(workReport.getWorkContext().isResultSingle()).toBeTruthy(); 65 | expect(workReport.getWorkContext().hasResult()).toBeTruthy(); 66 | expect(spyWork1).toHaveBeenCalledTimes(1); 67 | expect(spyWork2).toHaveBeenCalledTimes(1); 68 | expect(spyWork3).toHaveBeenCalledTimes(1); 69 | expect(result).toBe('Hello, World'); 70 | }); 71 | 72 | test('test passing with errors', async () => { 73 | const work1 = new ConcatWordCount('Hello'); 74 | const work2 = new BrokenWork(); 75 | const work3 = new ConcatWordCount('World'); 76 | 77 | const spyWork1 = vi.spyOn(work1, 'call'); 78 | const spyWork2 = vi.spyOn(work2, 'call'); 79 | const spyWork3 = vi.spyOn(work3, 'call'); 80 | 81 | const sequentialFlow = SequentialFlow.Builder.newFlow() 82 | .withWorks([work1, work2, work3]) 83 | .build(); 84 | 85 | const workContext = new WorkContext(); 86 | const workReport = await workFlowEngine.run(sequentialFlow, workContext); 87 | const result: string = workReport 88 | .getWorkContext() 89 | .get(WorkContext.CTX_RESULT); 90 | 91 | expect(workReport.getError()).not.toBeUndefined(); 92 | expect(spyWork1).toHaveBeenCalledTimes(1); 93 | expect(spyWork2).toHaveBeenCalledTimes(1); 94 | expect(spyWork3).toHaveBeenCalledTimes(0); 95 | expect(result).not.toBe('Hello, World'); 96 | }); 97 | 98 | test('test passing with errors', async () => { 99 | const work1 = new ConcatWordCount('Hello'); 100 | const work2 = new ErrorWork(); 101 | const work3 = new ConcatWordCount('World'); 102 | 103 | const spyWork1 = vi.spyOn(work1, 'call'); 104 | const spyWork2 = vi.spyOn(work2, 'call'); 105 | const spyWork3 = vi.spyOn(work3, 'call'); 106 | 107 | const sequentialFlow = SequentialFlow.Builder.newFlow() 108 | .withWorks([work1, work2, work3]) 109 | .build(); 110 | 111 | const workContext = new WorkContext(); 112 | const workReport = await workFlowEngine.run(sequentialFlow, workContext); 113 | const result: string = workReport 114 | .getWorkContext() 115 | .get(WorkContext.CTX_RESULT); 116 | 117 | expect(workReport.getError()).not.toBeUndefined(); 118 | expect(spyWork1).toHaveBeenCalledTimes(1); 119 | expect(spyWork2).toHaveBeenCalledTimes(1); 120 | expect(spyWork3).toHaveBeenCalledTimes(0); 121 | expect(result).not.toBe('Hello, World'); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ez-flow 2 | 3 | [![npm version](https://img.shields.io/npm/v/@rs-box/ez-flow)](https://www.npmjs.com/package/@rs-box/ez-flow) 4 | [![build status](https://img.shields.io/github/actions/workflow/status/rstanziale/ez-flow/ci.yml)](https://github.com/rstanziale/ez-flow/actions) 5 | ![npm bundle size](https://img.shields.io/bundlephobia/minzip/@rs-box/ez-flow?label=Bundle%20Size&link=https://bundlephobia.com/package/@rs-box/ez-flow@latest) 6 | 7 | It's a library implementing a simple workflow engine. 8 | 9 | This library was heavily inspired to [j-easy/easy-flows](https://github.com/j-easy/easy-flows) in the Java world. 10 | 11 | ## Installation 12 | 13 | ```bash 14 | npm install @rs-box/ez-flow 15 | ``` 16 | 17 | ## Usage 18 | 19 | A **Workflow** is a collection of **Work Units** must be run tu achieve a given purpose. 20 | 21 | Each _Work_ unit implements a behaviour and interacts with outer environment and the other units by the means of a **Context** exchanged between them. You can place each unit in a flow applying some logic to control it. 22 | 23 | That is you can place unit in a sequential flow, in a conditional one, in a parallel one and so on. 24 | 25 | At the end of this "chain" we obtain the final result. 26 | 27 | In this library are actually available the following flow constructs: 28 | 29 | - **Sequential Flow** 30 | - **Conditional Flow** 31 | - **Iterative Flow** 32 | - **Parallel Flow** 33 | 34 | ![flows](./doc/img/flows.png) 35 | 36 | ### How to apply a flow? 37 | 38 | Suppose you have defined a work unit called **PrintMessageWork** that implements _Work_. It takes a _message_ and its _call_ method prints it:
39 | 40 | ```typescript 41 | import { 42 | Work, 43 | WorkContext, 44 | WorkReport, 45 | DefaultWorkReport, 46 | WorkStatus, 47 | } from '@rs-box/ez-flow'; 48 | 49 | export class PrintMessageWork implements Work { 50 | private message: string; 51 | 52 | constructor(message: string) { 53 | this.message = message; 54 | } 55 | 56 | getName() { 57 | return 'print message'; 58 | } 59 | 60 | async call(workContext: WorkContext): Promise { 61 | console.log(this.message); 62 | return new DefaultWorkReport(WorkStatus.COMPLETED, workContext); 63 | } 64 | } 65 | ``` 66 | 67 | Now suppose to create the following workflow: 68 | 69 | - Print "foo" 3 times 70 | - Then print "hello" and "world" in parallel 71 | - Then if both "hello" and "world" have been successfully printed, finally print "ok", otherwise print "nok". 72 | 73 | This workflow can be illustrated as follows: 74 | 75 | ![sample workflow](./doc/img/flows-example.png) 76 | 77 | - _flow1_ is a **RepeatFlow** of _work1_ which prints "foo" 3 times. 78 | - _flow2_ is a **ParallelFlow** of _work2_ and _work3_ which prints "hello" and "world" in parallel 79 | - _flow3_ is a **ConditionalFlow**. It first executes _flow2_, then if _flow2_ is completed, it executes _work4_ (print "ok"), otherwise executes _work5_ (print "nok") 80 | - _flow4_ is a **SequentialFlow**. It executes _flow1_ then _flow3_ in sequence 81 | 82 | This is a code snippet for the above example: 83 | 84 | ```typescript 85 | import { 86 | ConditionalFlow, 87 | SequentialFlow, 88 | RepeatFlow, 89 | ParallelFlow, 90 | WorkFlowEngine, 91 | WorkStatus, 92 | WorkContext, 93 | WorkFlowEngineBuilder, 94 | WorkReport, 95 | } from '@rs-box/ez-flow'; 96 | import { PrintMessageWork } from './print-message-work'; 97 | 98 | // 1. Build work units 99 | const work1: PrintMessageWork = new PrintMessageWork('foo'); 100 | const work2: PrintMessageWork = new PrintMessageWork('hello'); 101 | const work3: PrintMessageWork = new PrintMessageWork('world'); 102 | const work4: PrintMessageWork = new PrintMessageWork('ok'); 103 | const work5: PrintMessageWork = new PrintMessageWork('nok'); 104 | 105 | // 2. Build workflow 106 | const workflow = SequentialFlow.Builder.newFlow() // flow 4 107 | .addWork( 108 | RepeatFlow.Builder.newFlow() // flow 1 109 | .withName('print foo') 110 | .withWork(work1) 111 | .withTimes(3) 112 | .build(), 113 | ) 114 | .addWork( 115 | ConditionalFlow.Builder.newFlow() // flow 3 116 | .withWork( 117 | ParallelFlow.Builder.newFlow() // flow 2 118 | .withName('print hello world') 119 | .addWork(work2) 120 | .addWork(work3) 121 | .build(), 122 | ) 123 | .then(work4) 124 | .otherwise(work5) 125 | .build(), 126 | ) 127 | .build(); 128 | 129 | // set needed attributes in workContext 130 | const workContext = new WorkContext(); 131 | 132 | // 3. Run workflow 133 | const workFlowEngine: WorkFlowEngine = WorkFlowEngineBuilder.newBuilder().build(); 134 | 135 | workFlowEngine.run(workflow, workContext).then( 136 | (finalReport: WorkReport) => { 137 | if (finalReport.getWorkStatus() === WorkStatus.COMPLETED) { 138 | // Completed successfully 139 | console.log('Completed successfully'); 140 | } else { 141 | // There was a failure 142 | const err = finalReport.getError(); 143 | // Show error... 144 | console.error('error: ', err); 145 | } 146 | }, 147 | err => { 148 | console.error('general error: ', err); 149 | }, 150 | ); 151 | ``` 152 | -------------------------------------------------------------------------------- /src/workflow/conditional-flow.ts: -------------------------------------------------------------------------------- 1 | import { LibUtil } from '../utils/lib-util'; 2 | import { NoOpWork } from '../work/no-op-work'; 3 | import { Predicate } from '../work/predicate'; 4 | import { Work } from '../work/work'; 5 | import { WorkContext } from '../work/work-context'; 6 | import { WorkReport } from '../work/work-report'; 7 | import { WorkReportPredicate } from '../work/work-report-predicate'; 8 | import { AbstractWorkFlow } from './abstract-work-flow'; 9 | 10 | /** 11 | * Defines a conditional workflow 12 | * 13 | * @author R.Stanzialee 14 | * @version 1.0 15 | */ 16 | export class ConditionalFlow extends AbstractWorkFlow { 17 | /** 18 | * Constructor 19 | * @param name work name 20 | * @param toExecute work to execute 21 | * @param nextOnTrue work to execute on success or true predicate 22 | * @param nextOnFalse work to execute on failure or false predicate (optional) 23 | * @param predicate predicate function (optional) 24 | */ 25 | constructor( 26 | name: string, 27 | private toExecute: Work, 28 | private nextOnTrue: Work, 29 | private nextOnFalse: Work, 30 | private predicate?: Predicate, 31 | ) { 32 | super(name); 33 | } 34 | 35 | /** 36 | * Execute an action on the given context 37 | * 38 | * It can work in 2 ways: 39 | * 1) a main statement is specified to be executed (withWork), the result of which is used as a predicate to be evaluated 40 | * 2) an explicit predicate (when) is specified to be evaluated 41 | * @param workContext work context 42 | * @returns work report promise 43 | */ 44 | async call(workContext: WorkContext) { 45 | let returnReport: WorkReport; 46 | // Executes main work unit 47 | returnReport = await this.toExecute.call(workContext); 48 | 49 | // If there is no explicit predicate, then the predicate is based on the execution state (COMPLETED, FAILED). 50 | if (!this.predicate) { 51 | this.predicate = new WorkReportPredicate(); 52 | } 53 | 54 | // Evaluates the predicate 55 | const predicateVal = await this.predicate.apply(returnReport); 56 | if (predicateVal) { 57 | // If true, executes the work item 'nextOnTrue' 58 | returnReport = await this.nextOnTrue.call(workContext); 59 | } else { 60 | // If it is 'false' and a 'nextOnFalse' unit is specified, then execute it 61 | if (!(this.nextOnFalse instanceof NoOpWork)) { 62 | returnReport = await this.nextOnFalse.call(workContext); 63 | } 64 | } 65 | 66 | return returnReport; 67 | } 68 | 69 | // 70 | // INNER CLASS 71 | // 72 | 73 | /** 74 | * Defines a builder 75 | */ 76 | static Builder = class { 77 | name: string; 78 | toExecute: Work; 79 | nextOnTrue: Work; 80 | nextOnFalse: Work; 81 | predicate: Predicate | undefined; 82 | 83 | /** 84 | * Constructor 85 | */ 86 | constructor() { 87 | this.name = LibUtil.getUUID(); 88 | this.toExecute = new NoOpWork(); 89 | this.nextOnTrue = new NoOpWork(); 90 | this.nextOnFalse = new NoOpWork(); 91 | } 92 | 93 | /** 94 | * Get a new flow builder 95 | */ 96 | public static newFlow(): ConditionalFlow.Builder { 97 | return new ConditionalFlow.Builder(); 98 | } 99 | 100 | /** 101 | * Set name 102 | * @param name name 103 | */ 104 | public withName(name: string): ConditionalFlow.Builder { 105 | this.name = name; 106 | return this; 107 | } 108 | 109 | /** 110 | * Set work to be execute 111 | * @param work work unit 112 | */ 113 | public withWork(work: Work): ConditionalFlow.Builder { 114 | this.toExecute = work; 115 | return this; 116 | } 117 | 118 | /** 119 | * Set work to be execute if predicate is true 120 | * @param work work unit 121 | */ 122 | public then(work: Work): ConditionalFlow.Builder { 123 | this.nextOnTrue = work; 124 | return this; 125 | } 126 | 127 | /** 128 | * Set work to be execute if predicate is false 129 | * @param work work unit 130 | */ 131 | public otherwise(work: Work): ConditionalFlow.Builder { 132 | this.nextOnFalse = work; 133 | return this; 134 | } 135 | 136 | /** 137 | * Set predicate function 138 | * @param predicate predicate function 139 | */ 140 | public when(predicate: Predicate): ConditionalFlow.Builder { 141 | this.predicate = predicate; 142 | return this; 143 | } 144 | 145 | /** 146 | * Build an instance of ConditionalFlow 147 | */ 148 | public build(): ConditionalFlow { 149 | if (this.predicate) { 150 | return new ConditionalFlow( 151 | this.name, 152 | this.toExecute, 153 | this.nextOnTrue, 154 | this.nextOnFalse, 155 | this.predicate, 156 | ); 157 | } else { 158 | return new ConditionalFlow( 159 | this.name, 160 | this.toExecute, 161 | this.nextOnTrue, 162 | this.nextOnFalse, 163 | ); 164 | } 165 | } 166 | }; 167 | } 168 | 169 | export namespace ConditionalFlow { 170 | export type Builder = typeof ConditionalFlow.Builder.prototype; 171 | } 172 | --------------------------------------------------------------------------------