├── .prettierrc ├── .eslintignore ├── .gitignore ├── .prettierignore ├── tsconfig.build.json ├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── bug-report.md │ └── suggest-a-feature.md ├── dependabot.yml └── workflows │ ├── main.yml │ ├── pages.yml │ └── release.yml ├── src ├── index.ts ├── state.ts ├── __tests__ │ ├── examples │ │ ├── file-upload.spec.ts │ │ └── task-status.spec.ts │ └── fsm.entity.spec.ts └── fsm.entity.ts ├── typedoc.json ├── commitlint.config.js ├── vitest.config.ts ├── tsconfig.json ├── .releaserc ├── LICENSE ├── package.json ├── CHANGELOG.md ├── .eslintrc.js └── README.md /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true 3 | } 4 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | docs -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | npm-debug.log 5 | docs 6 | .idea/ 7 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Add files here to ignore them from prettier formatting 2 | 3 | /dist 4 | /coverage 5 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src" 5 | ], 6 | "exclude": [ 7 | "node_modules", 8 | "test", 9 | "dist", 10 | "**/*.spec.ts", 11 | ] 12 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: ❗️ All other issues 4 | url: https://github.com/fsmoothy/typeorm-fsm/discussions/ 5 | about: | 6 | Please use GitHub Discussions for other issues and asking questions. -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | StateMachine, 3 | StateMachineParameters, 4 | t, 5 | IStateMachine, 6 | Transition, 7 | isStateMachineError, 8 | All, 9 | } from 'fsmoothy'; 10 | export { StateMachineEntity } from './fsm.entity'; 11 | export { state } from './state'; 12 | -------------------------------------------------------------------------------- /typedoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://typedoc.org/schema.json", 3 | "entryPoints": [ 4 | "./src/index.ts", 5 | ], 6 | "plugin": [ 7 | "typedoc-plugin-mermaid", 8 | "typedoc-plugin-extras", 9 | "typedoc-plugin-replace-text" 10 | ], 11 | "replaceText": { 12 | "inIncludedFiles": true, 13 | "replacements": [{ 14 | "pattern": "#(\\w+)", 15 | "replace": "#md:$1" 16 | }] 17 | } 18 | } -------------------------------------------------------------------------------- /src/state.ts: -------------------------------------------------------------------------------- 1 | import { AllowedNames, FsmContext } from 'fsmoothy/types'; 2 | 3 | import { IStateMachineEntityColumnParameters } from './fsm.entity'; 4 | 5 | export const state = < 6 | const State extends AllowedNames, 7 | const Event extends AllowedNames, 8 | Context extends FsmContext = FsmContext, 9 | >( 10 | parameters: IStateMachineEntityColumnParameters, 11 | ) => parameters; 12 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'subject-case': [2, 'always', ['start-case', 'lower-case']], 5 | 'type-enum': [ 6 | 2, 7 | 'always', 8 | [ 9 | 'style', 10 | 'ci', 11 | 'docs', 12 | 'feat', 13 | 'fix', 14 | 'perf', 15 | 'refactor', 16 | 'revert', 17 | 'test', 18 | 'wip', 19 | 'chore', 20 | ], 21 | ], 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import swc from 'unplugin-swc'; 3 | import { defineConfig } from 'vite'; 4 | 5 | export default defineConfig({ 6 | test: { 7 | watch: false, 8 | reporters: ['default'], 9 | coverage: { 10 | exclude: ['src/**/*.d.ts', 'src/**/index.ts'], 11 | include: ['src/**/*.ts'], 12 | reportsDirectory: './coverage', 13 | reporter: ['clover', 'html', 'lcov'], 14 | }, 15 | }, 16 | plugins: [ 17 | swc.vite({ 18 | module: { type: 'es6' }, 19 | }), 20 | ], 21 | }); 22 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # Maintain dependencies for GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | target-branch: "develop" 7 | schedule: 8 | interval: "weekly" 9 | 10 | # Maintain dependencies for npm 11 | - package-ecosystem: "npm" 12 | directory: "/" 13 | schedule: 14 | interval: "weekly" 15 | versioning-strategy: "increase" 16 | target-branch: "develop" 17 | commit-message: 18 | prefix: "fix" 19 | prefix-development: "build" 20 | include: "scope" -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "types": [ 4 | "node" 5 | ], 6 | "module": "commonjs", 7 | "target": "es2022", 8 | "outDir": "./dist", 9 | "baseUrl": "./", 10 | "sourceMap": true, 11 | "esModuleInterop": true, 12 | "experimentalDecorators": true, 13 | "emitDecoratorMetadata": true, 14 | "skipLibCheck": true, 15 | "resolveJsonModule": true, 16 | "declaration": true, 17 | "strict": true, 18 | "strictPropertyInitialization": false, 19 | "importHelpers": true, 20 | }, 21 | "include": [ 22 | "src/**/*", 23 | "./vitest.config.ts" 24 | ] 25 | } -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": [ 3 | "@semantic-release/commit-analyzer", 4 | "@semantic-release/release-notes-generator", 5 | "@semantic-release/changelog", 6 | ["@semantic-release/npm", 7 | { 8 | "pkgRoot": "dist" 9 | } 10 | ], 11 | "@semantic-release/github", 12 | ["@semantic-release/git", { 13 | "assets": [ 14 | "package.json", 15 | "README.md", 16 | "CHANGELOG.md", 17 | "dist/**/*.{js}" 18 | ], 19 | "message": "chore: Release ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 20 | }] 21 | ], 22 | "repositoryUrl": "https://github.com/fsmoothy/typeorm-fsm" 23 | } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🚨 Bug report 3 | about: Report a bug report, to improve this project. 4 | title: 'Bug: ' 5 | labels: 'bug-report' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 16 | 17 | ### Versions 18 | 19 | - Node: 20 | - OS: 21 | 22 | ### Reproduction 23 | 24 |
25 | Additional Details 26 |
27 | 28 | ### Steps to reproduce 29 | 30 | ### What is Expected? 31 | 32 | ### What is actually happening? 33 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | 6 | jobs: 7 | check: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node-version: [16.x, 18.x, 20.x] 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Use Node.js ${{ matrix.node-version }} 15 | uses: actions/setup-node@v3 16 | with: 17 | node-version: ${{ matrix.node-version }} 18 | - name: Install 19 | run: npm ci 20 | env: 21 | CI: true 22 | - name: Run linter 23 | run: npm run lint 24 | - name: Test & publish code coverage 25 | uses: paambaati/codeclimate-action@v5.0.0 26 | env: 27 | CC_TEST_REPORTER_ID: ${{ secrets.CC_TEST_REPORTER_ID }} 28 | with: 29 | coverageCommand: npm run test:cov 30 | debug: true -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/suggest-a-feature.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 🧠 Feature request 3 | about: Suggest an idea or enhancement for this project 4 | title: 'Feature: ' 5 | labels: 'feature-request' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 11 | 12 | ### Is your feature request related to a problem? Please describe 13 | 14 | 15 | 16 | ### Describe the solution you'd like 17 | 18 | 19 | 20 | ### Describe alternatives you've considered 21 | 22 | 23 | 24 | ### Additional context 25 | 26 | -------------------------------------------------------------------------------- /.github/workflows/pages.yml: -------------------------------------------------------------------------------- 1 | name: Deploy docs 2 | 3 | on: 4 | release: 5 | branches: 6 | - master 7 | workflow_dispatch: 8 | branches: 9 | - master 10 | 11 | permissions: 12 | contents: read 13 | pages: write 14 | id-token: write 15 | 16 | concurrency: 17 | group: "pages" 18 | cancel-in-progress: false 19 | 20 | jobs: 21 | build: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - uses: actions/checkout@v3 25 | - name: Use Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: 18 29 | - name: Install 30 | run: npm ci 31 | env: 32 | CI: true 33 | - name: Build Docs 34 | run: npm run docs:build 35 | env: 36 | CI: true 37 | - name: Setup Pages 38 | uses: actions/configure-pages@v3 39 | - name: Upload artifact 40 | uses: actions/upload-pages-artifact@v2 41 | with: 42 | # Upload entire repository 43 | path: './docs' 44 | - name: Deploy to GitHub Pages 45 | id: deployment 46 | uses: actions/deploy-pages@v2 -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2015-2023 bondiano. http://bondiano.io 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 13 | all 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 21 | THE SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | on: 3 | workflow_dispatch: 4 | branches: 5 | - master 6 | 7 | permissions: 8 | contents: read # for checkout 9 | 10 | jobs: 11 | release: 12 | name: Release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write # to be able to publish a GitHub release 16 | issues: write # to be able to comment on released issues 17 | pull-requests: write # to be able to comment on released pull requests 18 | id-token: write # to enable use of OIDC for npm provenance 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v3 22 | with: 23 | fetch-depth: 0 24 | persist-credentials: false 25 | - name: Setup Node.js 26 | uses: actions/setup-node@v3 27 | with: 28 | node-version: "lts/*" 29 | - name: Install dependencies 30 | run: npm clean-install 31 | - name: Verify the integrity of provenance attestations and registry signatures for installed dependencies 32 | run: npm audit signatures 33 | - name: Build 34 | run: npm run build 35 | - name: Release 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | run: npm run release -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeorm-fsm", 3 | "version": "1.8.1", 4 | "author": "Vassiliy Kuzenkov (bondiano)", 5 | "license": "MIT", 6 | "description": "Strong typed state machine for your TypeORM Entities", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/fsmoothy/typeorm-fsm" 10 | }, 11 | "main": "./index.js", 12 | "types": "./index.d.ts", 13 | "keywords": [ 14 | "typeorm", 15 | "fsm", 16 | "state-machine" 17 | ], 18 | "engines": { 19 | "node": ">=18.0.0" 20 | }, 21 | "scripts": { 22 | "prebuild": "npm run clean", 23 | "build": "tsc --project tsconfig.build.json", 24 | "postbuild": "cp -r package.json dist && cp -r README.md dist", 25 | "lint": "eslint .", 26 | "test": "vitest", 27 | "test:watch": "vitest --watch", 28 | "test:cov": "vitest --coverage", 29 | "commit": "cz", 30 | "docs:build": "typedoc src/index.ts --tsconfig tsconfig.build.json", 31 | "release": "semantic-release", 32 | "clean": "rm -rf dist", 33 | "postversion": "cp -r package.json .." 34 | }, 35 | "devDependencies": { 36 | "@commitlint/cli": "^18.4.3", 37 | "@commitlint/config-conventional": "^18.4.3", 38 | "@semantic-release/changelog": "^6.0.3", 39 | "@semantic-release/git": "^10.0.1", 40 | "@swc/core": "^1.3.101", 41 | "@types/better-sqlite3": "^7.6.8", 42 | "@typescript-eslint/eslint-plugin": "^6.16.0", 43 | "@typescript-eslint/parser": "^6.16.0", 44 | "@vitest/coverage-v8": "^1.1.0", 45 | "better-sqlite3": "^8.7.0", 46 | "cz-conventional-changelog": "^3.3.0", 47 | "eslint": "^8.56.0", 48 | "eslint-config-prettier": "^9.1.0", 49 | "eslint-import-resolver-node": "^0.3.9", 50 | "eslint-import-resolver-typescript": "^3.6.1", 51 | "eslint-plugin-import": "^2.29.1", 52 | "eslint-plugin-lodash": "^7.4.0", 53 | "eslint-plugin-node": "^11.1.0", 54 | "eslint-plugin-prettier": "^5.1.2", 55 | "eslint-plugin-unicorn": "^50.0.1", 56 | "prettier": "^3.1.1", 57 | "semantic-release": "^22.0.12", 58 | "typedoc": "^0.25.4", 59 | "typedoc-plugin-extras": "^3.0.0", 60 | "typedoc-plugin-mermaid": "^1.10.0", 61 | "typedoc-plugin-replace-text": "^3.2.0", 62 | "typescript": "^5.3.3", 63 | "unplugin-swc": "^1.4.4", 64 | "vitest": "^1.1.0" 65 | }, 66 | "peerDependencies": { 67 | "typeorm": "^0.3.17" 68 | }, 69 | "commit": "cz", 70 | "config": { 71 | "commitizen": { 72 | "path": "./node_modules/cz-conventional-changelog" 73 | } 74 | }, 75 | "dependencies": { 76 | "fsmoothy": "1.9.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/__tests__/examples/file-upload.spec.ts: -------------------------------------------------------------------------------- 1 | import { Column, DataSource, Entity, PrimaryGeneratedColumn } from 'typeorm'; 2 | import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest'; 3 | 4 | import { StateMachineEntity, t, state } from '../..'; 5 | 6 | /** 7 | * First, user submits a file to the server. 8 | * We're uploading a file to S3 bucket and want to track its state. 9 | */ 10 | 11 | enum FileState { 12 | pending = 'pending', 13 | uploading = 'uploading', 14 | completed = 'completed', 15 | failed = 'failed', 16 | } 17 | 18 | enum FileEvent { 19 | start = 'start', 20 | finish = 'finish', 21 | fail = 'fail', 22 | } 23 | 24 | @Entity('file') 25 | class File extends StateMachineEntity({ 26 | status: state({ 27 | id: 'fileStatus', 28 | initial: FileState.pending, 29 | transitions: [ 30 | t(FileState.pending, FileEvent.start, FileState.uploading), 31 | t(FileState.uploading, FileEvent.finish, FileState.completed, { 32 | async guard(this: File, _context, url: string) { 33 | const hasTheSameUrl = (this.url !== url) as boolean; 34 | 35 | return hasTheSameUrl; 36 | }, 37 | async onEnter(this: File, _context, url: string | null) { 38 | this.url = url; 39 | }, 40 | }), 41 | t( 42 | [FileState.pending, FileState.uploading], 43 | FileEvent.fail, 44 | FileState.failed, 45 | ), 46 | ], 47 | }), 48 | }) { 49 | @PrimaryGeneratedColumn() 50 | id: string; 51 | 52 | @Column({ nullable: true, type: 'varchar' }) 53 | url: string | null; 54 | } 55 | 56 | describe('File upload', () => { 57 | let dataSource: DataSource; 58 | 59 | beforeAll(async () => { 60 | dataSource = new DataSource({ 61 | name: (Date.now() * Math.random()).toString(16), 62 | database: ':memory:', 63 | dropSchema: true, 64 | entities: [File], 65 | logging: ['error', 'warn'], 66 | synchronize: true, 67 | type: 'better-sqlite3', 68 | }); 69 | 70 | await dataSource.initialize(); 71 | await dataSource.synchronize(); 72 | }); 73 | 74 | afterAll(async () => { 75 | await dataSource.dropDatabase(); 76 | await dataSource.destroy(); 77 | }); 78 | 79 | afterEach(async () => { 80 | await dataSource.manager.clear(File); 81 | }); 82 | 83 | const findFileById = async (id: string) => { 84 | return await dataSource.manager.findOneOrFail(File, { 85 | where: { 86 | id, 87 | }, 88 | }); 89 | }; 90 | 91 | it('should change state', async () => { 92 | const file = new File(); 93 | await file.save(); 94 | 95 | expect(file.fsm.status.isPending()).toBe(true); 96 | 97 | await file.fsm.status.start(); 98 | expect(file.fsm.status.isUploading()).toBe(true); 99 | 100 | const savedFile = await findFileById(file.id); 101 | 102 | expect(savedFile).toEqual( 103 | expect.objectContaining({ status: FileState.uploading }), 104 | ); 105 | 106 | await file.fsm.status.finish('https://example.com'); 107 | expect(file.fsm.status.isCompleted()).toBe(true); 108 | expect(await findFileById(file.id)).toEqual( 109 | expect.objectContaining({ 110 | status: FileState.completed, 111 | url: 'https://example.com', 112 | }), 113 | ); 114 | }); 115 | 116 | it('should bulk update to different state', async () => { 117 | const file1 = await dataSource.manager 118 | .create(File, { 119 | status: FileState.pending, 120 | }) 121 | .save(); 122 | const file2 = await dataSource.manager 123 | .create(File, { 124 | status: FileState.uploading, 125 | }) 126 | .save(); 127 | 128 | const filesToUpdate = [ 129 | { 130 | file: file1, 131 | event: FileEvent.start, 132 | }, 133 | { 134 | file: file2, 135 | event: FileEvent.fail, 136 | }, 137 | ]; 138 | 139 | await Promise.all( 140 | filesToUpdate.map(({ file, event }) => file.fsm.status.transition(event)), 141 | ); 142 | 143 | expect(file1.fsm.status.isUploading()).toBe(true); 144 | expect(file2.fsm.status.isFailed()).toBe(true); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [1.8.1](https://github.com/fsmoothy/typeorm-fsm/compare/v1.8.0...v1.8.1) (2023-12-30) 2 | 3 | 4 | ### Bug Fixes 5 | 6 | * migrate repo to maintenance mode ([f13c044](https://github.com/fsmoothy/typeorm-fsm/commit/f13c044a921b2560235c7fe05e320b88abe2d399)) 7 | 8 | # [1.8.0](https://github.com/fsmoothy/typeorm-fsm/compare/v1.7.0...v1.8.0) (2023-12-27) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * conflict entity on bulk update ([8a3601c](https://github.com/fsmoothy/typeorm-fsm/commit/8a3601c35c1a48ba9fd741abe4693ba9084450fb)) 14 | * update package lock ([3077378](https://github.com/fsmoothy/typeorm-fsm/commit/30773789e584b03d0c6c81966d6aa73b702e30ff)) 15 | 16 | 17 | ### Features 18 | 19 | * update fsmoothy version ([78262df](https://github.com/fsmoothy/typeorm-fsm/commit/78262df367e175bf543d1d148a561d6c09d9d6db)) 20 | * update packages ([6b23fee](https://github.com/fsmoothy/typeorm-fsm/commit/6b23fee86d91bc2f5353b14ccdc331a801657f6f)) 21 | 22 | # [1.7.0](https://github.com/fsmoothy/typeorm-fsm/compare/v1.6.1...v1.7.0) (2023-10-17) 23 | 24 | 25 | ### Features 26 | 27 | * update data from context usage ([bf550d5](https://github.com/fsmoothy/typeorm-fsm/commit/bf550d518f2f0693cfa7bd9894cc3ae41dfe549a)) 28 | * upgrade to new fsmoothy version ([2147e4a](https://github.com/fsmoothy/typeorm-fsm/commit/2147e4abc0a3beca7436335249515e71f0064ba2)) 29 | 30 | ## [1.6.1](https://github.com/fsmoothy/typeorm-fsm/compare/v1.6.0...v1.6.1) (2023-09-29) 31 | 32 | 33 | ### Bug Fixes 34 | 35 | * add missed reexports ([b8a0508](https://github.com/fsmoothy/typeorm-fsm/commit/b8a050815b9ccbe100d7da2137117503a961f341)) 36 | 37 | # [1.6.0](https://github.com/fsmoothy/typeorm-fsm/compare/v1.5.0...v1.6.0) (2023-09-28) 38 | 39 | 40 | ### Features 41 | 42 | * don't allow nested state ([f350c20](https://github.com/fsmoothy/typeorm-fsm/commit/f350c209a97c83e15fdd378c37db0cccb984b108)) 43 | * update fsmoothy version ([af1919b](https://github.com/fsmoothy/typeorm-fsm/commit/af1919b81ba787f3d64ba57142505312f2087c6a)) 44 | * update fsmoothy version ([fcbcfb1](https://github.com/fsmoothy/typeorm-fsm/commit/fcbcfb11bc77a477d4d19eecfcd27da6500de43f)) 45 | 46 | # [1.5.0](https://github.com/fsmoothy/typeorm-fsm/compare/v1.4.0...v1.5.0) (2023-09-23) 47 | 48 | 49 | ### Features 50 | 51 | * add state helper ([507432a](https://github.com/fsmoothy/typeorm-fsm/commit/507432a85582837b206890e511fb01834c3ba2e4)) 52 | * migrate to new bind method ([d485c56](https://github.com/fsmoothy/typeorm-fsm/commit/d485c56d3bd8de977f9e3394b6a23eb9c6ef7325)) 53 | * update fsmoothy core package ([b6958a1](https://github.com/fsmoothy/typeorm-fsm/commit/b6958a1fd47312b66667fd952f64904c9d9ec7cd)) 54 | * update fsmoothy version ([890cd2f](https://github.com/fsmoothy/typeorm-fsm/commit/890cd2f1194093968c89b1471cfcf56d3c5f8fba)) 55 | 56 | # [1.4.0](https://github.com/fsmoothy/typeorm-fsm/compare/v1.3.0...v1.4.0) (2023-09-03) 57 | 58 | 59 | ### Bug Fixes 60 | 61 | * use entity instad of context ([4adf25d](https://github.com/fsmoothy/typeorm-fsm/commit/4adf25d40730d1ad0b781305676ff6c46ad1b5d5)) 62 | 63 | 64 | ### Features 65 | 66 | * migrate to fsmoothy package ([ec70a0f](https://github.com/fsmoothy/typeorm-fsm/commit/ec70a0fd0ea2cfec68ec54f239f267e79dffb5d0)) 67 | 68 | # [1.3.0](https://github.com/bondiano/typeorm-fsm/compare/v1.2.0...v1.3.0) (2023-09-01) 69 | 70 | 71 | ### Features 72 | 73 | * allow to extend custom base entity ([2bce92a](https://github.com/bondiano/typeorm-fsm/commit/2bce92a9cee6022a143946033731c671dadb3a8f)) 74 | * move fsm to corresponding property in instance ([658198e](https://github.com/bondiano/typeorm-fsm/commit/658198e9511b10a3a5bf3a42ef5359f5f962abff)) 75 | 76 | # [1.2.0](https://github.com/bondiano/typeorm-fsm/compare/v1.1.3...v1.2.0) (2023-08-31) 77 | 78 | 79 | ### Features 80 | 81 | * add useful warning on duplicate transition ([d91168a](https://github.com/bondiano/typeorm-fsm/commit/d91168a6cc52cf016942d4405a16db4337699b7c)) 82 | * allow chain subscription ([4d5b662](https://github.com/bondiano/typeorm-fsm/commit/4d5b662108bb3993e051d81dbb1beaccf5aff057)) 83 | * make possible to pass from as array ([43f319f](https://github.com/bondiano/typeorm-fsm/commit/43f319ff41a781d38754202c675baa0ddb1983bc)) 84 | * remove almost useless isFinal method ([86bc9a3](https://github.com/bondiano/typeorm-fsm/commit/86bc9a35549d6cf714b38b94aaa27c8dc72adaf7)) 85 | 86 | ## [1.1.3](https://github.com/bondiano/typeorm-fsm/compare/v1.1.2...v1.1.3) (2023-08-30) 87 | 88 | 89 | ### Bug Fixes 90 | 91 | * update semantic-release config ([be539cb](https://github.com/bondiano/typeorm-fsm/commit/be539cb0ab1f81098a84962d7f800d7486264284)) 92 | 93 | # [1.1.0](https://github.com/bondiano/typeorm-fsm/compare/v1.0.0...v1.1.0) (2023-08-29) 94 | 95 | 96 | ### Features 97 | 98 | * add can methods to check is transition available ([86c9b2a](https://github.com/bondiano/typeorm-fsm/commit/86c9b2aa7759b67de776f3481ad1b817e35560bc)) 99 | * bind entity to this in subscribers ([7b8d088](https://github.com/bondiano/typeorm-fsm/commit/7b8d088fcd9bd377b97891123684b69211b0ebde)) 100 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | 3 | const OFF = 'off'; 4 | const ERROR = 'error'; 5 | const WARN = 'warn'; 6 | 7 | /** @type {import('eslint').ESLint.ConfigData} */ 8 | module.exports = { 9 | root: true, 10 | env: { 11 | node: true, 12 | }, 13 | settings: { 14 | 'import/resolver': { 15 | typescript: { 16 | alwaysTryTypes: true, 17 | }, 18 | }, 19 | }, 20 | plugins: ['import'], 21 | extends: [ 22 | 'plugin:node/recommended', 23 | 'plugin:prettier/recommended', 24 | 'plugin:unicorn/recommended', 25 | 'plugin:import/recommended', 26 | ], 27 | rules: { 28 | 'no-console': WARN, 29 | 'no-debugger': WARN, 30 | 'max-len': [ 31 | ERROR, 32 | 120, 33 | 4, 34 | { 35 | ignoreComments: true, 36 | ignoreUrls: true, 37 | ignoreStrings: true, 38 | ignoreTemplateLiterals: true, 39 | ignoreRegExpLiterals: true, 40 | }, 41 | ], 42 | curly: ERROR, 43 | 'no-implicit-coercion': ERROR, 44 | 'no-else-return': ERROR, 45 | 'no-duplicate-imports': [ERROR, { includeExports: true }], 46 | 'import/first': ERROR, 47 | 'import/no-mutable-exports': ERROR, 48 | 'import/no-self-import': ERROR, 49 | 'import/no-named-default': ERROR, 50 | 'import/no-relative-packages': ERROR, 51 | 'import/no-unresolved': OFF, 52 | 'import/order': [ 53 | ERROR, 54 | { 55 | 'newlines-between': 'always', 56 | pathGroups: [ 57 | { pattern: '@nestjs/**', group: 'builtin', position: 'after' }, 58 | ], 59 | groups: [ 60 | ['builtin', 'external'], 61 | 'internal', 62 | 'parent', 63 | 'sibling', 64 | 'type', 65 | 'index', 66 | 'object', 67 | ], 68 | pathGroupsExcludedImportTypes: ['builtin'], 69 | alphabetize: { 70 | order: 'asc', 71 | caseInsensitive: false, 72 | }, 73 | }, 74 | ], 75 | 'import/no-cycle': OFF, 76 | 'node/no-unsupported-features/es-syntax': OFF, 77 | 'node/no-missing-import': OFF, 78 | 'node/no-unpublished-import': OFF, 79 | 'node/no-extraneous-import': OFF, 80 | 'sonarjs/no-duplicate-string': OFF, 81 | 'unicorn/prefer-ternary': OFF, 82 | 'unicorn/prefer-top-level-await': OFF, 83 | 'unicorn/no-array-reduce': OFF, 84 | 'unicorn/no-null': OFF, 85 | 'unicorn/no-useless-undefined': [ERROR, { checkArguments: false }], 86 | 'unicorn/prevent-abbreviations': [ 87 | ERROR, 88 | { 89 | ignore: ['e2e'], 90 | }, 91 | ], 92 | }, 93 | overrides: [ 94 | { 95 | parser: '@typescript-eslint/parser', 96 | files: ['*.ts', '*.tsx'], 97 | plugins: ['@typescript-eslint/eslint-plugin'], 98 | extends: [ 99 | 'plugin:@typescript-eslint/recommended', 100 | 'plugin:import/typescript', 101 | ], 102 | parserOptions: { 103 | ecmaVersion: 2022, 104 | project: 'tsconfig.json', 105 | tsconfigRootDir: __dirname, 106 | sourceType: 'module', 107 | }, 108 | rules: { 109 | '@typescript-eslint/no-empty-function': [ 110 | ERROR, 111 | { 112 | allow: ['arrowFunctions'], 113 | }, 114 | ], 115 | '@typescript-eslint/no-explicit-any': OFF, 116 | '@typescript-eslint/no-unused-vars': [ 117 | ERROR, 118 | { 119 | vars: 'local', 120 | ignoreRestSiblings: false, 121 | argsIgnorePattern: '^_', 122 | }, 123 | ], 124 | '@typescript-eslint/ban-ts-comment': [ 125 | 'error', 126 | { 127 | 'ts-expect-error': 'allow-with-description', 128 | 'ts-ignore': true, 129 | 'ts-nocheck': true, 130 | 'ts-check': false, 131 | minimumDescriptionLength: 5, 132 | }, 133 | ], 134 | '@typescript-eslint/member-ordering': [ 135 | ERROR, 136 | { 137 | default: [ 138 | 'static-field', 139 | 'static-get', 140 | 'static-set', 141 | 'static-method', 142 | 'protected-decorated-field', 143 | 'private-decorated-field', 144 | 'public-decorated-field', 145 | 'protected-instance-field', 146 | 'private-instance-field', 147 | 'public-instance-field', 148 | 'constructor', 149 | 'instance-field', 150 | 'abstract-field', 151 | 'instance-get', 152 | 'abstract-get', 153 | 'instance-set', 154 | 'abstract-set', 155 | 'instance-method', 156 | 'protected-instance-method', 157 | 'private-instance-method', 158 | 'abstract-method', 159 | ], 160 | }, 161 | ], 162 | '@typescript-eslint/array-type': [ 163 | ERROR, 164 | { default: 'generic', readonly: 'generic' }, 165 | ], 166 | '@typescript-eslint/no-base-to-string': ERROR, 167 | '@typescript-eslint/prefer-regexp-exec': ERROR, 168 | '@typescript-eslint/consistent-generic-constructors': ERROR, 169 | '@typescript-eslint/prefer-nullish-coalescing': ERROR, 170 | '@typescript-eslint/prefer-optional-chain': ERROR, 171 | 'no-return-await': OFF, 172 | '@typescript-eslint/return-await': [ERROR, 'always'], 173 | }, 174 | }, 175 | { 176 | files: ['*rc.js', '*.config.js'], 177 | rules: { 178 | 'unicorn/prefer-module': OFF, 179 | }, 180 | }, 181 | ], 182 | }; 183 | -------------------------------------------------------------------------------- /src/__tests__/examples/task-status.spec.ts: -------------------------------------------------------------------------------- 1 | import { FsmContext } from 'fsmoothy/types'; 2 | import { 3 | Entity, 4 | PrimaryGeneratedColumn, 5 | BaseEntity, 6 | Column, 7 | DataSource, 8 | OneToMany, 9 | JoinColumn, 10 | ManyToOne, 11 | QueryRunner, 12 | } from 'typeorm'; 13 | import { describe, it, expect, afterAll, afterEach, beforeAll } from 'vitest'; 14 | 15 | import { StateMachineEntity, state, t } from '../..'; 16 | 17 | const fakeDate = new Date('2020-01-01'); 18 | 19 | const enum TaskState { 20 | Inactive = 'inactive', 21 | Active = 'active', 22 | Completed = 'completed', 23 | } 24 | 25 | const enum TaskEvent { 26 | Activate = 'activate', 27 | Complete = 'complete', 28 | } 29 | 30 | interface ITask { 31 | id: number; 32 | title: string; 33 | tags: Array; 34 | completedAt?: Date; 35 | } 36 | 37 | interface ITag { 38 | id: number; 39 | name: string; 40 | } 41 | 42 | interface ITaskContext extends FsmContext { 43 | qr: QueryRunner; 44 | } 45 | 46 | const activate = t( 47 | TaskState.Inactive, 48 | TaskEvent.Activate, 49 | TaskState.Active, 50 | { 51 | async onEnter(this: ITask, context, tags: Array) { 52 | this.tags = await Promise.all( 53 | tags.map(async (tag) => { 54 | const newTag = context.qr.manager.create(Tag, tag); 55 | return await context.qr.manager.save(Tag, newTag); 56 | }), 57 | ); 58 | }, 59 | async onExit(this: ITask, context) { 60 | await context.qr.manager.save(Task, this); 61 | }, 62 | }, 63 | ); 64 | 65 | const complete = t( 66 | TaskState.Active, 67 | TaskEvent.Complete, 68 | TaskState.Completed, 69 | { 70 | onEnter(this: ITask) { 71 | this.completedAt = fakeDate; 72 | }, 73 | async onExit(this: ITask, context) { 74 | for (const tag of this.tags) { 75 | tag.name = tag.name.toUpperCase() + '-completed'; 76 | await context.qr.manager.save(Tag, tag); 77 | } 78 | 79 | await context.qr.manager.save(Task, this); 80 | }, 81 | }, 82 | ); 83 | 84 | @Entity() 85 | class Task 86 | extends StateMachineEntity({ 87 | status: state({ 88 | initial: TaskState.Inactive, 89 | saveAfterTransition: false, 90 | transitions: [activate, complete], 91 | }), 92 | }) 93 | implements ITask 94 | { 95 | @PrimaryGeneratedColumn() 96 | id: number; 97 | 98 | @Column() 99 | title: string; 100 | 101 | @OneToMany(() => Tag, (tag) => tag.task, { 102 | eager: true, 103 | }) 104 | @JoinColumn({ name: 'tag_id' }) 105 | tags: Array; 106 | 107 | @Column({ nullable: true }) 108 | completedAt?: Date; 109 | } 110 | 111 | @Entity() 112 | class Tag extends BaseEntity implements ITag { 113 | @PrimaryGeneratedColumn() 114 | id: number; 115 | 116 | @Column() 117 | name: string; 118 | 119 | @ManyToOne(() => Task, (task) => task.id) 120 | task: Task; 121 | } 122 | 123 | describe('Task Status', () => { 124 | let dataSource: DataSource; 125 | 126 | beforeAll(async () => { 127 | dataSource = new DataSource({ 128 | name: (Date.now() * Math.random()).toString(16), 129 | database: ':memory:', 130 | dropSchema: true, 131 | entities: [Tag, Task], 132 | logging: ['error', 'warn'], 133 | synchronize: true, 134 | type: 'better-sqlite3', 135 | }); 136 | 137 | await dataSource.initialize(); 138 | await dataSource.synchronize(); 139 | }); 140 | 141 | afterAll(async () => { 142 | await dataSource.dropDatabase(); 143 | await dataSource.destroy(); 144 | }); 145 | 146 | afterEach(async () => { 147 | await dataSource.manager.clear(Tag); 148 | await dataSource.manager.clear(Task); 149 | }); 150 | 151 | it('should be able to pass user flow', async () => { 152 | const task = new Task(); 153 | task.title = 'My Task'; 154 | await task.save(); 155 | 156 | const queryRunner = dataSource.createQueryRunner(); 157 | task.fsm.status.inject('qr', queryRunner); 158 | 159 | await queryRunner.startTransaction(); 160 | await task.fsm.status.activate([ 161 | { 162 | name: 'Tag One', 163 | }, 164 | { 165 | name: 'Tag Two', 166 | }, 167 | ]); 168 | 169 | expect(task.status).toBe(TaskState.Active); 170 | 171 | await task.fsm.status.complete(); 172 | await queryRunner.commitTransaction(); 173 | 174 | const taskFromDatabase = await dataSource.manager.findOneByOrFail(Task, { 175 | id: task.id, 176 | }); 177 | expect(taskFromDatabase.status).toBe(TaskState.Completed); 178 | expect(taskFromDatabase.tags).toEqual( 179 | expect.arrayContaining([ 180 | expect.objectContaining({ 181 | name: 'TAG ONE-completed', 182 | }), 183 | expect.objectContaining({ 184 | name: 'TAG TWO-completed', 185 | }), 186 | ]), 187 | ); 188 | }); 189 | 190 | it('should bulk update to different state', async () => { 191 | const task1 = await dataSource.manager 192 | .create(Task, { 193 | title: 'My Task 1', 194 | }) 195 | .save(); 196 | const task2 = await dataSource.manager 197 | .create(Task, { 198 | title: 'My Task 2', 199 | status: TaskState.Active, 200 | tags: [], 201 | }) 202 | .save(); 203 | 204 | const tasksToUpdate = [ 205 | { 206 | task: task1, 207 | event: TaskEvent.Activate, 208 | }, 209 | { task: task2, event: TaskEvent.Complete }, 210 | ]; 211 | 212 | const queryRunner = dataSource.createQueryRunner(); 213 | for (const { task } of tasksToUpdate) { 214 | task.fsm.status.inject('qr', queryRunner); 215 | } 216 | 217 | await queryRunner.startTransaction(); 218 | await Promise.all( 219 | tasksToUpdate.map(({ task, event }) => 220 | task.fsm.status.transition(event, [ 221 | { 222 | name: 'Tag One', 223 | }, 224 | { 225 | name: 'Tag Two', 226 | }, 227 | ]), 228 | ), 229 | ); 230 | 231 | await queryRunner.commitTransaction(); 232 | 233 | const updatedTask1 = await dataSource.manager.findOneByOrFail(Task, { 234 | id: task1.id, 235 | }); 236 | 237 | expect(updatedTask1.status).toBe(TaskState.Active); 238 | 239 | const updatedTask2 = await dataSource.manager.findOneByOrFail(Task, { 240 | id: task2.id, 241 | }); 242 | 243 | expect(updatedTask2.status).toBe(TaskState.Completed); 244 | }); 245 | }); 246 | -------------------------------------------------------------------------------- /src/__tests__/fsm.entity.spec.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Column, 3 | DataSource, 4 | Entity, 5 | PrimaryGeneratedColumn, 6 | BaseEntity as TypeOrmBaseEntity, 7 | } from 'typeorm'; 8 | import { 9 | describe, 10 | expect, 11 | it, 12 | vi, 13 | afterEach, 14 | beforeAll, 15 | afterAll, 16 | } from 'vitest'; 17 | 18 | import { StateMachineEntity, state, t } from '../'; 19 | 20 | enum OrderState { 21 | draft = 'draft', 22 | pending = 'pending', 23 | paid = 'paid', 24 | shipped = 'shipped', 25 | completed = 'completed', 26 | } 27 | enum OrderEvent { 28 | create = 'create', 29 | pay = 'pay', 30 | ship = 'ship', 31 | complete = 'complete', 32 | } 33 | 34 | enum OrderItemState { 35 | draft = 'draft', 36 | assembly = 'assembly', 37 | warehouse = 'warehouse', 38 | shipping = 'shipping', 39 | delivered = 'delivered', 40 | } 41 | enum OrderItemEvent { 42 | create = 'create', 43 | assemble = 'assemble', 44 | transfer = 'transfer', 45 | ship = 'ship', 46 | deliver = 'deliver', 47 | } 48 | 49 | interface IOrderItemContext { 50 | place: string; 51 | } 52 | 53 | class BaseEntity extends TypeOrmBaseEntity { 54 | @PrimaryGeneratedColumn() 55 | id: string; 56 | } 57 | 58 | @Entity('order') 59 | class Order extends StateMachineEntity( 60 | { 61 | status: state({ 62 | id: 'orderStatus', 63 | initial: OrderState.draft, 64 | transitions: [ 65 | t(OrderState.draft, OrderEvent.create, OrderState.pending), 66 | t(OrderState.pending, OrderEvent.pay, OrderState.paid), 67 | t(OrderState.paid, OrderEvent.ship, OrderState.shipped), 68 | t(OrderState.shipped, OrderEvent.complete, OrderState.completed), 69 | ], 70 | }), 71 | itemsStatus: state({ 72 | id: 'orderItemsStatus', 73 | initial: OrderItemState.draft, 74 | persistContext: true, 75 | data() { 76 | return { 77 | place: 'My warehouse', 78 | }; 79 | }, 80 | transitions: [ 81 | t(OrderItemState.draft, OrderItemEvent.create, OrderItemState.assembly), 82 | t( 83 | OrderItemState.assembly, 84 | OrderItemEvent.assemble, 85 | OrderItemState.warehouse, 86 | ), 87 | { 88 | from: OrderItemState.warehouse, 89 | event: OrderItemEvent.transfer, 90 | to: OrderItemState.warehouse, 91 | guard(context, place: string) { 92 | return context.place !== place; 93 | }, 94 | onExit(context, place: string) { 95 | context.place = place; 96 | }, 97 | }, 98 | t( 99 | [OrderItemState.assembly, OrderItemState.warehouse], 100 | OrderItemEvent.ship, 101 | OrderItemState.shipping, 102 | ), 103 | t( 104 | OrderItemState.shipping, 105 | OrderItemEvent.deliver, 106 | OrderItemState.delivered, 107 | ), 108 | ], 109 | }), 110 | }, 111 | BaseEntity, 112 | ) { 113 | @Column({ 114 | default: 0, 115 | }) 116 | price: number; 117 | } 118 | 119 | describe('StateMachineEntity', () => { 120 | let dataSource: DataSource; 121 | 122 | beforeAll(async () => { 123 | dataSource = new DataSource({ 124 | name: (Date.now() * Math.random()).toString(16), 125 | database: ':memory:', 126 | dropSchema: true, 127 | entities: [Order], 128 | logging: ['error', 'warn'], 129 | synchronize: true, 130 | type: 'better-sqlite3', 131 | }); 132 | 133 | await dataSource.initialize(); 134 | await dataSource.synchronize(); 135 | }); 136 | 137 | afterAll(async () => { 138 | await dataSource.dropDatabase(); 139 | await dataSource.destroy(); 140 | }); 141 | 142 | afterEach(async () => { 143 | await dataSource.manager.clear(Order); 144 | }); 145 | 146 | it('should be able to create a new entity with default state', async () => { 147 | const order = new Order(); 148 | 149 | await order.save(); 150 | 151 | expect(order).toBeDefined(); 152 | expect(order.fsm.status.isDraft()).toBe(true); 153 | expect(order.fsm.itemsStatus.isDraft()).toBe(true); 154 | expect(order.status).toBe(OrderState.draft); 155 | expect(order.itemsStatus).toBe(OrderItemState.draft); 156 | }); 157 | 158 | it('state should change after event', async () => { 159 | const order = new Order(); 160 | await order.save(); 161 | 162 | await order.fsm.status.create(); 163 | 164 | expect(order.fsm.status.isPending()).toBe(true); 165 | 166 | const orderFromDatabase = await dataSource.manager.findOneOrFail(Order, { 167 | where: { 168 | id: order.id, 169 | }, 170 | }); 171 | 172 | expect(orderFromDatabase.fsm.status.current).toBe(OrderState.pending); 173 | }); 174 | 175 | it('should be able to pass correct contexts (this and ctx) to subscribers', async () => { 176 | const order = new Order(); 177 | await order.save(); 178 | 179 | let handlerContext!: Order; 180 | const handler = vi.fn().mockImplementation(function ( 181 | this: Order, 182 | _context: IOrderItemContext, 183 | ) { 184 | // eslint-disable-next-line @typescript-eslint/no-this-alias, unicorn/no-this-assignment 185 | handlerContext = this; 186 | }); 187 | 188 | order.fsm.itemsStatus.on(OrderItemEvent.create, handler); 189 | 190 | await order.fsm.itemsStatus.create(); 191 | 192 | expect(handlerContext).toBeInstanceOf(Order); 193 | expect(handler).toBeCalledTimes(1); 194 | expect(handler).toBeCalledWith({ data: { place: 'My warehouse' } }); 195 | }); 196 | 197 | it('should throw error when transition is not possible', async () => { 198 | const order = new Order(); 199 | await order.save(); 200 | 201 | await expect(order.fsm.status.pay()).rejects.toThrowError(); 202 | }); 203 | 204 | it('should throw error when transition guard is not passed', async () => { 205 | const order = new Order(); 206 | await order.save(); 207 | 208 | await order.fsm.itemsStatus.create(); 209 | await order.fsm.itemsStatus.assemble(); 210 | 211 | await order.fsm.itemsStatus.transfer('John warehouse'); 212 | 213 | await expect( 214 | order.fsm.itemsStatus.transfer('John warehouse'), 215 | ).rejects.toThrowError(); 216 | }); 217 | 218 | it('should work with repositories', async () => { 219 | const orderRepository = dataSource.manager.getRepository(Order); 220 | const order = orderRepository.create(); 221 | await orderRepository.save(order); 222 | 223 | await order.fsm.status.create(); 224 | const orderFromDatabase = await orderRepository.findOneOrFail({ 225 | where: { 226 | id: order.id, 227 | }, 228 | }); 229 | 230 | expect(orderFromDatabase.fsm.status.current).toBe(OrderState.pending); 231 | }); 232 | }); 233 | -------------------------------------------------------------------------------- /src/fsm.entity.ts: -------------------------------------------------------------------------------- 1 | import { StateMachineParameters, StateMachine, IStateMachine } from 'fsmoothy'; 2 | import { AllowedNames, FsmContext } from 'fsmoothy/types'; 3 | import { BaseEntity, Column, getMetadataArgsStorage } from 'typeorm'; 4 | 5 | export interface IStateMachineEntityColumnParameters< 6 | State extends AllowedNames, 7 | Event extends AllowedNames, 8 | Context extends FsmContext = FsmContext, 9 | > extends Omit, 'states'> { 10 | persistContext?: boolean; 11 | /** 12 | * @default true 13 | */ 14 | saveAfterTransition?: boolean; 15 | } 16 | 17 | type ExtractState< 18 | Parameters extends object, 19 | Column extends keyof Parameters, 20 | > = Parameters[Column] extends IStateMachineEntityColumnParameters< 21 | infer State, 22 | any, 23 | any 24 | > 25 | ? State extends AllowedNames 26 | ? State 27 | : never 28 | : never; 29 | 30 | type ExtractEvent< 31 | Parameters extends object, 32 | Column extends keyof Parameters, 33 | > = Parameters[Column] extends IStateMachineEntityColumnParameters< 34 | any, 35 | infer Event, 36 | any 37 | > 38 | ? Event extends AllowedNames 39 | ? Event 40 | : never 41 | : never; 42 | 43 | type ExtractContext< 44 | Parameters extends object, 45 | Column extends keyof Parameters, 46 | > = Parameters[Column] extends IStateMachineEntityColumnParameters< 47 | any, 48 | any, 49 | infer Context 50 | > 51 | ? Context extends object 52 | ? Context 53 | : never 54 | : never; 55 | 56 | type BaseStateMachineEntity< 57 | State extends AllowedNames, 58 | Event extends AllowedNames, 59 | Context extends FsmContext = FsmContext, 60 | Column extends string = string, 61 | > = BaseEntity & { 62 | [key: string]: unknown; 63 | } & { 64 | fsm: { 65 | [column in Column]: IStateMachine; 66 | }; 67 | }; 68 | 69 | const buildAfterLoadMethodName = (column: string) => 70 | `__${column}FSM__afterLoad` as const; 71 | 72 | const buildContextColumnName = (column: string) => 73 | `__${column}FSM__context` as const; 74 | 75 | function initializeStateMachine< 76 | const State extends AllowedNames, 77 | const Event extends AllowedNames, 78 | Context extends FsmContext = FsmContext, 79 | const Column extends string = string, 80 | >( 81 | this: BaseStateMachineEntity, 82 | column: Column, 83 | parameters: IStateMachineEntityColumnParameters, 84 | ) { 85 | const { 86 | persistContext, 87 | saveAfterTransition = true, 88 | transitions, 89 | data, 90 | } = parameters; 91 | // @ts-expect-error - readonly property 92 | parameters.transitions = transitions?.map(function (transition) { 93 | return { 94 | ...transition, 95 | async onExit( 96 | this: BaseStateMachineEntity, 97 | context: Context, 98 | ...arguments_: Array 99 | ) { 100 | this[column] = transition.to; 101 | 102 | await transition.onExit?.call(this, context, ...arguments_); 103 | 104 | if (persistContext) { 105 | this[buildContextColumnName(column)] = JSON.stringify(context.data); 106 | } 107 | 108 | if (saveAfterTransition) { 109 | await this.save(); 110 | } 111 | }, 112 | }; 113 | }); 114 | 115 | let _data = typeof data === 'string' ? JSON.parse(data) : data; 116 | 117 | if ( 118 | persistContext && 119 | Object.keys(this[buildContextColumnName(column)] as object).length > 0 120 | ) { 121 | _data = this[buildContextColumnName(column)]; 122 | } 123 | 124 | if (typeof _data !== 'function') { 125 | _data = () => _data; 126 | } 127 | 128 | this.fsm[column] = new StateMachine({ 129 | ...parameters, 130 | initial: this[column] as State, 131 | data, 132 | }); 133 | 134 | this.fsm[column].bind(this); 135 | } 136 | 137 | /** 138 | * Mixin to extend your entity with state machine. Extends BaseEntity. 139 | * @param parameters - state machine parameters 140 | * @param _BaseEntity - base entity class to extend from 141 | * 142 | * @example 143 | * import { StateMachineEntity, t } from 'typeorm-fsm'; 144 | * 145 | * enum OrderState { 146 | * draft = 'draft', 147 | * pending = 'pending', 148 | * paid = 'paid', 149 | * completed = 'completed', 150 | * } 151 | * 152 | * enum OrderEvent { 153 | * create = 'create', 154 | * pay = 'pay', 155 | * complete = 'complete', 156 | * } 157 | * 158 | * @Entity() 159 | * class Order extends StateMachineEntity({ 160 | * status: { 161 | * id: 'orderStatus', 162 | * initial: OrderState.draft, 163 | * transitions: [ 164 | * t(OrderState.draft, OrderEvent.create, OrderState.pending), 165 | * t(OrderState.pending, OrderEvent.pay, OrderState.paid), 166 | * t(OrderState.paid, OrderEvent.complete, OrderState.completed), 167 | * ], 168 | * }}) {} 169 | */ 170 | export const StateMachineEntity = function < 171 | const Parameters extends { 172 | [Column in Columns]: IStateMachineEntityColumnParameters; 173 | }, 174 | Entity extends BaseEntity = BaseEntity, 175 | const Columns extends keyof Parameters = keyof Parameters, 176 | >(parameters: Parameters, _BaseEntity?: { new (): Entity }) { 177 | const _Entity = _BaseEntity ?? BaseEntity; 178 | 179 | class _StateMachineEntity extends _Entity { 180 | constructor() { 181 | super(); 182 | Object.defineProperty(this, 'fsm', { 183 | value: {}, 184 | writable: true, 185 | enumerable: false, 186 | }); 187 | } 188 | } 189 | 190 | const metadataStorage = getMetadataArgsStorage(); 191 | 192 | for (const [column, parameter] of Object.entries(parameters)) { 193 | const _parameter = parameter as IStateMachineEntityColumnParameters< 194 | AllowedNames, 195 | AllowedNames, 196 | FsmContext 197 | >; 198 | const { persistContext, initial } = _parameter; 199 | 200 | const afterLoadMethodName = buildAfterLoadMethodName(column); 201 | 202 | Object.defineProperty(_StateMachineEntity.prototype, afterLoadMethodName, { 203 | value: function () { 204 | initializeStateMachine.call(this, column, _parameter); 205 | }, 206 | }); 207 | 208 | Object.defineProperty(_StateMachineEntity.prototype, column, { 209 | value: undefined, 210 | writable: true, 211 | }); 212 | 213 | Reflect.decorate( 214 | [ 215 | Column('text', { 216 | default: initial, 217 | }), 218 | ], 219 | _StateMachineEntity.prototype, 220 | column, 221 | ); 222 | Reflect.metadata('design:type', String)( 223 | _StateMachineEntity.prototype, 224 | column, 225 | ); 226 | 227 | if (persistContext) { 228 | const contextColumnName = buildContextColumnName(column); 229 | Object.defineProperty(_StateMachineEntity.prototype, contextColumnName, { 230 | value: {}, 231 | writable: true, 232 | }); 233 | 234 | Reflect.decorate( 235 | [ 236 | Column({ 237 | type: 'text', 238 | default: '{}', 239 | transformer: { 240 | from(value) { 241 | return value; 242 | }, 243 | to(value) { 244 | return JSON.stringify(value); 245 | }, 246 | }, 247 | }), 248 | ], 249 | _StateMachineEntity.prototype, 250 | contextColumnName, 251 | ); 252 | } 253 | 254 | metadataStorage.entityListeners.push( 255 | { 256 | target: _StateMachineEntity, 257 | propertyName: afterLoadMethodName, 258 | type: 'after-load', 259 | }, 260 | { 261 | target: _StateMachineEntity, 262 | propertyName: afterLoadMethodName, 263 | type: 'after-insert', 264 | }, 265 | ); 266 | } 267 | 268 | return _StateMachineEntity as unknown as { 269 | new (): Entity & { 270 | params: Parameters; 271 | fsm: { 272 | [Column in keyof Parameters]: IStateMachine< 273 | ExtractState, 274 | ExtractEvent, 275 | ExtractContext 276 | >; 277 | }; 278 | } & { 279 | [Column in keyof Parameters]: ExtractState; 280 | }; 281 | }; 282 | }; 283 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TypeORM State Machine 2 | 3 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) [![Maintainability](https://api.codeclimate.com/v1/badges/a24c3d831b2fe310c268/maintainability)](https://codeclimate.com/github/fsmoothy/typeorm-fsm/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/a24c3d831b2fe310c268/test_coverage)](https://codeclimate.com/github/fsmoothy/typeorm-fsm/test_coverage) 4 | 5 | > Package migrated to monorepo and published with new name `@fsmoothy/typeorm`. This repo in maintenance mode. 6 | > Only critical bugs will be fixed. 7 | 8 | `typeorm-fsm` is a strongly typed state machine designed for TypeORM entities. It allows you to define and manage state transitions in a declarative manner. The library is using [fsmoothy](https://github.com/fsmoothy/fsmoothy) package to provide the best DX. 9 | 10 | ## Index 11 | 12 | - [Usage](#usage) 13 | - [Events and States](#events-and-states) 14 | - [Entity](#entity) 15 | - [StateMachineEntity](#statemachineentity) 16 | - [Transitions](#transitions) 17 | - [Make transition](#make-transition) 18 | - [Current state](#current-state) 19 | - [Transition availability](#transition-availability) 20 | - [Subscribers](#subscribers) 21 | - [Lifecycle](#lifecycle) 22 | - [Bound lifecycle methods](#bound-lifecycle-methods) 23 | - [Error handling](#error-handling) 24 | - [Installation](#installation) 25 | - [Examples](#examples) 26 | - [Latest Changes](#latest-changes) 27 | - [Thanks](#thanks) 28 | 29 | ## Usage 30 | 31 | Let's create a basic order state machine to showcase the features of the library. The diagram below illustrates the states and transitions of the state machine. 32 | 33 | ```mermaid 34 | stateDiagram-v2 35 | draft --> assembly: create 36 | assembly --> warehouse: assemble 37 | assembly --> shipping: ship 38 | warehouse --> warehouse: transfer 39 | warehouse --> shipping: ship 40 | shipping --> delivered: deliver 41 | ``` 42 | 43 | ### Events and States 44 | 45 | The library was initially designed to use `enums` for events and states. However, using string enums would provide more convenient method names. It is also possible to use `string` or `number` as event or state types, but this approach is not recommended. 46 | 47 | ```typescript 48 | enum OrderItemState { 49 | draft = 'draft', 50 | assembly = 'assembly', 51 | warehouse = 'warehouse', 52 | shipping = 'shipping', 53 | delivered = 'delivered', 54 | } 55 | 56 | enum OrderItemEvent { 57 | create = 'create', 58 | assemble = 'assemble', 59 | transfer = 'transfer', 60 | ship = 'ship', 61 | deliver = 'deliver', 62 | } 63 | 64 | interface IOrderItemContext = FSMContext<{ 65 | place: string; 66 | }> 67 | ``` 68 | 69 | ### Entity 70 | 71 | To create an entity class, it must extend `StateMachineEntity` and have defined initial state and transitions. Additionally, you can combine `StateMachineEntity` with your own `BaseEntity`, which should be extended from TypeORM's base entity. 72 | 73 | ```typescript 74 | class BaseEntity extends TypeOrmBaseEntity { 75 | @PrimaryGeneratedColumn() 76 | id: string; 77 | } 78 | 79 | @Entity('order') 80 | class Order extends StateMachineEntity( 81 | { 82 | itemsStatus: state({ 83 | id: 'orderItemsStatus', 84 | initial: OrderItemState.draft, 85 | persistContext: true, 86 | data: () => ({ 87 | place: 'My warehouse', 88 | }), 89 | transitions: [ 90 | t(OrderItemState.draft, OrderItemEvent.create, OrderItemState.assembly), 91 | t( 92 | OrderItemState.assembly, 93 | OrderItemEvent.assemble, 94 | OrderItemState.warehouse, 95 | ), 96 | { 97 | from: OrderItemState.warehouse, 98 | event: OrderItemEvent.transfer, 99 | to: OrderItemState.warehouse, 100 | guard(context: IOrderItemContext, place: string) { 101 | return context.data.place !== place; 102 | }, 103 | onExit(context: IOrderItemContext, place: string) { 104 | context.data.place = place; 105 | }, 106 | }, 107 | t( 108 | [OrderItemState.assembly, OrderItemState.warehouse], 109 | OrderItemEvent.ship, 110 | OrderItemState.shipping, 111 | ), 112 | t( 113 | OrderItemState.shipping, 114 | OrderItemEvent.deliver, 115 | OrderItemState.delivered, 116 | ), 117 | ], 118 | }), 119 | }, 120 | BaseEntity, // It's optional 121 | ) { 122 | @Column({ 123 | default: 0, 124 | }) 125 | price: number; 126 | } 127 | ``` 128 | 129 | ### StateMachineEntity 130 | 131 | Let's take a look at the `StateMachineEntity` mixin. It accepts an object with the following properties: 132 | 133 | - `id` - a unique identifier for the state machine (used for debugging purposes) 134 | - `initial` - the initial state of the state machine 135 | - `persistContext` - if set to `true`, the state machine context will be saved to the database. Default value is `false` 136 | - `saveAfterTransition` - if `true`, the state machine will be saved to the database after each transition. Default value is `true` 137 | - `data` - initial data for the state machine context 138 | - `transitions` - an array of transitions 139 | - `subscribers` - an object with subscribers array for events 140 | 141 | It also support extend your own `BaseEntity` class by passing it as a second argument. 142 | 143 | ### Transitions 144 | 145 | The most common way to define a transition is by using the `t` function, which requires three arguments (guard is optional). 146 | 147 | ```typescript 148 | t(from: State | State[], event: Event, to: State, guard?: (context: Context) => boolean); 149 | ``` 150 | 151 | We also able to pass optional `onEnter` and `onExit` functions to the transition as options: 152 | 153 | ```typescript 154 | t( 155 | from: State | State[], 156 | event: Event, 157 | to: State, 158 | options?: { 159 | guard?: (context: Context) => boolean; 160 | onEnter?: (context: Context) => void; 161 | onExit?: (context: Context) => void; 162 | }, 163 | ); 164 | ``` 165 | 166 | In such cases, we're using next options: 167 | 168 | - `from` - represents the state from which the transition is permitted 169 | - `event` - denotes the event that triggers the transition 170 | - `to` - indicates the state to which the transition leads 171 | - `guard` - a function that verifies if the transition is permissible 172 | - `onEnter` - a function that executes when the transition is triggered 173 | - `onExit` - a function that executes when the transition is completed 174 | - `onLeave` - a function that executes when the next transition is triggered (before `onEnter`) 175 | 176 | ### Make transition 177 | 178 | To make a transition, we need to call the `transition` method of the entity or use methods with the same name as the event. State changes will persist to the database by default. 179 | 180 | ```typescript 181 | const order = new Order(); 182 | await order.fsm.itemsStatus.create(); 183 | await order.fsm.itemsStatus.assemble(); 184 | await order.fsm.itemsStatus.transfer('Another warehouse'); 185 | await order.fsm.itemsStatus.ship(); 186 | ``` 187 | 188 | We're passing the `place` argument to the `transfer` method. It will be passed to the `guard` and `onExit` functions. 189 | 190 | ### Dynamic add transitions 191 | 192 | We can add transition dynamically using the `addTransition` method. 193 | 194 | ```typescript 195 | orderItemFSM.addTransition([ 196 | t( 197 | OrderItemState.shipping, 198 | OrderItemEvent.transfer, 199 | OrderItemState.shipping, 200 | { 201 | guard(context: IOrderItemContext, place: string) { 202 | return context.data.place !== place; 203 | }, 204 | onExit(context: IOrderItemContext, place: string) { 205 | context.data.place = place; 206 | }, 207 | }, 208 | ), 209 | ]); 210 | ``` 211 | 212 | ### Current state 213 | 214 | You can get the current state of the state machine using the `current` property. 215 | 216 | ```typescript 217 | const order = new Order(); 218 | console.log(order.fsm.itemsStatus.current); // draft 219 | ``` 220 | 221 | Also you can use `is` + `state name` method to check the current state. 222 | 223 | ```typescript 224 | const order = new Order(); 225 | console.log(order.fsm.itemsStatus.isDraft()); // true 226 | ``` 227 | 228 | Also `is(state: State)` method is available. 229 | 230 | ### Transition availability 231 | 232 | You can check if the transition is available using the `can` + `event name` method. 233 | 234 | ```typescript 235 | const order = new Order(); 236 | 237 | console.log(order.fsm.itemsStatus.canCreate()); // true 238 | await order.fsm.itemsStatus.create(); 239 | console.log(order.fsm.itemsStatus.canCreate()); // false 240 | await order.fsm.itemsStatus.assemble(); 241 | ``` 242 | 243 | Arguments are passed to the `guard` function. 244 | 245 | ``` typescript 246 | await order.fsm.itemsStatus.transfer('Another warehouse'); 247 | console.log(order.fsm.itemsStatus.canTransfer('Another warehouse')); // false 248 | ``` 249 | 250 | Also `can(event: Event, ...args)` method is available. 251 | 252 | ### Subscribers 253 | 254 | You can subscribe to transition using the `on` method. And unsubscribe using the `off` method. 255 | 256 | ```typescript 257 | const order = new Order(); 258 | 259 | const subscriber = (state: OrderItemState) => { 260 | console.log(state); 261 | }; 262 | order.fsm.itemsStatus.on(OrderItemEvent.create, subscriber); 263 | 264 | await order.fsm.itemsStatus.create(); 265 | 266 | order.fsm.itemsStatus.off(OrderItemEvent.create, subscriber); 267 | ``` 268 | 269 | ### Lifecycle 270 | 271 | The state machine has the following lifecycle methods in the order of execution: 272 | 273 | ``` 274 | - guard 275 | - onLeave (from previous transition) 276 | - onEnter 277 | - transition 278 | - subscribers 279 | - onExit 280 | ``` 281 | 282 | ### Bound lifecycle methods 283 | 284 | The entity instance will be bound to the lifecycle methods. You can access the entity instance using `this` keyword. 285 | 286 | ```typescript 287 | const order = new Order(); 288 | 289 | order.fsm.itemsStatus.onEnter(function (this: Order) { 290 | console.log(this.id); 291 | }); 292 | order.fsm.itemStatus.on(OrderItemEvent.create, function (this: Order) { 293 | console.log(this.id); 294 | }); 295 | 296 | await order.fsm.itemsStatus.create(); 297 | ``` 298 | 299 | You also able to use `bind` method to bind your own `this` keyword to the function. 300 | 301 | ```typescript 302 | order.fsm.itemsStatus.on(function () { 303 | console.log(this.current); 304 | }.bind({ current: 'test' })); 305 | ``` 306 | 307 | ### Error handling 308 | 309 | Library throws `StateMachineError` if transition is not available. It can be caught using `try/catch` and checked using `isStateMachineError` function. 310 | 311 | ```typescript 312 | import { isStateMachineError } from 'typeorm-fsm'; 313 | 314 | try { 315 | await order.fsm.itemsStatus.create(); 316 | } catch (error) { 317 | if (isStateMachineError(error)) { 318 | console.log(error.message); 319 | } 320 | } 321 | ``` 322 | 323 | ## Installation 324 | 325 | ```bash 326 | npm install typeorm fsm-typeorm 327 | ``` 328 | 329 | ## Examples 330 | 331 | Check out the [examples](./src/__tests__/examples) directory for more examples. 332 | 333 | ## Latest Changes 334 | 335 | Take a look at the [CHANGELOG](CHANGELOG.md) for details about recent changes to the current version. 336 | 337 | ## Thanks 338 | 339 | This project was inspired by [aasm](https://github.com/aasm/aasm) and [typescript-fsm](https://github.com/eram/typescript-fsm). 340 | 341 | And thank you for reading this far. I hope you find this library useful. 342 | --------------------------------------------------------------------------------