├── test ├── .keep └── simple.test.ts ├── src ├── invalid-transition.error.ts ├── index.ts ├── state-machine.decorator.ts └── state-machine.loader.ts ├── .gitignore ├── tsconfig.build.json ├── .npmignore ├── .travis.yml ├── .editorconfig ├── tsconfig.json ├── .prettierrc ├── .eslintrc.js ├── package.json ├── samples └── user.ts └── README.md /test/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/invalid-transition.error.ts: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /dist/ 3 | .idea 4 | db.sqlite 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './state-machine.decorator'; 2 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src"] 4 | } 5 | -------------------------------------------------------------------------------- /test/simple.test.ts: -------------------------------------------------------------------------------- 1 | import assert = require('assert'); 2 | 3 | it('always true', async () => { 4 | assert(true); 5 | }); 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /src/ 2 | .travis.yml 3 | .editorconfig 4 | .prettierrc 5 | .eslintrc.js 6 | .idea 7 | db.sqlite 8 | tsconfig*.json 9 | /test/ 10 | /samples/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "stable" 4 | 5 | script: 6 | - npm run lint 7 | - npm run build 8 | - npm test 9 | 10 | deploy: 11 | provider: npm 12 | skip_cleanup: true 13 | email: "$NPM_EMAIL" 14 | api_key: "$NPM_AUTH_TOKEN" 15 | on: 16 | tags: true 17 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | # Unix-style newlines with a newline ending every file 4 | [*] 5 | end_of_line = lf 6 | insert_final_newline = true 7 | trim_trailing_whitespace = true 8 | charset = utf-8 9 | indent_style = space 10 | indent_size = 4 11 | 12 | # Matches the exact files either *.json or *.yml 13 | [*.{json,yml}] 14 | indent_style = space 15 | indent_size = 2 16 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "target": "esnext", 9 | "sourceMap": false, 10 | "outDir": "./dist", 11 | "baseUrl": "./" 12 | }, 13 | "include": ["src", "samples", "test"] 14 | } 15 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "tabWidth": 4, 5 | "printWidth": 140, 6 | "arrowParens": "avoid", 7 | "overrides": [ 8 | { 9 | "files": "*.yml", 10 | "options": { 11 | "singleQuote": false, 12 | "tabWidth": 2 13 | } 14 | }, 15 | { 16 | "files": "*.json", 17 | "options": { 18 | "tabWidth": 2 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/eslint-recommended', 10 | 'plugin:@typescript-eslint/recommended', 11 | 'prettier', 12 | 'prettier/@typescript-eslint', 13 | ], 14 | root: true, 15 | env: { 16 | node: true, 17 | jest: true, 18 | }, 19 | rules: { 20 | indent: [ 21 | 'error', 22 | 4, 23 | { 24 | SwitchCase: 1, 25 | }, 26 | ], 27 | '@typescript-eslint/interface-name-prefix': 'off', 28 | '@typescript-eslint/explicit-function-return-type': 'off', 29 | '@typescript-eslint/no-explicit-any': 'off', 30 | '@typescript-eslint/no-empty-function': 'off', 31 | }, 32 | }; 33 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typeorm-state-machine", 3 | "version": "0.6.3", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "sample": "ts-node samples/user.ts", 8 | "build": "tsc -p tsconfig.build.json", 9 | "lint": "eslint \"{src,samples,test}/**/*.ts\"", 10 | "lint:fix": "eslint \"{src,samples,test}/**/*.ts\" --fix", 11 | "test": "jest" 12 | }, 13 | "keywords": [ 14 | "typeorm", 15 | "fsm", 16 | "state-machine" 17 | ], 18 | "author": "Eugene Sinitsyn", 19 | "license": "ISC", 20 | "repository": { 21 | "type": "git", 22 | "url": "https://github.com/jougene/typeorm-state-machine" 23 | }, 24 | "devDependencies": { 25 | "@types/jest": "^26.0.3", 26 | "@types/node": "^14.0.14", 27 | "@typescript-eslint/eslint-plugin": "^2.23.0", 28 | "@typescript-eslint/parser": "^2.23.0", 29 | "eslint": "^6.8.0", 30 | "eslint-config-prettier": "^6.10.0", 31 | "eslint-plugin-import": "^2.20.1", 32 | "jest": "^26.1.0", 33 | "prettier": "^1.19.1", 34 | "ts-jest": "^26.1.1", 35 | "ts-node": "^8.10.2", 36 | "typescript": "^3.9.5", 37 | "sqlite3": "^5.0.0", 38 | "typeorm": "^0.2.25" 39 | }, 40 | "dependencies": { 41 | "javascript-state-machine": "^3.1.0" 42 | }, 43 | "jest": { 44 | "moduleFileExtensions": [ 45 | "js", 46 | "ts" 47 | ], 48 | "rootDir": "test", 49 | "testRegex": ".test.ts$", 50 | "transform": { 51 | "^.+\\.(t|j)s$": "ts-jest" 52 | }, 53 | "coverageDirectory": "../coverage", 54 | "testEnvironment": "node" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /samples/user.ts: -------------------------------------------------------------------------------- 1 | import { StateMachine, HookParam } from '../src/state-machine.decorator'; 2 | import { Entity, getRepository, Column, createConnection, ConnectionOptions, PrimaryGeneratedColumn, BaseEntity } from 'typeorm'; 3 | 4 | @StateMachine([ 5 | { 6 | transitions: [ 7 | { name: 'walk', from: 'init', to: 'walking' }, 8 | { name: 'stop', from: 'walking', to: 'stopped' }, 9 | { name: 'meow', from: ['stopped', 'walking'], to: 'meowed' }, 10 | ], 11 | options: { saveAfterTransition: true, afterTransition: [async (param: HookParam) => console.log(param)] }, 12 | }, 13 | { 14 | stateField: 'status1', 15 | transitions: [ 16 | { name: 'walk1', from: 'init1', to: 'walking1' }, 17 | { name: 'stop1', from: 'walking1', to: 'stopped1' }, 18 | { name: 'meow1', from: ['stopped1', 'walking1'], to: 'meowed1' }, 19 | ], 20 | options: { saveAfterTransition: true }, 21 | }, 22 | ]) 23 | @Entity() 24 | export class Example extends BaseEntity { 25 | @PrimaryGeneratedColumn() 26 | id: number; 27 | 28 | @Column() 29 | status: string; 30 | 31 | @Column() 32 | status1: string; 33 | 34 | @Column() 35 | name: string; 36 | } 37 | 38 | export interface Example { 39 | walk(): Promise; 40 | stop(): Promise; 41 | meow(): Promise; 42 | 43 | walk1(): Promise; 44 | stop1(): Promise; 45 | meow1(): Promise; 46 | } 47 | 48 | const options: ConnectionOptions = { 49 | type: 'sqlite', 50 | database: ':memory:', 51 | entities: [Example], 52 | //logging: true, 53 | synchronize: true, 54 | }; 55 | 56 | (async () => { 57 | await createConnection(options); 58 | const repo = getRepository(Example); 59 | await repo.insert({ status: 'init', status1: 'init1', name: 'Ivan' }); 60 | 61 | const user = await repo.findOne(); 62 | 63 | await user.walk(); 64 | //await user.stop(); 65 | //const saved = await user.meow(); 66 | //const savedawait user.meow(); 67 | 68 | console.log(user); 69 | await user.stop1(); 70 | //await user.walk1(); 71 | //console.log(user); 72 | })(); 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Typeorm State Machine 2 | 3 | Declarative state machine definition for typeorm entity classes 4 | Based on javascript-state-machine implementation 5 | 6 | ### Why? 7 | 8 | ### Usage 9 | Imagine you have some payment model. 10 | This payment has statuses: 11 | - new 12 | - registered 13 | - held 14 | - charged 15 | - error 16 | - refunded 17 | 18 | ```typescript 19 | enum Status { 20 | NEW = 'new', 21 | REGISTERED = 'registered', 22 | HELD = 'held', 23 | CHARGED = 'charged', 24 | ERROR = 'error', 25 | REFUNDED = 'refunded', 26 | } 27 | 28 | @StateMachine({ 29 | transitions: [ 30 | { name: 'register', from: Status.NEW, to: Status.REGISTERED }, 31 | { name: 'hold', from: Status.REGISTERED, to: Status.HELD }, 32 | { name: 'charge', from: Status.HELD, to: Status.CHARGED }, 33 | { name: 'fail', from: [Status.NEW, Status.HELD, STATUS.REGISTERED], to: Status.ERROR }, 34 | { name: 'refund', from: Status.CHARGED, to: Status.REFUNDED }, 35 | ] 36 | }) 37 | @Entity() 38 | class Payment { 39 | @Column() 40 | guid: string; 41 | 42 | @Column() 43 | externalId: string; 44 | 45 | @Column() 46 | amount: number; 47 | 48 | @Column() 49 | status: Status; 50 | } 51 | ``` 52 | 53 | Also you need interface with these methods and same name as entity 54 | 55 | ```typescript 56 | interface Payment { 57 | register(): void; 58 | hold(): void; 59 | charge(): void; 60 | fail(): void; 61 | refund(): void; 62 | } 63 | ``` 64 | 65 | After the entity will be loaded - state machine initialized with proper status. 66 | And you can use methods from interface which was implemented while entity loading 67 | 68 | ```typescript 69 | payment.register(); 70 | payment.hold(); 71 | payment.charge(); 72 | payment.refund(); 73 | 74 | payment.register() // will fail, becauze it is incorrect state transition 75 | ``` 76 | 77 | ### Options 78 | - saveAfterTransition - all transitions become promisified and you can use it like `await payment.hold()`. Entity is also will be saved in database (Default false) 79 | - autoImplementAll - all methods provided in state machine definition are auto implemented while loading. (Default true) 80 | 81 | ### Samples 82 | 83 | See `samples` directory. Also this samples are used in tests, so you can be sure that it is just working 84 | -------------------------------------------------------------------------------- /src/state-machine.decorator.ts: -------------------------------------------------------------------------------- 1 | import { getMetadataArgsStorage } from 'typeorm'; 2 | import { StateMachineLoader } from './state-machine.loader'; 3 | 4 | export type ErrorFactory = (entity: string, transition: string, from: string, to: string) => Error; 5 | 6 | export type HookParam = { 7 | transition: string; 8 | from: string; 9 | to: string; 10 | entity: string; 11 | field: string; 12 | }; 13 | 14 | export type Options = { 15 | transitions: any[]; 16 | stateField?: string; 17 | options?: { 18 | saveAfterTransition?: boolean; 19 | autoImplementAll?: boolean; 20 | autoImplementOnly?: string[]; 21 | autoImplementExcept?: string[]; 22 | errorFactory?: ErrorFactory; 23 | afterTransition?: Function[]; 24 | }; 25 | }; 26 | 27 | const defaultOptions: Partial = { 28 | stateField: 'status', 29 | options: { 30 | autoImplementAll: true, 31 | saveAfterTransition: false, 32 | afterTransition: [], 33 | }, 34 | }; 35 | 36 | export function StateMachine(data: Options | Options[]) { 37 | const allOptions = []; 38 | if (!Array.isArray(data)) { 39 | const options = data; 40 | options.stateField = data.stateField || defaultOptions.stateField; 41 | options.options = { ...defaultOptions.options, ...data.options }; 42 | allOptions.push(options); 43 | } else { 44 | for (const singleFsm of data) { 45 | const options = singleFsm; 46 | options.stateField = singleFsm.stateField || defaultOptions.stateField; 47 | options.options = { ...defaultOptions.options, ...singleFsm.options }; 48 | allOptions.push(options); 49 | } 50 | } 51 | 52 | return function(ctor: T) { 53 | allOptions.forEach(options => { 54 | const load = function() { 55 | return StateMachineLoader.load(this, options); 56 | }; 57 | 58 | const afterLoadMethodName = '__initStateMachine_' + options.stateField; 59 | Object.defineProperty(ctor.prototype, afterLoadMethodName, { 60 | value: load, 61 | }); 62 | 63 | getMetadataArgsStorage().entityListeners.push({ 64 | target: ctor, 65 | propertyName: afterLoadMethodName, 66 | type: 'after-load', 67 | }); 68 | getMetadataArgsStorage().entityListeners.push({ 69 | target: ctor, 70 | propertyName: afterLoadMethodName, 71 | type: 'after-insert', 72 | }); 73 | }); 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /src/state-machine.loader.ts: -------------------------------------------------------------------------------- 1 | import * as StateMachine from 'javascript-state-machine'; 2 | import { Options, HookParam } from './state-machine.decorator'; 3 | import { EventEmitter } from 'events'; 4 | 5 | export class StateMachineLoader { 6 | static load(entity: any, options: Options) { 7 | const emitter = new EventEmitter(); 8 | if (options.options.afterTransition.length > 0) { 9 | options.options.afterTransition.forEach(async hook => { 10 | emitter.on('transition', async (s: HookParam) => { 11 | await hook(s); 12 | }); 13 | }); 14 | } 15 | const { transitions, stateField } = options; 16 | const defaultErrorFactory = (entity: string, transition: string, from: string, to: string) => { 17 | return new Error(`Invalid ${entity} transition <${transition}> from [${from}] to [${to}]`); 18 | }; 19 | const errorFactory = options.options.errorFactory || defaultErrorFactory; 20 | 21 | const transitionMethodWrapper = (stateMachine: StateMachine, transition: string) => { 22 | return async () => { 23 | const { to } = transitions.find(e => e.name === transition); 24 | try { 25 | const from = entity[stateField]; 26 | stateMachine[transition](); 27 | if (options.options.saveAfterTransition && entity.save) { 28 | entity = await entity.save(); 29 | } 30 | emitter.emit('transition', { from, to, transition, entity, field: stateField }); 31 | return entity; 32 | } catch (e) { 33 | throw errorFactory(entity.constructor.name, transition, e.from, to); 34 | } 35 | }; 36 | }; 37 | 38 | const stateMachine = new StateMachine({ 39 | init: entity[stateField], 40 | transitions, 41 | methods: { 42 | onTransition(s: any): void { 43 | entity[stateField] = s.to; 44 | }, 45 | }, 46 | }); 47 | 48 | const stateMachineClone = { ...stateMachine }; 49 | 50 | // implement all transition methods in entity class 51 | if (options.options.autoImplementAll) { 52 | transitions 53 | .map(t => t.name) 54 | .forEach(transition => { 55 | Object.defineProperty(entity, transition, { 56 | value: transitionMethodWrapper(stateMachineClone, transition), 57 | writable: true, // hack for Typeorm reload method 58 | }); 59 | }); 60 | } 61 | 62 | // wrap all transition methods for better error handling 63 | transitions 64 | .map(t => t.name) 65 | .forEach(transition => { 66 | stateMachine[transition] = transitionMethodWrapper(stateMachineClone, transition); 67 | }); 68 | } 69 | } 70 | --------------------------------------------------------------------------------