├── CODEOWNERS ├── src ├── index.js ├── RuleError.js ├── Premise.js ├── Delegator.js ├── observe.js ├── Action.js ├── Logger.js ├── index.d.ts ├── WorkingMemory.js ├── Rule.js ├── RuleSet.js ├── ConflictResolution.js └── Rools.js ├── .eslintignore ├── .eslintrc ├── .editorconfig ├── test ├── .eslintrc ├── setup.js ├── facts │ ├── weather.js │ └── users.js ├── delegate.spec.js ├── typescript │ └── typescript.spec.ts ├── withdraw.spec.js ├── async.spec.js ├── final.spec.js ├── rules │ ├── availability.js │ ├── deep.js │ └── mood.js ├── deep.spec.js ├── refraction.spec.js ├── cycle.spec.js ├── result.spec.js ├── symbol.spec.js ├── simple.spec.js ├── observe.spec.js ├── errors.spec.js ├── classes.spec.js ├── priority.spec.js ├── reevaluate.spec.js ├── longer.spec.js ├── activationGroup.spec.js ├── specificity.spec.js ├── extend.spec.js ├── Rule.spec.js ├── strategy.spec.js ├── logging.spec.js └── premises.spec.js ├── .gitignore ├── tsconfig.json ├── .github └── workflows │ └── main.yml ├── package.json └── README.md /CODEOWNERS: -------------------------------------------------------------------------------- 1 | # code owners for Github 2 | # see https://help.github.com/articles/about-codeowners/ 3 | 4 | * @frankthelen 5 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const Rools = require('./Rools'); 2 | const Rule = require('./Rule'); 3 | 4 | module.exports = { Rools, Rule }; 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # /node_modules/* and /bower_components/* ignored by default 2 | 3 | # Ignore built files except build/index.js 4 | coverage/* 5 | build/* 6 | builds/* 7 | bundle.js 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-base" 4 | ], 5 | "plugins": [ 6 | "promise" 7 | ], 8 | "rules": { 9 | "no-param-reassign": 0 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/RuleError.js: -------------------------------------------------------------------------------- 1 | class RuleError extends Error { 2 | constructor(message, error) { 3 | super(message); 4 | this.cause = error; 5 | } 6 | } 7 | 8 | module.exports = RuleError; 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.eslintrc", 3 | "env": { 4 | "mocha": true 5 | }, 6 | "globals" : { 7 | "assert": false, 8 | "expect": false, 9 | "should": false, 10 | "sinon": false 11 | }, 12 | "plugins": [ 13 | "should-promised" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /src/Premise.js: -------------------------------------------------------------------------------- 1 | class Premise { 2 | constructor({ 3 | id, name, when, 4 | }) { 5 | this.id = id; 6 | this.name = name; // for logging only 7 | this.when = when; 8 | this.actions = []; 9 | } 10 | 11 | add(action) { 12 | this.actions.push(action); 13 | } 14 | } 15 | 16 | module.exports = Premise; 17 | -------------------------------------------------------------------------------- /src/Delegator.js: -------------------------------------------------------------------------------- 1 | class Delegator { 2 | constructor() { 3 | this.to = null; 4 | } 5 | 6 | delegate(...args) { 7 | return this.to ? this.to(...args) : undefined; 8 | } 9 | 10 | set(to) { 11 | this.to = to; 12 | } 13 | 14 | unset() { 15 | this.to = null; 16 | } 17 | } 18 | 19 | module.exports = Delegator; 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # directories 2 | .idea/* 3 | .vscode/* 4 | local 5 | 6 | # generated files 7 | .DS_Store 8 | .vscode 9 | Desktop.ini 10 | Thumbs.db 11 | 12 | # specific file types 13 | *.backup 14 | *.bak 15 | *.log 16 | *.log.* 17 | *.tmp 18 | *.backup 19 | 20 | # node specific 21 | node_modules 22 | coverage 23 | */config/local.js 24 | .nyc_output 25 | -------------------------------------------------------------------------------- /test/setup.js: -------------------------------------------------------------------------------- 1 | const chai = require('chai'); 2 | const chaiAsPromised = require('chai-as-promised'); 3 | const sinon = require('sinon'); 4 | const sinonChai = require('sinon-chai'); 5 | 6 | chai.use(chaiAsPromised); 7 | chai.use(sinonChai); 8 | 9 | global.chai = chai; 10 | global.sinon = sinon; 11 | global.expect = chai.expect; 12 | global.should = chai.should(); 13 | -------------------------------------------------------------------------------- /test/facts/weather.js: -------------------------------------------------------------------------------- 1 | const good = { 2 | temperature: 20, 3 | humidity: 39, 4 | pressure: 1319, 5 | windy: true, 6 | rainy: false, 7 | }; 8 | 9 | const bad = { 10 | temperature: 9, 11 | humidity: 89, 12 | pressure: 1013, 13 | windy: true, 14 | rainy: true, 15 | }; 16 | 17 | module.exports = () => ({ 18 | good: JSON.parse(JSON.stringify(good)), 19 | bad: JSON.parse(JSON.stringify(bad)), 20 | }); 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", /* 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ 4 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ 5 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/facts/users.js: -------------------------------------------------------------------------------- 1 | const frank = { 2 | name: 'frank', 3 | stars: 347, 4 | dateOfBirth: new Date('1995-01-01'), 5 | address: { 6 | city: 'hamburg', 7 | street: 'redderkoppel', 8 | country: 'germany', 9 | }, 10 | }; 11 | 12 | const michael = { 13 | name: 'michael', 14 | stars: 156, 15 | dateOfBirth: new Date('1999-08-08'), 16 | address: { 17 | city: 'san Francisco', 18 | street: 'willard', 19 | country: 'usa', 20 | }, 21 | }; 22 | 23 | module.exports = () => ({ 24 | frank: JSON.parse(JSON.stringify(frank)), 25 | michael: JSON.parse(JSON.stringify(michael)), 26 | }); 27 | -------------------------------------------------------------------------------- /src/observe.js: -------------------------------------------------------------------------------- 1 | const observe = (object, onAccess) => { 2 | const handler = { 3 | get(target, property, receiver) { 4 | onAccess(property); 5 | return Reflect.get(target, property, receiver); 6 | }, 7 | defineProperty(target, property, descriptor) { 8 | onAccess(property); 9 | return Reflect.defineProperty(target, property, descriptor); 10 | }, 11 | deleteProperty(target, property) { 12 | onAccess(property); 13 | return Reflect.deleteProperty(target, property); 14 | }, 15 | }; 16 | return new Proxy(object, handler); 17 | }; 18 | 19 | module.exports = observe; 20 | -------------------------------------------------------------------------------- /src/Action.js: -------------------------------------------------------------------------------- 1 | class Action { 2 | constructor({ 3 | id, name, then, priority, final, activationGroup, 4 | }) { 5 | this.id = id; 6 | this.name = name; // for logging only 7 | this.then = then; 8 | this.priority = priority; 9 | this.final = final; 10 | this.activationGroup = activationGroup; 11 | this.premises = []; 12 | } 13 | 14 | add(premise) { 15 | this.premises.push(premise); 16 | } 17 | 18 | async fire(facts) { 19 | const thenable = this.then(facts); // >>> fire action! 20 | return thenable && thenable.then ? thenable : undefined; 21 | } 22 | } 23 | 24 | module.exports = Action; 25 | -------------------------------------------------------------------------------- /test/delegate.spec.js: -------------------------------------------------------------------------------- 1 | const Delegator = require('../src/Delegator'); 2 | require('./setup'); 3 | 4 | describe('Delegator', () => { 5 | it('should delegate call', () => { 6 | const delegator = new Delegator(); 7 | const spy = sinon.spy(); 8 | delegator.set(spy); 9 | delegator.delegate('bla'); 10 | expect(spy.calledWith('bla')).to.be.equal(true); 11 | }); 12 | 13 | it('should not delegate call if unset', () => { 14 | const delegator = new Delegator(); 15 | const spy = sinon.spy(); 16 | delegator.set(spy); 17 | delegator.unset(); 18 | delegator.delegate('bla'); 19 | expect(spy.called).to.be.equal(false); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /test/typescript/typescript.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import { expect } from 'chai'; 3 | import { Rools, Rule } from "../.."; 4 | 5 | describe('Integration TypeScript', () => { 6 | it('should work', async () => { 7 | const facts: any = { 8 | foo: false, 9 | bar: true, 10 | }; 11 | const rools = new Rools(); 12 | const rule = new Rule({ 13 | name: 'rule', 14 | when: (facts: any) => { 15 | return true 16 | }, 17 | then: (facts: any) => { 18 | facts.foo = true; 19 | }, 20 | }); 21 | await rools.register([rule]); 22 | await rools.evaluate(facts); 23 | expect(facts.foo).to.be.equal(true); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | strategy: 15 | matrix: 16 | node-version: [12.x, 14.x, 16.x] 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | - run: npm ci 25 | - run: npm run lint 26 | - run: npm run test 27 | - run: npm run coverage 28 | - name: Coveralls 29 | uses: coverallsapp/github-action@master 30 | with: 31 | github-token: ${{ secrets.GITHUB_TOKEN }} 32 | -------------------------------------------------------------------------------- /test/withdraw.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / withdraw', () => { 5 | const spy = sinon.spy(); 6 | 7 | const rule1 = new Rule({ 8 | name: 'rule1', 9 | when: (facts) => facts.fact1, 10 | then: (facts) => { facts.fact2 = false; }, 11 | }); 12 | 13 | const rule2 = new Rule({ 14 | name: 'rule2', 15 | when: (facts) => facts.fact2, 16 | then: () => { spy(); }, 17 | }); 18 | 19 | const facts = { 20 | fact1: true, 21 | fact2: true, 22 | }; 23 | 24 | it('should withdraw action from agenda (un-ready) if facts were changed by previous action', async () => { 25 | const rools = new Rools(); 26 | await rools.register([rule1, rule2]); 27 | await rools.evaluate(facts); 28 | expect(spy.called).to.be.equal(false); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/async.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools } = require('..'); 2 | const { frank } = require('./facts/users')(); 3 | const { rule1, rule2 } = require('./rules/availability'); 4 | require('./setup'); 5 | 6 | describe('Rools.evaluate() / async', () => { 7 | it('should call async action / action with async/await', async () => { 8 | const facts = { user: frank }; 9 | const rools = new Rools(); 10 | await rools.register([rule1]); 11 | await rools.evaluate(facts); 12 | expect(facts.products).to.deep.equal(['dsl', 'm4g', 'm3g']); 13 | }); 14 | 15 | it('should call async action / action with promises', async () => { 16 | const facts = { user: frank }; 17 | const rools = new Rools(); 18 | await rools.register([rule2]); 19 | await rools.evaluate(facts); 20 | expect(facts.products).to.deep.equal(['dsl', 'm4g', 'm3g']); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/final.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | const { frank } = require('./facts/users')(); 3 | const { good } = require('./facts/weather')(); 4 | const { 5 | ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome, 6 | } = require('./rules/mood'); 7 | require('./setup'); 8 | 9 | describe('Rools.evaluate() / final', () => { 10 | let rools; 11 | 12 | before(async () => { 13 | rools = new Rools(); 14 | await rools.register([ 15 | ruleGoWalking, 16 | ruleStayAtHome, 17 | new Rule({ ...ruleMoodGreat, final: true }), 18 | new Rule({ ...ruleMoodSad, final: true }), 19 | ]); 20 | }); 21 | 22 | it('should terminate after final rule', async () => { 23 | const facts = { user: frank, weather: good }; 24 | await rools.evaluate(facts); 25 | expect(facts.user.mood).to.be.equal('great'); 26 | expect(facts.goWalking).to.be.equal(undefined); 27 | expect(facts.stayAtHome).to.be.equal(undefined); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/Logger.js: -------------------------------------------------------------------------------- 1 | class Logger { 2 | constructor({ 3 | error = true, debug = false, delegate = null, 4 | } = { error: true, debug: false, delegate: null }) { 5 | this.filter = { error, debug }; 6 | this.delegate = delegate; 7 | } 8 | 9 | debug(options) { 10 | if (!this.filter.debug) return; 11 | this.log({ ...options, level: 'debug' }); 12 | } 13 | 14 | error(options) { 15 | if (!this.filter.error) return; 16 | this.log({ ...options, level: 'error' }); 17 | } 18 | 19 | log(options) { 20 | const out = this.delegate ? this.delegate : Logger.logDefault; 21 | out(options); 22 | } 23 | 24 | static logDefault({ message, rule, error }) { 25 | const msg = rule ? `# ${message} - "${rule}"` : `# ${message}`; 26 | if (error) { 27 | console.error(msg, error); // eslint-disable-line no-console 28 | } else { 29 | console.log(msg); // eslint-disable-line no-console 30 | } 31 | } 32 | } 33 | 34 | module.exports = Logger; 35 | -------------------------------------------------------------------------------- /test/rules/availability.js: -------------------------------------------------------------------------------- 1 | const { Rule } = require('../..'); 2 | 3 | const availabilityCheck = (address) => { // eslint-disable-line arrow-body-style 4 | return new Promise((resolve) => { 5 | setTimeout(() => { 6 | if (address.country === 'germany') { 7 | if (address.city === 'hamburg') { 8 | return resolve(['dsl', 'm4g', 'm3g']); 9 | } 10 | } 11 | return resolve([]); 12 | }, 100); 13 | }); 14 | }; 15 | 16 | const rule1 = new Rule({ 17 | name: 'check availability of products (async await)', 18 | when: (facts) => facts.user.address.country === 'germany', 19 | then: async (facts) => { 20 | facts.products = await availabilityCheck(facts.user.address); 21 | }, 22 | }); 23 | 24 | const rule2 = new Rule({ 25 | name: 'check availability of products (promises)', 26 | when: (facts) => facts.user.address.country === 'germany', 27 | then: (facts) => availabilityCheck(facts.user.address).then((result) => { 28 | facts.products = result; 29 | }), 30 | }); 31 | 32 | module.exports = { rule1, rule2 }; 33 | -------------------------------------------------------------------------------- /test/deep.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools } = require('../src'); 2 | const { frank, michael } = require('./facts/users')(); 3 | const { good, bad } = require('./facts/weather')(); 4 | const { 5 | ruleTeamMoodGreat, ruleTeamGoWalking, ruleTeamStayAtHome, 6 | } = require('./rules/deep'); 7 | require('./setup'); 8 | 9 | describe('Rools.evaluate() / deep facts + array', () => { 10 | let rools; 11 | 12 | before(async () => { 13 | rools = new Rools(); 14 | await rools.register([ruleTeamMoodGreat, ruleTeamGoWalking, ruleTeamStayAtHome]); 15 | }); 16 | 17 | it('should evaluate scenario 1', async () => { 18 | const facts = { team: { members: [frank, michael] }, weather: good }; 19 | await rools.evaluate(facts); 20 | expect(facts.team.mood).to.be.equal('great'); 21 | expect(facts.team.goWalking).to.be.equal(true); 22 | }); 23 | 24 | it('should evaluate scenario 2', async () => { 25 | const facts = { team: { members: [frank, michael] }, weather: bad }; 26 | await rools.evaluate(facts); 27 | expect(facts.team.mood).to.be.equal('great'); 28 | expect(facts.team.stayAtHome).to.be.equal(true); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/rules/deep.js: -------------------------------------------------------------------------------- 1 | const { Rule } = require('../../src'); 2 | 3 | const ruleTeamMoodGreat = new Rule({ 4 | name: 'mood is great if all have 100 stars or more', 5 | when: (facts) => { 6 | const { members } = facts.team; 7 | return members.reduce((acc, { stars }) => acc && stars >= 100, true); 8 | }, 9 | then: (facts) => { 10 | facts.team.mood = 'great'; 11 | }, 12 | }); 13 | 14 | const ruleTeamGoWalking = new Rule({ 15 | name: 'go for a walk if mood is great and the weather is fine', 16 | when: [ 17 | (facts) => facts.team.mood === 'great', 18 | (facts) => facts.weather.temperature >= 20, 19 | (facts) => !facts.weather.rainy, 20 | ], 21 | then: (facts) => { 22 | facts.team.goWalking = true; 23 | }, 24 | }); 25 | 26 | const ruleTeamStayAtHome = new Rule({ 27 | name: 'stay at home if mood is sad or the weather is bad', 28 | when: [ 29 | (facts) => facts.weather.rainy || facts.team.mood !== 'great', 30 | ], 31 | then: (facts) => { 32 | facts.team.stayAtHome = true; 33 | }, 34 | }); 35 | 36 | module.exports = { 37 | ruleTeamMoodGreat, ruleTeamGoWalking, ruleTeamStayAtHome, 38 | }; 39 | -------------------------------------------------------------------------------- /test/refraction.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / refraction', () => { 5 | const spy = sinon.spy(); 6 | 7 | const rule1 = new Rule({ 8 | name: 'rule1', 9 | when: (facts) => facts.fact1, 10 | then: (facts) => { facts.fact1 = false; facts.fact1 = true; spy(); }, 11 | priority: 10, 12 | }); 13 | 14 | const rule2 = new Rule({ 15 | name: 'rule2', 16 | when: (facts) => facts.fact2, 17 | then: (facts) => { facts.fact1 = false; facts.fact1 = true; }, 18 | }); 19 | 20 | const facts = { 21 | fact1: true, 22 | fact2: true, 23 | }; 24 | 25 | it('should fire each rule only once / recursive', async () => { 26 | spy.resetHistory(); 27 | const rools = new Rools(); 28 | await rools.register([rule1]); 29 | await rools.evaluate(facts); 30 | expect(spy.calledOnce).to.be.equal(true); 31 | }); 32 | 33 | it('should fire each rule only once / transitive', async () => { 34 | spy.resetHistory(); 35 | const rools = new Rools(); 36 | await rools.register([rule1, rule2]); 37 | await rools.evaluate(facts); 38 | expect(spy.calledOnce).to.be.equal(true); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /test/rules/mood.js: -------------------------------------------------------------------------------- 1 | const { Rule } = require('../..'); 2 | 3 | const ruleMoodGreat = new Rule({ 4 | name: 'mood is great if 200 stars or more', 5 | when: (facts) => facts.user.stars >= 200, 6 | then: (facts) => { 7 | facts.user.mood = 'great'; 8 | }, 9 | }); 10 | 11 | const ruleMoodSad = new Rule({ 12 | name: 'mood is sad if less than 200 stars', 13 | when: (facts) => facts.user.stars < 200, 14 | then: (facts) => { 15 | facts.user.mood = 'sad'; 16 | }, 17 | }); 18 | 19 | const ruleGoWalking = new Rule({ 20 | name: 'go for a walk if mood is great and the weather is fine', 21 | when: [ 22 | (facts) => facts.user.mood === 'great', 23 | (facts) => facts.weather.temperature >= 20, 24 | (facts) => !facts.weather.rainy, 25 | ], 26 | then: (facts) => { 27 | facts.goWalking = true; 28 | }, 29 | }); 30 | 31 | const ruleStayAtHome = new Rule({ 32 | name: 'stay at home if mood is sad or the weather is bad', 33 | when: [ 34 | (facts) => facts.weather.rainy || facts.user.mood === 'sad', 35 | ], 36 | then: (facts) => { 37 | facts.stayAtHome = true; 38 | }, 39 | }); 40 | 41 | module.exports = { 42 | ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome, 43 | }; 44 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export class Rools { 2 | constructor(opts?: RoolsOptions); 3 | register(rules: Rule[]): Promise; 4 | evaluate(facts: any, opts?: EvaluateOptions): Promise; 5 | } 6 | 7 | export interface RoolsOptions { 8 | logging?: { 9 | error?: boolean; 10 | debug?: boolean; 11 | delegate?: (params: { level: string, message: string, rule?: string, error?: Error }) => void; 12 | }; 13 | } 14 | 15 | export interface EvaluateOptions { 16 | strategy?: "ps" | "sp"; 17 | } 18 | 19 | export interface EvaluateResult { 20 | /** 21 | * @deprecated Please use `accessedByActions` instead. 22 | */ 23 | updated: string[]; // deprecated 24 | accessedByPremises: string[]; 25 | accessedByActions: string[]; 26 | fired: number; 27 | elapsed: number; 28 | } 29 | 30 | export class Rule { 31 | constructor(opts: RuleOptions); 32 | } 33 | 34 | export interface RuleOptions { 35 | name: string; 36 | when: Premise | Premise[]; 37 | then: Action; 38 | priority?: number; 39 | final?: boolean; 40 | extend?: Rule | Rule[]; 41 | activationGroup?: string; 42 | } 43 | 44 | export type Premise = (facts: any) => boolean; 45 | export type Action = (facts: any) => void | Promise; 46 | -------------------------------------------------------------------------------- /test/cycle.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('../src'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / cycle', () => { 5 | let rools; 6 | 7 | before(async () => { 8 | rools = new Rools(); 9 | const rule1 = new Rule({ 10 | name: 'rule 1', 11 | when: (facts) => facts.foo, 12 | then: (facts) => { 13 | facts.foo = false; 14 | }, 15 | }); 16 | const rule2 = new Rule({ 17 | name: 'rule 2', 18 | when: (facts) => !facts.foo, 19 | then: (facts) => { 20 | facts.foo = true; 21 | }, 22 | }); 23 | await rools.register([rule1, rule2]); 24 | }); 25 | 26 | it('should evaluate without cycle / scenario 1', async () => { 27 | const facts = { 28 | foo: true, 29 | }; 30 | const result = await rools.evaluate(facts); 31 | expect(facts).to.be.deep.equal({ foo: true }); 32 | expect(result.fired).to.be.equal(2); 33 | }); 34 | 35 | it('should evaluate without cycle / scenario 2', async () => { 36 | const facts = { 37 | foo: false, 38 | }; 39 | const result = await rools.evaluate(facts); 40 | expect(facts).to.be.deep.equal({ foo: false }); 41 | expect(result.fired).to.be.equal(2); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /test/result.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools } = require('..'); 2 | const { frank } = require('./facts/users')(); 3 | const { good } = require('./facts/weather')(); 4 | const { 5 | ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome, 6 | } = require('./rules/mood'); 7 | require('./setup'); 8 | 9 | describe('Rools.evaluate() / result', () => { 10 | let rools; 11 | 12 | before(async () => { 13 | rools = new Rools(); 14 | await rools.register([ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 15 | }); 16 | 17 | it('should return evaluation details', async () => { 18 | const facts = { user: frank, weather: good }; 19 | const result = await rools.evaluate(facts); 20 | expect(result).to.have.property('updated'); 21 | expect(result).to.have.property('accessedByActions'); 22 | expect(result).to.have.property('accessedByPremises'); 23 | expect(result).to.have.property('fired'); 24 | expect(result).to.have.property('elapsed'); 25 | expect(result.updated).to.be.deep.equal(['user', 'goWalking']); 26 | expect(result.accessedByActions).to.be.deep.equal(['user', 'goWalking']); 27 | expect(result.accessedByPremises).to.be.deep.equal(['user', 'weather']); 28 | expect(result.fired).to.be.equal(2); 29 | expect(result.elapsed).to.be.gte(0); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /test/symbol.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('../src'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / facts with symbol properties', () => { 5 | let rools; 6 | 7 | before(async () => { 8 | rools = new Rools(); 9 | const foo = Symbol.for('foo'); 10 | const rule1 = new Rule({ 11 | name: 'symbol test rule 1', 12 | when: (facts) => facts[foo].bar, 13 | then: (facts) => { 14 | facts[foo].baz = 'great'; 15 | }, 16 | }); 17 | const rule2 = new Rule({ 18 | name: 'symbol test rule 2', 19 | when: (facts) => facts[foo].baz === 'great', 20 | then: (facts) => { 21 | facts[foo].qux = 'great'; 22 | }, 23 | }); 24 | await rools.register([rule1, rule2]); 25 | }); 26 | 27 | it('should evaluate rules', async () => { 28 | const foo = Symbol.for('foo'); 29 | const facts = { 30 | [foo]: { 31 | bar: true, 32 | quux: false, 33 | }, 34 | }; 35 | const { updated, fired } = await rools.evaluate(facts); 36 | expect(facts).to.be.deep.equal({ 37 | [foo]: { 38 | bar: true, 39 | quux: false, 40 | baz: 'great', 41 | qux: 'great', 42 | }, 43 | }); 44 | expect(updated).to.be.deep.equal([foo]); 45 | expect(fired).to.be.equal(2); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/simple.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools } = require('..'); 2 | const { frank, michael } = require('./facts/users')(); 3 | const { good, bad } = require('./facts/weather')(); 4 | const { 5 | ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome, 6 | } = require('./rules/mood'); 7 | require('./setup'); 8 | 9 | describe('Rools.evaluate() / simple', () => { 10 | let rools; 11 | 12 | before(async () => { 13 | rools = new Rools(); 14 | await rools.register([ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 15 | }); 16 | 17 | it('should evaluate scenario 1', async () => { 18 | const facts = { user: frank, weather: good }; 19 | await rools.evaluate(facts); 20 | expect(facts.user.mood).to.be.equal('great'); 21 | expect(facts.goWalking).to.be.equal(true); 22 | expect(facts.stayAtHome).to.be.equal(undefined); 23 | }); 24 | 25 | it('should evaluate scenario 2', async () => { 26 | const facts = { user: michael, weather: good }; 27 | await rools.evaluate(facts); 28 | expect(facts.user.mood).to.be.equal('sad'); 29 | expect(facts.goWalking).to.be.equal(undefined); 30 | expect(facts.stayAtHome).to.be.equal(true); 31 | }); 32 | 33 | it('should evaluate scenario 3', async () => { 34 | const facts = { user: frank, weather: bad }; 35 | await rools.evaluate(facts); 36 | expect(facts.user.mood).to.be.equal('great'); 37 | expect(facts.goWalking).to.be.equal(undefined); 38 | expect(facts.stayAtHome).to.be.equal(true); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rools", 3 | "version": "2.3.0", 4 | "description": "A small rule engine for Node.", 5 | "main": "src/index.js", 6 | "types": "src/index.d.ts", 7 | "author": "Frank Thelen", 8 | "license": "MIT", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/frankthelen/rools" 12 | }, 13 | "keywords": [ 14 | "rules", 15 | "rule", 16 | "engine", 17 | "rools", 18 | "rule engine", 19 | "rules engine" 20 | ], 21 | "scripts": { 22 | "lint": "eslint . --ignore-path ./.eslintignore", 23 | "test": "npm run test:unit && npm run test:typescript", 24 | "test:unit": "nyc --reporter=lcov --reporter=text-summary mocha --exit --recursive test/**/*.spec.js", 25 | "test:typescript": "mocha -r ts-node/register test/typescript/**/*.spec.ts", 26 | "coverage": "nyc report --reporter=lcovonly", 27 | "preversion": "npm run lint && npm test" 28 | }, 29 | "engines": { 30 | "node": ">=10.x.x" 31 | }, 32 | "devDependencies": { 33 | "@types/chai": "^4.2.22", 34 | "@types/mocha": "^9.0.0", 35 | "@types/node": "^16.11.1", 36 | "chai": "^4.3.4", 37 | "chai-as-promised": "^7.1.1", 38 | "coveralls": "^3.1.1", 39 | "eslint": "^8.0.1", 40 | "eslint-config-airbnb-base": "^14.2.1", 41 | "eslint-plugin-import": "^2.25.2", 42 | "eslint-plugin-promise": "^5.1.0", 43 | "eslint-plugin-should-promised": "^2.0.0", 44 | "mocha": "^9.1.3", 45 | "nyc": "^15.1.0", 46 | "sinon": "^11.1.2", 47 | "sinon-chai": "^3.7.0", 48 | "ts-node": "^10.3.0", 49 | "typescript": "^4.4.4" 50 | }, 51 | "dependencies": { 52 | "arrify": "^2.0.1", 53 | "lodash": "^4.17.21", 54 | "md5": "^2.3.0", 55 | "uniqueid": "^1.0.0" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/observe.spec.js: -------------------------------------------------------------------------------- 1 | const observe = require('../src/observe'); 2 | require('./setup'); 3 | 4 | const object = { 5 | prop: true, 6 | sub: { 7 | subsub: { 8 | bla: true, 9 | }, 10 | }, 11 | }; 12 | 13 | describe('observe', () => { 14 | it('should notify on reading property', () => { 15 | const spy = sinon.spy(); 16 | const proxy = observe(object, spy); 17 | const temp = proxy.prop; // eslint-disable-line no-unused-vars 18 | expect(spy.calledWith('prop')).to.be.equal(true); 19 | }); 20 | 21 | it('should notify on writing property', () => { 22 | const spy = sinon.spy(); 23 | const proxy = observe(object, spy); 24 | proxy.prop = false; 25 | expect(spy.calledWith('prop')).to.be.equal(true); 26 | }); 27 | 28 | it('should notify on deleting property', () => { 29 | const spy = sinon.spy(); 30 | const proxy = observe(object, spy); 31 | delete proxy.prop; 32 | expect(spy.calledWith('prop')).to.be.equal(true); 33 | }); 34 | 35 | it('should notify on reading sub-property', () => { 36 | const spy = sinon.spy(); 37 | const proxy = observe(object, spy); 38 | const temp = proxy.sub.subsub.bla; // eslint-disable-line no-unused-vars 39 | expect(spy.calledWith('sub')).to.be.equal(true); 40 | }); 41 | 42 | it('should notify on writing sub-property', () => { 43 | const spy = sinon.spy(); 44 | const proxy = observe(object, spy); 45 | proxy.sub.subsub.bla = false; 46 | expect(spy.calledWith('sub')).to.be.equal(true); 47 | }); 48 | 49 | it('should notify on deleting sub-property', () => { 50 | const spy = sinon.spy(); 51 | const proxy = observe(object, spy); 52 | delete proxy.sub.subsub.bla; 53 | expect(spy.calledWith('sub')).to.be.equal(true); 54 | }); 55 | }); 56 | -------------------------------------------------------------------------------- /test/errors.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { Rools, Rule } = require('..'); 3 | const { frank } = require('./facts/users')(); 4 | const { good } = require('./facts/weather')(); 5 | const { 6 | ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome, 7 | } = require('./rules/mood'); 8 | require('./setup'); 9 | 10 | describe('Rools.evaluate() / errors', () => { 11 | it('should not fail if `when` throws error', async () => { 12 | const brokenRule = new Rule({ 13 | name: 'broken rule #1', 14 | when: (facts) => facts.bla.blub === 'blub', // TypeError: Cannot read property 'blub' of undefined 15 | then: () => {}, 16 | }); 17 | const rools = new Rools({ logging: { error: false } }); 18 | const facts = { user: frank, weather: good }; 19 | await rools.register([brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 20 | try { 21 | await rools.evaluate(facts); 22 | } catch (error) { 23 | assert.fail(); 24 | } 25 | expect(facts.user.mood).to.be.equal('great'); 26 | expect(facts.goWalking).to.be.equal(true); 27 | }); 28 | 29 | it('should fail if `then` throws error', async () => { 30 | const brokenRule = new Rule({ 31 | name: 'broken rule #2', 32 | when: () => true, // fire immediately 33 | then: (facts) => { 34 | facts.bla.blub = 'blub'; // TypeError: Cannot read property 'blub' of undefined 35 | }, 36 | }); 37 | const rools = new Rools({ logging: { error: false } }); 38 | const facts = { user: frank, weather: good }; 39 | await rools.register([brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 40 | try { 41 | await rools.evaluate(facts); 42 | assert.fail(); 43 | } catch (error) { 44 | // ignore 45 | } 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/WorkingMemory.js: -------------------------------------------------------------------------------- 1 | const Action = require('./Action'); 2 | 3 | class WorkingMemory { 4 | constructor({ actions, premises }) { 5 | this.actions = actions; 6 | this.premises = premises; 7 | this.actionsById = {}; // hash 8 | this.premisesById = {}; // hash 9 | this.actions.forEach((action) => { 10 | this.actionsById[action.id] = { ready: false, fired: false, discarded: false }; 11 | }); 12 | this.premises.forEach((premise) => { 13 | this.premisesById[premise.id] = { value: undefined }; 14 | }); 15 | this.dirtySegments = new Set(); 16 | this.premisesBySegment = {}; // hash 17 | this.accessedByActions = new Set(); // total 18 | this.accessedByPremises = new Set(); // total 19 | } 20 | 21 | getState(object) { 22 | const { id } = object; 23 | return object instanceof Action ? this.actionsById[id] : this.premisesById[id]; 24 | } 25 | 26 | clearDirtySegments() { 27 | this.dirtySegments.clear(); 28 | } 29 | 30 | getDirtyPremises() { 31 | const premises = new Set(); 32 | this.dirtySegments.forEach((segment) => { 33 | const dirtyPremises = this.premisesBySegment[segment] || []; 34 | dirtyPremises.forEach((premise) => { 35 | premises.add(premise); 36 | }); 37 | }); 38 | return [...premises]; 39 | } 40 | 41 | segmentInAction(segment) { 42 | this.dirtySegments.add(segment); 43 | this.accessedByActions.add(segment); 44 | } 45 | 46 | segmentInPremise(segment, premise) { 47 | this.accessedByPremises.add(segment); 48 | let premises = this.premisesBySegment[segment]; 49 | if (!premises) { 50 | premises = new Set(); 51 | this.premisesBySegment[segment] = premises; 52 | } 53 | premises.add(premise); // might grow over time with "hidden" conditions 54 | } 55 | } 56 | 57 | module.exports = WorkingMemory; 58 | -------------------------------------------------------------------------------- /src/Rule.js: -------------------------------------------------------------------------------- 1 | const isBoolean = require('lodash/isBoolean'); 2 | const isFunction = require('lodash/isFunction'); 3 | const isInteger = require('lodash/isInteger'); 4 | const isString = require('lodash/isString'); 5 | const assert = require('assert'); 6 | const arrify = require('arrify'); 7 | 8 | class Rule { 9 | constructor({ 10 | name, when, then, priority = 0, final = false, extend, activationGroup, 11 | }) { 12 | this.name = name; 13 | this.when = arrify(when); 14 | this.then = then; 15 | this.priority = priority; 16 | this.final = final; 17 | this.extend = arrify(extend); 18 | this.activationGroup = activationGroup; 19 | this.assert(); 20 | } 21 | 22 | assert() { 23 | assert( 24 | this.name, 25 | '"name" is required', 26 | ); 27 | assert( 28 | isString(this.name), 29 | '"name" must be a string', 30 | ); 31 | assert( 32 | this.when.length, 33 | '"when" is required with at least one premise', 34 | ); 35 | assert( 36 | this.when.reduce((acc, premise) => acc && isFunction(premise), true), 37 | '"when" must be a function or an array of functions', 38 | ); 39 | assert( 40 | this.then, 41 | '"then" is required', 42 | ); 43 | assert( 44 | isFunction(this.then), 45 | '"then" must be a function', 46 | ); 47 | assert( 48 | isInteger(this.priority), 49 | '"priority" must be an integer', 50 | ); 51 | assert( 52 | isBoolean(this.final), 53 | '"final" must be a boolean', 54 | ); 55 | assert( 56 | this.extend.reduce((acc, rule) => acc && (rule instanceof Rule), true), 57 | '"extend" must be a Rule or an array of Rules', 58 | ); 59 | assert( 60 | !this.activationGroup || isString(this.activationGroup), 61 | '"activationGroup" must be a string', 62 | ); 63 | } 64 | } 65 | 66 | module.exports = Rule; 67 | -------------------------------------------------------------------------------- /test/classes.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | class Person { 5 | constructor({ name, stars, salery }) { 6 | this.name = name; 7 | this.mood = 'unknown'; 8 | this.stars = stars; 9 | this.salery = salery; 10 | } 11 | 12 | getStars() { 13 | return this.stars; 14 | } 15 | 16 | setStars(stars) { 17 | this.stars = stars; 18 | } 19 | 20 | getSalery() { 21 | return this.salery; 22 | } 23 | 24 | setSalery(salery) { 25 | this.salery = salery; 26 | } 27 | 28 | getMood() { 29 | return this.mood; 30 | } 31 | 32 | setMood(mood) { 33 | this.mood = mood; 34 | } 35 | } 36 | 37 | const rule1 = new Rule({ 38 | name: 'mood is great if 200 stars or more', 39 | when: (facts) => facts.user.getStars() >= 200, 40 | then: (facts) => { 41 | facts.user.setMood('great'); 42 | }, 43 | }); 44 | 45 | const rule2 = new Rule({ 46 | name: 'mark applicable if mood is great and salery greater 1000', 47 | when: [ 48 | (facts) => facts.user.getMood() === 'great', 49 | (facts) => facts.user.getSalery() > 1000, 50 | ], 51 | then: (facts) => { 52 | facts.result = true; 53 | }, 54 | }); 55 | 56 | describe('Rools.evaluate() / classes with getters and setters', () => { 57 | it('should set mood in 1 pass', async () => { 58 | const facts = { 59 | user: new Person({ name: 'frank', stars: 347, salery: 1234 }), 60 | }; 61 | const rools = new Rools(); 62 | await rools.register([rule1]); 63 | await rools.evaluate(facts); 64 | // console.log(result); // eslint-disable-line no-console 65 | expect(facts.user.mood).to.be.equal('great'); 66 | }); 67 | 68 | it('should set result in 2 passes', async () => { 69 | const facts = { 70 | user: new Person({ name: 'frank', stars: 347, salery: 1234 }), 71 | }; 72 | const rools = new Rools(); 73 | await rools.register([rule1, rule2]); 74 | await rools.evaluate(facts); 75 | // console.log(result); // eslint-disable-line no-console 76 | expect(facts.user.mood).to.be.equal('great'); 77 | expect(facts.result).to.be.equal(true); 78 | }); 79 | }); 80 | -------------------------------------------------------------------------------- /src/RuleSet.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const md5 = require('md5'); 3 | const uniqueid = require('uniqueid'); 4 | const Action = require('./Action'); 5 | const Premise = require('./Premise'); 6 | const Rule = require('./Rule'); 7 | 8 | class RuleSet { 9 | constructor() { 10 | this.actions = []; 11 | this.premises = []; 12 | this.premisesByHash = {}; 13 | this.nextActionId = uniqueid('a'); 14 | this.nextPremiseId = uniqueid('p'); 15 | this.actionsByActivationGroup = {}; // hash 16 | } 17 | 18 | register(rule) { 19 | assert(rule instanceof Rule, 'rule must be an instance of "Rule"'); 20 | // action 21 | const action = new Action({ 22 | ...rule, 23 | id: this.nextActionId(), 24 | }); 25 | this.actions.push(action); 26 | // extend 27 | const walked = new Set(); // cycle check 28 | const whens = new Set(); 29 | const walker = (node) => { 30 | if (walked.has(node)) return; // cycle 31 | walked.add(node); 32 | node.when.forEach((w) => { whens.add(w); }); 33 | node.extend.forEach((r) => { walker(r); }); // recursion 34 | }; 35 | walker(rule); 36 | // premises 37 | [...whens].forEach((when, index) => { 38 | const hash = md5(when.toString()); // is function already introduced by other rule? 39 | let premise = this.premisesByHash[hash]; 40 | if (!premise) { // create new premise 41 | premise = new Premise({ 42 | ...rule, 43 | id: this.nextPremiseId(), 44 | name: `${rule.name} / ${index}`, 45 | when, 46 | }); 47 | this.premisesByHash[hash] = premise; 48 | this.premises.push(premise); 49 | } 50 | action.add(premise); // action ->> premises 51 | premise.add(action); // premise ->> actions 52 | }); 53 | // activation group 54 | const { activationGroup } = rule; 55 | if (activationGroup) { 56 | let group = this.actionsByActivationGroup[activationGroup]; 57 | if (!group) { 58 | group = []; 59 | this.actionsByActivationGroup[activationGroup] = group; 60 | } 61 | group.push(action); 62 | } 63 | } 64 | } 65 | 66 | module.exports = RuleSet; 67 | -------------------------------------------------------------------------------- /test/priority.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / priority', () => { 5 | const sequence = []; 6 | 7 | const rule1 = new Rule({ 8 | name: 'rule1', 9 | when: (facts) => facts.fact1, 10 | then: () => { sequence.push(1); }, 11 | }); 12 | 13 | const rule2 = new Rule({ 14 | name: 'rule2', 15 | when: (facts) => facts.fact1, 16 | then: () => { sequence.push(2); }, 17 | }); 18 | 19 | const rule3 = new Rule({ 20 | name: 'rule3', 21 | when: (facts) => facts.fact1, 22 | then: () => { sequence.push(3); }, 23 | }); 24 | 25 | const facts = { 26 | fact1: true, 27 | }; 28 | 29 | it('should fire priority 10 first, then in order of registration', async () => { 30 | sequence.length = 0; 31 | const rools = new Rools(); 32 | await rools.register([ 33 | rule1, 34 | new Rule({ ...rule2, priority: 10 }), 35 | rule3, 36 | ]); 37 | await rools.evaluate(facts); 38 | expect(sequence).to.be.deep.equal([2, 1, 3]); 39 | }); 40 | 41 | it('should fire in order of registration, finally negative priority -10', async () => { 42 | sequence.length = 0; 43 | const rools = new Rools(); 44 | await rools.register([ 45 | new Rule({ ...rule1, priority: -10 }), 46 | rule2, 47 | rule3, 48 | ]); 49 | await rools.evaluate(facts); 50 | expect(sequence).to.be.deep.equal([2, 3, 1]); 51 | }); 52 | 53 | it('should fire in order of priority 10, 0, -10', async () => { 54 | sequence.length = 0; 55 | const rools = new Rools(); 56 | await rools.register([ 57 | new Rule({ ...rule1, priority: -10 }), 58 | rule2, 59 | new Rule({ ...rule3, priority: 10 }), 60 | ]); 61 | await rools.evaluate(facts); 62 | expect(sequence).to.be.deep.equal([3, 2, 1]); 63 | }); 64 | 65 | it('should fire in order of registration if equal priorities', async () => { 66 | sequence.length = 0; 67 | const rools = new Rools(); 68 | await rools.register([ 69 | new Rule({ ...rule1, priority: 10 }), 70 | new Rule({ ...rule2, priority: 10 }), 71 | new Rule({ ...rule3, priority: 10 }), 72 | ]); 73 | await rools.evaluate(facts); 74 | expect(sequence).to.be.deep.equal([1, 2, 3]); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /test/reevaluate.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / re-evaluate', () => { 5 | it('should re-evaluate premises only if facts are changed / row', async () => { 6 | const premisesEvaluated = []; 7 | const actionsFired = []; 8 | const rule1 = new Rule({ 9 | name: 'rule1', 10 | when: (facts) => { premisesEvaluated.push(1); return facts.fact1; }, 11 | then: (facts) => { actionsFired.push(1); facts.fact2 = true; }, 12 | }); 13 | const rule2 = new Rule({ 14 | name: 'rule2', 15 | when: (facts) => { premisesEvaluated.push(2); return facts.fact2; }, 16 | then: (facts) => { actionsFired.push(2); facts.fact3 = true; }, 17 | }); 18 | const rule3 = new Rule({ 19 | name: 'rule3', 20 | when: (facts) => { premisesEvaluated.push(3); return facts.fact3; }, 21 | then: () => { actionsFired.push(3); }, 22 | }); 23 | const facts = { 24 | fact1: true, 25 | fact2: false, 26 | fact3: false, 27 | }; 28 | const rools = new Rools(); 29 | await rools.register([rule1, rule2, rule3]); 30 | await rools.evaluate(facts); 31 | expect(premisesEvaluated).to.be.deep.equal([1, 2, 3, 2, 3]); 32 | expect(actionsFired).to.be.deep.equal([1, 2, 3]); 33 | }); 34 | 35 | it('should re-evaluate premises only if facts are changed / complex', async () => { 36 | const premisesEvaluated = []; 37 | const actionsFired = []; 38 | const rule1 = new Rule({ 39 | name: 'rule1', 40 | when: (facts) => { premisesEvaluated.push(1); return facts.fact1; }, 41 | then: (facts) => { actionsFired.push(1); facts.fact2 = true; }, 42 | }); 43 | const rule2 = new Rule({ 44 | name: 'rule2', 45 | when: (facts) => { premisesEvaluated.push(2); return facts.fact2; }, 46 | then: (facts) => { actionsFired.push(2); facts.fact1 = false; facts.fact3 = true; }, 47 | }); 48 | const rule3 = new Rule({ 49 | name: 'rule3', 50 | when: (facts) => { premisesEvaluated.push(3); return facts.fact3; }, 51 | then: (facts) => { actionsFired.push(3); facts.fact2 = false; }, 52 | }); 53 | const facts = { 54 | fact1: true, 55 | fact2: false, 56 | fact3: false, 57 | }; 58 | const rools = new Rools(); 59 | await rools.register([rule1, rule2, rule3]); 60 | await rools.evaluate(facts); 61 | expect(premisesEvaluated).to.be.deep.equal([1, 2, 3, 2, 1, 3, 2]); 62 | expect(actionsFired).to.be.deep.equal([1, 2, 3]); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /test/longer.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / longer cycle', () => { 5 | let rools; 6 | 7 | before(async () => { 8 | const rule0 = new Rule({ 9 | name: 'rule0', 10 | when: (facts) => facts.user.stars === 0, 11 | then: (facts) => { 12 | facts.user.stars += 1; 13 | }, 14 | }); 15 | const rule1 = new Rule({ 16 | name: 'rule1', 17 | when: (facts) => facts.user.stars === 1, 18 | then: (facts) => { 19 | facts.user.stars += 1; 20 | }, 21 | }); 22 | const rule2 = new Rule({ 23 | name: 'rule2', 24 | when: (facts) => facts.user.stars === 2, 25 | then: (facts) => { 26 | facts.user.stars += 1; 27 | }, 28 | }); 29 | const rule3 = new Rule({ 30 | name: 'rule3', 31 | when: (facts) => facts.user.stars === 3, 32 | then: (facts) => { 33 | facts.user.stars += 1; 34 | }, 35 | }); 36 | const rule4 = new Rule({ 37 | name: 'rule4', 38 | when: (facts) => facts.user.stars === 4, 39 | then: (facts) => { 40 | facts.user.stars += 1; 41 | }, 42 | }); 43 | const rule5 = new Rule({ 44 | name: 'rule5', 45 | when: (facts) => facts.user.stars === 5, 46 | then: (facts) => { 47 | facts.user.stars += 1; 48 | }, 49 | }); 50 | const rule6 = new Rule({ 51 | name: 'rule6', 52 | when: (facts) => facts.user.stars === 6, 53 | then: (facts) => { 54 | facts.user.stars += 1; 55 | }, 56 | }); 57 | const rule7 = new Rule({ 58 | name: 'rule7', 59 | when: (facts) => facts.user.stars === 7, 60 | then: (facts) => { 61 | facts.user.stars += 1; 62 | }, 63 | }); 64 | const rule8 = new Rule({ 65 | name: 'rule8', 66 | when: (facts) => facts.user.stars === 8, 67 | then: (facts) => { 68 | facts.user.stars += 1; 69 | }, 70 | }); 71 | const rule9 = new Rule({ 72 | name: 'rule9', 73 | when: (facts) => facts.user.stars === 9, 74 | then: (facts) => { 75 | facts.user.stars += 1; 76 | }, 77 | }); 78 | rools = new Rools(); 79 | await rools.register([rule7, rule0, rule2, rule3, rule1, rule6, rule8, rule4, rule9, rule5]); 80 | }); 81 | 82 | it('should fire 10 rules in 10 passes', async () => { 83 | const frank = { 84 | name: 'frank', 85 | stars: 0, 86 | }; 87 | await rools.evaluate({ user: frank }); 88 | expect(frank.stars).to.be.equal(10); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/ConflictResolution.js: -------------------------------------------------------------------------------- 1 | const intersection = require('lodash/intersection'); 2 | 3 | class ConflictResolution { 4 | constructor({ strategy = 'ps', logger }) { 5 | if (strategy === 'ps') { 6 | this.strategy = [ 7 | this.resolveByPriority.bind(this), 8 | this.resolveBySpecificity.bind(this), 9 | this.resolveByOrderOfRegistration.bind(this), 10 | ]; 11 | } else if (strategy === 'sp') { 12 | this.strategy = [ 13 | this.resolveBySpecificity.bind(this), 14 | this.resolveByPriority.bind(this), 15 | this.resolveByOrderOfRegistration.bind(this), 16 | ]; 17 | } else { 18 | throw new Error('conflict resolution strategy must be "ps" or "sp"'); 19 | } 20 | this.logger = logger; 21 | this.logger.debug({ message: `conflict resolution strategy "${strategy}"` }); 22 | } 23 | 24 | select(actions) { 25 | if (actions.length === 0) { 26 | return undefined; // none 27 | } 28 | if (actions.length === 1) { 29 | return actions[0]; 30 | } 31 | // conflict resolution 32 | this.logger.debug({ message: `conflict resolution starting with ${actions.length}` }); 33 | let resolved = actions; // start with all actions 34 | this.strategy.some((resolver) => { 35 | resolved = resolver(resolved); 36 | return resolved.length === 1; // break 37 | }); 38 | return resolved[0]; 39 | } 40 | 41 | resolveByPriority(actions) { 42 | const prios = actions.map((action) => action.priority); 43 | const highestPrio = Math.max(...prios); 44 | const selected = actions.filter((action) => action.priority === highestPrio); 45 | this.logger.debug({ 46 | message: `conflict resolution by priority ${actions.length} -> ${selected.length}`, 47 | }); 48 | return selected; 49 | } 50 | 51 | resolveBySpecificity(actions) { 52 | const isMoreSpecific = (action, rhs) => action.premises.length > rhs.premises.length 53 | && intersection(action.premises, rhs.premises).length === rhs.premises.length; 54 | const isMostSpecific = (action, all) => all.reduce((acc, other) => acc 55 | && !isMoreSpecific(other, action), true); 56 | const selected = actions.filter((action) => isMostSpecific(action, actions)); 57 | this.logger.debug({ 58 | message: `conflict resolution by specificity ${actions.length} -> ${selected.length}`, 59 | }); 60 | return selected; 61 | } 62 | 63 | resolveByOrderOfRegistration(actions) { 64 | const selected = [actions[0]]; 65 | this.logger.debug({ 66 | message: `conflict resolution by order of registration ${actions.length} -> 1`, 67 | }); 68 | return selected; 69 | } 70 | } 71 | 72 | module.exports = ConflictResolution; 73 | -------------------------------------------------------------------------------- /test/activationGroup.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / activation group', () => { 5 | const sequence = []; 6 | const rule1 = new Rule({ 7 | name: 'rule1', 8 | when: (facts) => facts.fact1, 9 | then: () => { sequence.push(1); }, 10 | }); 11 | const rule2 = new Rule({ 12 | name: 'rule2', 13 | extend: rule1, 14 | when: (facts) => facts.fact2, 15 | then: () => { sequence.push(2); }, 16 | }); 17 | const rule3 = new Rule({ 18 | name: 'rule3', 19 | extens: rule2, 20 | activationGroup: 'groupX', 21 | when: (facts) => facts.fact3, 22 | then: () => { sequence.push(3); }, 23 | }); 24 | const rule4 = new Rule({ 25 | name: 'rule4', 26 | extend: rule2, 27 | activationGroup: 'groupX', 28 | when: (facts) => facts.fact4, 29 | then: () => { sequence.push(4); }, 30 | }); 31 | const facts = { 32 | fact1: true, 33 | fact2: true, 34 | fact3: true, 35 | fact4: true, 36 | }; 37 | 38 | it('should fire only one rule in activation group', async () => { 39 | sequence.length = 0; // reset 40 | const rools = new Rools(); 41 | await rools.register([ 42 | rule1, 43 | rule2, 44 | rule3, 45 | rule4, 46 | ]); 47 | await rools.evaluate(facts); 48 | expect(sequence).to.be.deep.equal([3, 2, 1]); 49 | }); 50 | 51 | it('should fire only one rule in activation group / priority', async () => { 52 | sequence.length = 0; // reset 53 | const rools = new Rools(); 54 | await rools.register([ 55 | rule1, 56 | rule2, 57 | rule3, 58 | new Rule({ ...rule4, priority: 10 }), 59 | ]); 60 | await rools.evaluate(facts); 61 | expect(sequence).to.be.deep.equal([4, 2, 1]); 62 | }); 63 | 64 | it('should fire only one rule in activation group / specificity', async () => { 65 | sequence.length = 0; // reset 66 | const rools = new Rools(); 67 | await rools.register([ 68 | new Rule({ ...rule1, priority: 10 }), 69 | new Rule({ ...rule2, priority: 5 }), 70 | rule3, 71 | rule4, 72 | ]); 73 | await rools.evaluate(facts, { strategy: 'sp' }); 74 | expect(sequence).to.be.deep.equal([3, 2, 1]); 75 | }); 76 | 77 | it('should fire only one rule in activation group / specificity 2', async () => { 78 | sequence.length = 0; // reset 79 | const rools = new Rools(); 80 | await rools.register([ 81 | new Rule({ ...rule1, priority: 10 }), 82 | new Rule({ ...rule2, priority: 5 }), 83 | rule3, 84 | new Rule({ ...rule4, priority: 2 }), 85 | ]); 86 | await rools.evaluate(facts, { strategy: 'sp' }); 87 | expect(sequence).to.be.deep.equal([4, 2, 1]); 88 | }); 89 | }); 90 | -------------------------------------------------------------------------------- /test/specificity.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / specificity', () => { 5 | const sequence = []; 6 | const rule1 = new Rule({ 7 | name: 'rule1', 8 | when: (facts) => facts.fact1, 9 | then: () => { sequence.push(1); }, 10 | }); 11 | const rule2 = new Rule({ 12 | name: 'rule2', 13 | when: [ 14 | (facts) => facts.fact1, 15 | (facts) => facts.fact2, 16 | ], 17 | then: () => { sequence.push(2); }, 18 | }); 19 | const rule3 = new Rule({ 20 | name: 'rule3', 21 | when: [ 22 | (facts) => facts.fact1, 23 | (facts) => facts.fact2, 24 | (facts) => facts.fact3, 25 | ], 26 | then: () => { sequence.push(3); }, 27 | }); 28 | const rule4 = new Rule({ 29 | name: 'rule4', 30 | when: [ 31 | (facts) => facts.fact1, 32 | (facts) => facts.fact2, 33 | (facts) => facts.fact4, 34 | ], 35 | then: () => { sequence.push(4); }, 36 | }); 37 | const facts = { 38 | fact1: true, 39 | fact2: true, 40 | fact3: true, 41 | fact4: true, 42 | }; 43 | 44 | it('should fire rule with higher specificity first', async () => { 45 | sequence.length = 0; // reset 46 | const rools = new Rools(); 47 | await rools.register([rule1, rule2]); 48 | await rools.evaluate(facts); 49 | expect(sequence).to.be.deep.equal([2, 1]); 50 | }); 51 | 52 | it('should fire rule with higher specificity first / three levels', async () => { 53 | sequence.length = 0; // reset 54 | const rools = new Rools(); 55 | await rools.register([rule1, rule2, rule3]); 56 | await rools.evaluate(facts); 57 | expect(sequence).to.be.deep.equal([3, 2, 1]); 58 | }); 59 | 60 | it('should fire rule with higher specificity, then order of registration', async () => { 61 | sequence.length = 0; // reset 62 | const rools = new Rools(); 63 | await rools.register([rule1, rule2, rule3, rule4]); 64 | await rools.evaluate(facts); 65 | expect(sequence).to.be.deep.equal([3, 4, 2, 1]); 66 | }); 67 | 68 | it('should fire rule with highest prio, then higher specificity', async () => { 69 | sequence.length = 0; // reset 70 | const rools = new Rools(); 71 | await rools.register([new Rule({ ...rule1, priority: 10 }), rule2, rule3]); 72 | await rools.evaluate(facts); 73 | expect(sequence).to.be.deep.equal([1, 3, 2]); 74 | }); 75 | 76 | it('should fire rule with highest prio, then higher specificity / 2', async () => { 77 | sequence.length = 0; // reset 78 | const rools = new Rools(); 79 | await rools.register([rule1, new Rule({ ...rule2, priority: 10 }), rule3]); 80 | await rools.evaluate(facts); 81 | expect(sequence).to.be.deep.equal([2, 3, 1]); 82 | }); 83 | 84 | it('should fire rule with highest prio, then higher specificity / 3', async () => { 85 | sequence.length = 0; // reset 86 | const rools = new Rools(); 87 | await rools.register([rule1, rule2, rule3, new Rule({ ...rule4, priority: 10 })]); 88 | await rools.evaluate(facts); 89 | expect(sequence).to.be.deep.equal([4, 3, 2, 1]); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /test/extend.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / extend', () => { 5 | const sequence = []; 6 | const rule1 = new Rule({ 7 | name: 'rule1', 8 | when: (facts) => facts.fact1, 9 | then: () => { sequence.push(1); }, 10 | }); 11 | const rule2 = new Rule({ 12 | name: 'rule2', 13 | extend: rule1, 14 | when: (facts) => facts.fact2, 15 | then: () => { sequence.push(2); }, 16 | }); 17 | const rule3 = new Rule({ 18 | name: 'rule3', 19 | extend: rule2, 20 | when: (facts) => facts.fact3, 21 | then: () => { sequence.push(3); }, 22 | }); 23 | const rule4 = new Rule({ 24 | name: 'rule4', 25 | extend: rule2, 26 | when: (facts) => facts.fact4, 27 | then: () => { sequence.push(4); }, 28 | }); 29 | const rule5 = new Rule({ 30 | name: 'rule5', 31 | extend: [rule3, rule4], 32 | when: (facts) => facts.fact5, 33 | then: () => { sequence.push(5); }, 34 | }); 35 | const facts = { 36 | fact1: true, 37 | fact2: true, 38 | fact3: true, 39 | fact4: true, 40 | fact5: true, 41 | }; 42 | 43 | it('should fire rule with higher specificity first / 1 extended rule', async () => { 44 | sequence.length = 0; // reset 45 | const rools = new Rools(); 46 | await rools.register([rule1, rule2]); 47 | await rools.evaluate(facts); 48 | expect(sequence).to.be.deep.equal([2, 1]); 49 | }); 50 | 51 | it('should fire rule with higher specificity first / 2 extended rules', async () => { 52 | sequence.length = 0; // reset 53 | const rools = new Rools(); 54 | await rools.register([rule1, rule2, rule3]); 55 | await rools.evaluate(facts); 56 | expect(sequence).to.be.deep.equal([3, 2, 1]); 57 | }); 58 | 59 | it('should fire rule with higher specificity, then order of registration / 3 extended rules', async () => { 60 | sequence.length = 0; // reset 61 | const rools = new Rools(); 62 | await rools.register([rule1, rule2, rule3, rule4]); 63 | await rools.evaluate(facts); 64 | expect(sequence).to.be.deep.equal([3, 4, 2, 1]); 65 | }); 66 | 67 | it('should fire rule with highest prio, then higher specificity / 2 extended rules / 1', async () => { 68 | sequence.length = 0; // reset 69 | const rools = new Rools(); 70 | await rools.register([new Rule({ ...rule1, priority: 10 }), rule2, rule3]); 71 | await rools.evaluate(facts); 72 | expect(sequence).to.be.deep.equal([1, 3, 2]); 73 | }); 74 | 75 | it('should fire rule with highest prio, then higher specificity / 2 extended rules / 2', async () => { 76 | sequence.length = 0; // reset 77 | const rools = new Rools(); 78 | await rools.register([rule1, new Rule({ ...rule2, priority: 10 }), rule3]); 79 | await rools.evaluate(facts); 80 | expect(sequence).to.be.deep.equal([2, 3, 1]); 81 | }); 82 | 83 | it('should fire rule with highest prio, then higher specificity / 3 extended rules', async () => { 84 | sequence.length = 0; // reset 85 | const rools = new Rools(); 86 | await rools.register([rule1, rule2, rule3, new Rule({ ...rule4, priority: 10 })]); 87 | await rools.evaluate(facts); 88 | expect(sequence).to.be.deep.equal([4, 3, 2, 1]); 89 | }); 90 | 91 | it('should fire rule with highest specificity, then order of registration / 4 extended rules', async () => { 92 | sequence.length = 0; // reset 93 | const rools = new Rools(); 94 | await rools.register([rule1, rule2, rule3, rule4, rule5]); 95 | await rools.evaluate(facts); 96 | expect(sequence).to.be.deep.equal([5, 3, 4, 2, 1]); 97 | }); 98 | }); 99 | -------------------------------------------------------------------------------- /test/Rule.spec.js: -------------------------------------------------------------------------------- 1 | const assert = require('assert'); 2 | const { Rule } = require('..'); 3 | require('./setup'); 4 | 5 | /* eslint-disable no-unused-vars */ 6 | 7 | describe('new Rule()', () => { 8 | it('should not fail if properties are correct / minimum', async () => { 9 | try { 10 | const rule = new Rule({ 11 | name: 'bla', 12 | when: () => true, 13 | then: () => {}, 14 | }); 15 | } catch (error) { 16 | assert.fail(error); 17 | } 18 | }); 19 | 20 | it('should not fail if properties are correct / maximum', async () => { 21 | try { 22 | const rule = new Rule({ 23 | name: 'bla', 24 | when: () => true, 25 | then: () => {}, 26 | final: true, 27 | extend: [new Rule({ name: 'blub', when: () => false, then: () => {} })], 28 | }); 29 | } catch (error) { 30 | assert.fail(error); 31 | } 32 | }); 33 | 34 | it('should fail if rule has no "name"', async () => { 35 | try { 36 | const rule = new Rule({ 37 | when: () => true, 38 | then: () => {}, 39 | }); 40 | assert.fail(); 41 | } catch (error) { 42 | // correct! 43 | } 44 | }); 45 | 46 | it('should fail if "name" is not a string', async () => { 47 | try { 48 | const rule = new Rule({ 49 | name: () => {}, 50 | when: () => true, 51 | then: () => {}, 52 | }); 53 | assert.fail(); 54 | } catch (error) { 55 | // correct! 56 | } 57 | }); 58 | 59 | it('should fail if rule has no "when"', async () => { 60 | try { 61 | const rule = new Rule({ 62 | name: 'bla', 63 | then: () => {}, 64 | }); 65 | assert.fail(); 66 | } catch (error) { 67 | // correct! 68 | } 69 | }); 70 | 71 | it('should fail if rule has no "then"', async () => { 72 | try { 73 | const rule = new Rule({ 74 | name: 'bla', 75 | when: () => true, 76 | }); 77 | assert.fail(); 78 | } catch (error) { 79 | // correct! 80 | } 81 | }); 82 | 83 | it('should fail if rule "when" is empty', async () => { 84 | try { 85 | const rule = new Rule({ 86 | name: 'bla', 87 | when: [], 88 | then: () => {}, 89 | }); 90 | assert.fail(); 91 | } catch (error) { 92 | // correct! 93 | } 94 | }); 95 | 96 | it('should fail if rule "when" is neither function nor array', async () => { 97 | try { 98 | const rule = new Rule({ 99 | name: 'bla', 100 | when: 'not a function', 101 | then: () => {}, 102 | }); 103 | assert.fail(); 104 | } catch (error) { 105 | // correct! 106 | } 107 | }); 108 | 109 | it('should fail if rule "when" is an array with a non-function element', async () => { 110 | try { 111 | const rule = new Rule({ 112 | name: 'bla', 113 | when: ['not a function'], 114 | then: () => {}, 115 | }); 116 | assert.fail(); 117 | } catch (error) { 118 | // correct! 119 | } 120 | }); 121 | 122 | it('should fail if rule "then" is not a function', async () => { 123 | try { 124 | const rule = new Rule({ 125 | name: 'bla', 126 | when: () => true, 127 | then: 'not a function', 128 | }); 129 | assert.fail(); 130 | } catch (error) { 131 | // correct! 132 | } 133 | }); 134 | 135 | it('should fail if rule "extend" contains not a Rule', async () => { 136 | try { 137 | const rule = new Rule({ 138 | name: 'bla', 139 | when: () => true, 140 | then: () => {}, 141 | extend: {}, 142 | }); 143 | assert.fail(); 144 | } catch (error) { 145 | // correct! 146 | } 147 | }); 148 | }); 149 | -------------------------------------------------------------------------------- /test/strategy.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | require('./setup'); 3 | 4 | describe('Rools.evaluate() / strategy', () => { 5 | const sequence = []; 6 | const rule1 = new Rule({ 7 | name: 'rule1', 8 | when: (facts) => facts.fact1, 9 | then: () => { sequence.push(1); }, 10 | }); 11 | const rule2 = new Rule({ 12 | name: 'rule2', 13 | when: [ 14 | (facts) => facts.fact1, 15 | (facts) => facts.fact2, 16 | ], 17 | then: () => { sequence.push(2); }, 18 | }); 19 | const rule3 = new Rule({ 20 | name: 'rule3', 21 | when: [ 22 | (facts) => facts.fact1, 23 | (facts) => facts.fact2, 24 | (facts) => facts.fact3, 25 | ], 26 | then: () => { sequence.push(3); }, 27 | }); 28 | const rule4 = new Rule({ 29 | name: 'rule4', 30 | when: [ 31 | (facts) => facts.fact1, 32 | (facts) => facts.fact2, 33 | (facts) => facts.fact4, 34 | ], 35 | then: () => { sequence.push(4); }, 36 | }); 37 | const facts = { 38 | fact1: true, 39 | fact2: true, 40 | fact3: true, 41 | fact4: true, 42 | }; 43 | 44 | it('should fail if unknown strategy', async () => { 45 | try { 46 | const rools = new Rools(); 47 | await rools.register([rule1, rule2, rule3]); 48 | await rools.evaluate(facts, { strategy: 'xx' }); 49 | assert.fail(); 50 | } catch (error) { 51 | // correct 52 | } 53 | }); 54 | 55 | it('should fire rule with highest prio, then higher specificity / strategy "ps" / 1', async () => { 56 | sequence.length = 0; // reset 57 | const rools = new Rools(); 58 | await rools.register([new Rule({ ...rule1, priority: 10 }), rule2, rule3]); 59 | await rools.evaluate(facts); 60 | expect(sequence).to.be.deep.equal([1, 3, 2]); 61 | }); 62 | 63 | it('should fire rule with highest prio, then higher specificity / strategy "ps" / 2', async () => { 64 | sequence.length = 0; // reset 65 | const rools = new Rools(); 66 | await rools.register([new Rule({ ...rule1, priority: 10 }), rule2, rule3]); 67 | await rools.evaluate(facts, { strategy: 'ps' }); 68 | expect(sequence).to.be.deep.equal([1, 3, 2]); 69 | }); 70 | 71 | it('should fire rule with highest prio, then higher specificity / strategy "ps" / 3', async () => { 72 | sequence.length = 0; // reset 73 | const rools = new Rools(); 74 | await rools.register([new Rule({ ...rule1, priority: 10 }), rule2, rule3]); 75 | await rools.evaluate(facts, {}); 76 | expect(sequence).to.be.deep.equal([1, 3, 2]); 77 | }); 78 | 79 | it('should fire rule with higher specificity, then highest prio / strategy "sp" / 1', async () => { 80 | sequence.length = 0; // reset 81 | const rools = new Rools(); 82 | await rools.register([new Rule({ ...rule1, priority: 10 }), rule2, rule3]); 83 | await rools.evaluate(facts, { strategy: 'sp' }); 84 | expect(sequence).to.be.deep.equal([3, 2, 1]); 85 | }); 86 | 87 | it('should fire rule with higher specificity, then highest prio / strategy "sp" / 2', async () => { 88 | sequence.length = 0; // reset 89 | const rools = new Rools(); 90 | await rools.register([rule1, new Rule({ ...rule2, priority: 10 }), rule3]); 91 | await rools.evaluate(facts, { strategy: 'sp' }); 92 | expect(sequence).to.be.deep.equal([3, 2, 1]); 93 | }); 94 | 95 | it('should fire rule with higher specificity, then highest prio / strategy "sp" / 3', async () => { 96 | sequence.length = 0; // reset 97 | const rools = new Rools(); 98 | await rools.register([rule1, rule2, rule3, rule4]); 99 | await rools.evaluate(facts, { strategy: 'sp' }); 100 | expect(sequence).to.be.deep.equal([3, 4, 2, 1]); 101 | }); 102 | 103 | it('should fire rule with higher specificity, then highest prio / strategy "sp" / 4', async () => { 104 | sequence.length = 0; // reset 105 | const rools = new Rools(); 106 | await rools.register([rule1, rule2, rule3, new Rule({ ...rule4, priority: 10 })]); 107 | await rools.evaluate(facts, { strategy: 'sp' }); 108 | expect(sequence).to.be.deep.equal([4, 3, 2, 1]); 109 | }); 110 | }); 111 | -------------------------------------------------------------------------------- /test/logging.spec.js: -------------------------------------------------------------------------------- 1 | const { Rools, Rule } = require('..'); 2 | const { frank } = require('./facts/users')(); 3 | const { good } = require('./facts/weather')(); 4 | const { 5 | ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome, 6 | } = require('./rules/mood'); 7 | require('./setup'); 8 | 9 | describe('Rools.evaluate() / delegate logging', () => { 10 | it('should log debug', async () => { 11 | let counter = 0; 12 | const spy = () => { 13 | counter += 1; 14 | }; 15 | const rools = new Rools({ logging: { error: false, debug: true, delegate: spy } }); 16 | await rools.register([ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 17 | await rools.evaluate({ user: frank, weather: good }); 18 | expect(counter).to.not.be.equals(0); 19 | }); 20 | 21 | it('should log errors', async () => { 22 | const brokenRule = new Rule({ 23 | name: 'broken rule #2', 24 | when: () => true, // fire immediately 25 | then: (facts) => { 26 | facts.bla.blub = 'blub'; // TypeError: Cannot read property 'blub' of undefined 27 | }, 28 | }); 29 | let counter = 0; 30 | const spy = () => { 31 | counter += 1; 32 | }; 33 | const rools = new Rools({ logging: { error: true, debug: false, delegate: spy } }); 34 | await rools.register([brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 35 | try { 36 | await rools.evaluate({ user: frank, weather: good }); 37 | } catch (error) { 38 | // ignore 39 | } 40 | expect(counter).to.not.be.equals(0); 41 | }); 42 | 43 | it('should log errors by default', async () => { 44 | const brokenRule = new Rule({ 45 | name: 'broken rule #2', 46 | when: () => true, // fire immediately 47 | then: (facts) => { 48 | facts.bla.blub = 'blub'; // TypeError: Cannot read property 'blub' of undefined 49 | }, 50 | }); 51 | let counter = 0; 52 | const spy = () => { 53 | counter += 1; 54 | }; 55 | const rools = new Rools({ logging: { delegate: spy } }); 56 | await rools.register([brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 57 | try { 58 | await rools.evaluate({ user: frank, weather: good }); 59 | } catch (error) { 60 | // ignore 61 | } 62 | expect(counter).to.not.be.equals(0); 63 | }); 64 | }); 65 | 66 | describe('Rools.evaluate() / console logging', () => { 67 | beforeEach(() => { 68 | sinon.spy(console, 'log'); 69 | sinon.spy(console, 'error'); 70 | }); 71 | 72 | afterEach(() => { 73 | console.log.restore(); // eslint-disable-line no-console 74 | console.error.restore(); // eslint-disable-line no-console 75 | }); 76 | 77 | it('should log debug', async () => { 78 | const rools = new Rools({ logging: { error: false, debug: true } }); 79 | await rools.register([ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 80 | await rools.evaluate({ user: frank, weather: good }); 81 | expect(console.log).to.be.called; // eslint-disable-line no-unused-expressions, no-console 82 | }); 83 | 84 | it('should log errors', async () => { 85 | const brokenRule = new Rule({ 86 | name: 'broken rule #2', 87 | when: () => true, // fire immediately 88 | then: (facts) => { 89 | facts.bla.blub = 'blub'; // TypeError: Cannot read property 'blub' of undefined 90 | }, 91 | }); 92 | const rools = new Rools({ logging: { error: true, debug: false } }); 93 | await rools.register([brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 94 | try { 95 | await rools.evaluate({ user: frank, weather: good }); 96 | } catch (error) { 97 | // ignore 98 | } 99 | expect(console.error).to.be.called; // eslint-disable-line no-unused-expressions, no-console 100 | }); 101 | 102 | it('should log errors by default / 1', async () => { 103 | const brokenRule = new Rule({ 104 | name: 'broken rule #2', 105 | when: () => true, // fire immediately 106 | then: (facts) => { 107 | facts.bla.blub = 'blub'; // TypeError: Cannot read property 'blub' of undefined 108 | }, 109 | }); 110 | const rools = new Rools(); 111 | await rools.register([brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 112 | try { 113 | await rools.evaluate({ user: frank, weather: good }); 114 | } catch (error) { 115 | // ignore 116 | } 117 | expect(console.error).to.be.called; // eslint-disable-line no-unused-expressions, no-console 118 | }); 119 | 120 | it('should log errors by default / 2', async () => { 121 | const brokenRule = new Rule({ 122 | name: 'broken rule #2', 123 | when: () => true, // fire immediately 124 | then: (facts) => { 125 | facts.bla.blub = 'blub'; // TypeError: Cannot read property 'blub' of undefined 126 | }, 127 | }); 128 | const rools = new Rools({}); 129 | await rools.register([brokenRule, ruleMoodGreat, ruleMoodSad, ruleGoWalking, ruleStayAtHome]); 130 | try { 131 | await rools.evaluate({ user: frank, weather: good }); 132 | } catch (error) { 133 | // ignore 134 | } 135 | expect(console.error).to.be.called; // eslint-disable-line no-unused-expressions, no-console 136 | }); 137 | }); 138 | -------------------------------------------------------------------------------- /src/Rools.js: -------------------------------------------------------------------------------- 1 | const RuleSet = require('./RuleSet'); 2 | const Logger = require('./Logger'); 3 | const Delegator = require('./Delegator'); 4 | const WorkingMemory = require('./WorkingMemory'); 5 | const ConflictResolution = require('./ConflictResolution'); 6 | const observe = require('./observe'); 7 | const RuleError = require('./RuleError'); 8 | 9 | class Rools { 10 | constructor({ logging } = {}) { 11 | this.rules = new RuleSet(); 12 | this.maxPasses = 1000; // emergency stop 13 | this.logger = new Logger(logging); 14 | } 15 | 16 | async register(rules) { 17 | rules.forEach((rule) => this.rules.register(rule)); 18 | } 19 | 20 | async evaluate(facts, { strategy } = {}) { 21 | const startDate = new Date(); 22 | // init 23 | const memory = new WorkingMemory({ 24 | actions: this.rules.actions, 25 | premises: this.rules.premises, 26 | }); 27 | const conflictResolution = new ConflictResolution({ strategy, logger: this.logger }); 28 | const delegator = new Delegator(); 29 | const proxy = observe(facts, (segment) => delegator.delegate(segment)); 30 | // match-resolve-act cycle 31 | let pass = 0; /* eslint-disable no-await-in-loop */ 32 | for (; pass < this.maxPasses; pass += 1) { 33 | const next = await this.pass(proxy, delegator, memory, conflictResolution, pass); 34 | if (!next) break; // for 35 | } /* eslint-enable no-await-in-loop */ 36 | // return info 37 | const endDate = new Date(); 38 | return { 39 | updated: [...memory.accessedByActions], // for backward compatibility 40 | accessedByActions: [...memory.accessedByActions], 41 | accessedByPremises: [...memory.accessedByPremises], 42 | fired: pass, 43 | elapsed: endDate.getTime() - startDate.getTime(), 44 | }; 45 | } 46 | 47 | async pass(facts, delegator, memory, conflictResolution, pass) { 48 | this.logger.debug({ message: `evaluate pass ${pass}` }); 49 | // create agenda for premises 50 | const premisesAgenda = pass === 0 ? memory.premises : memory.getDirtyPremises(); 51 | this.logger.debug({ message: `premises agenda length ${premisesAgenda.length}` }); 52 | // evaluate premises 53 | premisesAgenda.forEach((premise) => { 54 | try { 55 | delegator.set((segment) => { // listen to reading fact segments 56 | const segmentName = (typeof segment === 'symbol') ? segment.toString() : segment; 57 | this.logger.debug({ message: `access fact segment "${segmentName}" in premise`, rule: premise.name }); 58 | memory.segmentInPremise(segment, premise); 59 | }); 60 | memory.getState(premise).value = premise.when(facts); // >>> evaluate premise! 61 | } catch (error) { // ignore error! 62 | memory.getState(premise).value = undefined; 63 | this.logger.error({ message: 'error in premise (when)', rule: premise.name, error }); 64 | } finally { 65 | delegator.unset(); 66 | } 67 | }); 68 | // create agenda for actions 69 | const actionsAgenda = pass === 0 ? memory.actions : premisesAgenda 70 | .reduce((acc, premise) => [...new Set([...acc, ...premise.actions])], []) 71 | .filter((action) => { 72 | const { fired, discarded } = memory.getState(action); 73 | return !fired && !discarded; 74 | }); 75 | this.logger.debug({ message: `actions agenda length ${actionsAgenda.length}` }); 76 | // evaluate actions 77 | actionsAgenda.forEach((action) => { 78 | memory.getState(action).ready = action.premises.reduce((acc, premise) => acc 79 | && memory.getState(premise).value, true); 80 | }); 81 | // create conflict set 82 | const conflictSet = memory.actions.filter((action) => { // all actions not only actionsAgenda! 83 | const { fired, ready, discarded } = memory.getState(action); 84 | return ready && !fired && !discarded; 85 | }); 86 | this.logger.debug({ message: `conflict set length ${conflictSet.length}` }); 87 | // conflict resolution 88 | const action = conflictResolution.select(conflictSet); 89 | if (!action) { 90 | this.logger.debug({ message: 'evaluation complete' }); 91 | return false; // done 92 | } 93 | // fire action 94 | this.logger.debug({ message: 'fire action', rule: action.name }); 95 | memory.getState(action).fired = true; // mark fired first 96 | try { 97 | memory.clearDirtySegments(); 98 | delegator.set((segment) => { // listen to writing fact segments 99 | const segmentName = (typeof segment === 'symbol') ? segment.toString() : segment; 100 | this.logger.debug({ message: `access fact segment "${segmentName}" in action`, rule: action.name }); 101 | memory.segmentInAction(segment); 102 | }); 103 | await action.fire(facts); // >>> fire action! 104 | } catch (error) { // re-throw error! 105 | this.logger.error({ message: 'error in action (then)', rule: action.name, error }); 106 | throw new RuleError(`error in action (then): ${action.name}`, error); 107 | } finally { 108 | delegator.unset(); 109 | } 110 | // final rule 111 | if (action.final) { 112 | this.logger.debug({ message: 'evaluation stop after final rule', rule: action.name }); 113 | return false; // done 114 | } 115 | // activation group 116 | if (action.activationGroup) { 117 | this.logger.debug({ 118 | message: `activation group fired "${action.activationGroup}"`, 119 | rule: action.name, 120 | }); 121 | this.rules.actionsByActivationGroup[action.activationGroup].forEach((other) => { 122 | const state = memory.getState(other); 123 | state.discarded = !state.fired; 124 | }); 125 | } 126 | // continue with next pass 127 | return true; 128 | } 129 | } 130 | 131 | module.exports = Rools; 132 | -------------------------------------------------------------------------------- /test/premises.spec.js: -------------------------------------------------------------------------------- 1 | const md5 = require('md5'); 2 | const { Rools, Rule } = require('..'); 3 | require('./setup'); 4 | 5 | describe('Rools.register() / optimization of premises', () => { 6 | it('should not merge premises if not identical', async () => { 7 | const rule1 = new Rule({ 8 | name: 'rule1', 9 | when: (facts) => facts.user.name === 'frank', 10 | then: () => {}, 11 | }); 12 | const rule2 = new Rule({ 13 | name: 'rule2', 14 | when: (facts) => facts.user.name === 'michael', 15 | then: () => {}, 16 | }); 17 | const rools = new Rools(); 18 | await rools.register([rule1, rule2]); 19 | expect(rools.rules.premises.length).to.be.equal(2); 20 | }); 21 | 22 | it('should merge premises if identical / reference / arrow function', async () => { 23 | const isFrank = (facts) => facts.user.name === 'frank'; 24 | const rule1 = new Rule({ 25 | name: 'rule1', 26 | when: isFrank, 27 | then: () => {}, 28 | }); 29 | const rule2 = new Rule({ 30 | name: 'rule2', 31 | when: isFrank, 32 | then: () => {}, 33 | }); 34 | const rools = new Rools(); 35 | await rools.register([rule1, rule2]); 36 | expect(rools.rules.premises.length).to.be.equal(1); 37 | }); 38 | 39 | it('should merge premises if identical / reference / classic function', async () => { 40 | function isFrank(facts) { 41 | return facts.user.name === 'frank'; 42 | } 43 | const rule1 = new Rule({ 44 | name: 'rule1', 45 | when: isFrank, 46 | then: () => {}, 47 | }); 48 | const rule2 = new Rule({ 49 | name: 'rule2', 50 | when: isFrank, 51 | then: () => {}, 52 | }); 53 | const rools = new Rools(); 54 | await rools.register([rule1, rule2]); 55 | expect(rools.rules.premises.length).to.be.equal(1); 56 | }); 57 | 58 | it('should merge premises if identical / hash / arrow function', async () => { 59 | const rule1 = new Rule({ 60 | name: 'rule1', 61 | when: (facts) => facts.user.name === 'frank', 62 | then: () => {}, 63 | }); 64 | const rule2 = new Rule({ 65 | name: 'rule2', 66 | when: (facts) => facts.user.name === 'frank', 67 | then: () => {}, 68 | }); 69 | const rools = new Rools(); 70 | await rools.register([rule1, rule2]); 71 | expect(rools.rules.premises.length).to.be.equal(1); 72 | }); 73 | 74 | it('should merge premises if identical / hash / classic function()', async () => { 75 | const rule1 = new Rule({ 76 | name: 'rule1', 77 | when: function p(facts) { 78 | return facts.user.name === 'frank'; 79 | }, 80 | then: () => {}, 81 | }); 82 | const rule2 = new Rule({ 83 | name: 'rule2', 84 | when: function p(facts) { 85 | return facts.user.name === 'frank'; 86 | }, 87 | then: () => {}, 88 | }); 89 | const rools = new Rools(); 90 | await rools.register([rule1, rule2]); 91 | expect(rools.rules.premises.length).to.be.equal(1); 92 | }); 93 | 94 | it('should not merge premises if identical / hash / slightly different (unfortunately)', async () => { 95 | const rule1 = new Rule({ 96 | name: 'rule1', 97 | when: (facts) => facts.user.name === 'frank', 98 | then: () => {}, 99 | }); 100 | const rule2 = new Rule({ 101 | name: 'rule2', 102 | when: (facts) => facts.user.name === "frank", // eslint-disable-line quotes 103 | then: () => {}, 104 | }); 105 | const rools = new Rools(); 106 | await rools.register([rule1, rule2]); 107 | expect(rools.rules.premises.length).to.be.equal(2); 108 | }); 109 | 110 | it('should not merge premises if not identical / with Date object', async () => { 111 | const date1 = new Date('2000-01-01'); 112 | const date2 = new Date('1990-01-01'); 113 | const rule1 = new Rule({ 114 | name: 'rule1', 115 | when: (facts) => facts.user.birthdate > date1, 116 | then: () => {}, 117 | }); 118 | const rule2 = new Rule({ 119 | name: 'rule2', 120 | when: (facts) => facts.user.birthdate > date2, 121 | then: () => {}, 122 | }); 123 | const rools = new Rools(); 124 | await rools.register([rule1, rule2]); 125 | expect(rools.rules.premises.length).to.be.equal(2); 126 | }); 127 | 128 | it('should merge premises if identical / with Date object', async () => { 129 | const date = new Date('2000-01-01'); 130 | const rule1 = new Rule({ 131 | name: 'rule1', 132 | when: (facts) => facts.user.birthdate > date, 133 | then: () => {}, 134 | }); 135 | const rule2 = new Rule({ 136 | name: 'rule2', 137 | when: (facts) => facts.user.birthdate > date, 138 | then: () => {}, 139 | }); 140 | const rools = new Rools(); 141 | await rools.register([rule1, rule2]); 142 | expect(rools.rules.premises.length).to.be.equal(1); 143 | }); 144 | 145 | it('should merge premises if identical / fn.toString() / 1', async () => { 146 | const foo = ({ context }) => context.bar === 'buz0'; 147 | const fns = [ 148 | ({ context }) => context.bar === 'buz0', 149 | ({ context }) => context.bar === 'buz 0', 150 | ({ context }) => context.bar === 'buz 0', 151 | ({ context }) => context.bar === 'buz10', 152 | ({ context }) => context.bar === 'buz00', 153 | foo, 154 | foo, 155 | ({ context }) => context.bar === 'buz0', 156 | ]; 157 | const set = new Set(); 158 | fns.forEach((f) => set.add(md5(f.toString()))); 159 | expect(set.size).to.be.equal(4); 160 | }); 161 | 162 | it('should merge premises if identical / fn.toString() / 2', async () => { 163 | const foo = ({ context }) => context.bar === Math.abs(3); 164 | const fns = [ 165 | ({ context }) => context.bar === Math.abs(0), 166 | ({ context }) => context.bar === Math.abs(1), 167 | foo, 168 | ]; 169 | const set = new Set(); 170 | fns.forEach((f) => set.add(md5(f.toString()))); 171 | expect(set.size).to.be.equal(3); 172 | }); 173 | }); 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # rools 2 | 3 | A small rule engine for Node. 4 | 5 | ![main workflow](https://github.com/frankthelen/rools/actions/workflows/main.yml/badge.svg) 6 | [![Coverage Status](https://coveralls.io/repos/github/frankthelen/rools/badge.svg?branch=master)](https://coveralls.io/github/frankthelen/rools?branch=master) 7 | [![dependencies Status](https://david-dm.org/frankthelen/rools/status.svg)](https://david-dm.org/frankthelen/rools) 8 | [![Maintainability](https://api.codeclimate.com/v1/badges/d1f858c321b03000fc63/maintainability)](https://codeclimate.com/github/frankthelen/rools/maintainability) 9 | [![node](https://img.shields.io/node/v/rools.svg)](https://nodejs.org) 10 | [![code style](https://img.shields.io/badge/code_style-airbnb-brightgreen.svg)](https://github.com/airbnb/javascript) 11 | [![Types](https://img.shields.io/npm/types/rools.svg)](https://www.npmjs.com/package/rools) 12 | [![License Status](http://img.shields.io/npm/l/rools.svg)]() 13 | 14 | *Primary goal* was to provide a nice and state-of-the-art interface for modern JavaScript (ES6). 15 | *Facts* are plain JavaScript or JSON objects or objects from ES6 classes with getters and setters. 16 | *Rules* are specified in pure JavaScript rather than in a separate, special-purpose language like DSL. 17 | 18 | *Secondary goal* was to provide [RETE](https://en.wikipedia.org/wiki/Rete_algorithm)-like efficiency and optimization. 19 | 20 | Mission accomplished! JavaScript rocks! 21 | 22 | See [migration info](#migration) for breaking changes between major versions 1.x.x and 2.x.x. 23 | 24 | ## Install 25 | 26 | ```bash 27 | npm install rools 28 | ``` 29 | 30 | ## Usage 31 | 32 | This is a basic example. 33 | 34 | ```javascript 35 | // import 36 | const { Rools, Rule } = require('rools'); 37 | 38 | // facts 39 | const facts = { 40 | user: { 41 | name: 'frank', 42 | stars: 347, 43 | }, 44 | weather: { 45 | temperature: 20, 46 | windy: true, 47 | rainy: false, 48 | }, 49 | }; 50 | 51 | // rules 52 | const ruleMoodGreat = new Rule({ 53 | name: 'mood is great if 200 stars or more', 54 | when: (facts) => facts.user.stars >= 200, 55 | then: (facts) => { 56 | facts.user.mood = 'great'; 57 | }, 58 | }); 59 | const ruleGoWalking = new Rule({ 60 | name: 'go for a walk if mood is great and the weather is fine', 61 | when: [ 62 | (facts) => facts.user.mood === 'great', 63 | (facts) => facts.weather.temperature >= 20, 64 | (facts) => !facts.weather.rainy, 65 | ], 66 | then: (facts) => { 67 | facts.goWalking = true; 68 | }, 69 | }); 70 | 71 | // evaluation 72 | const rools = new Rools(); 73 | await rools.register([ruleMoodGreat, ruleGoWalking]); 74 | await rools.evaluate(facts); 75 | ``` 76 | 77 | These are the resulting facts: 78 | 79 | ```javascript 80 | { user: { name: 'frank', stars: 347, mood: 'great' }, 81 | weather: { temperature: 20, windy: true, rainy: false }, 82 | goWalking: true, 83 | } 84 | ``` 85 | 86 | ## Features 87 | 88 | ### Rule engine 89 | 90 | The engine does forward-chaining and works in the usual match-resolve-act cycle. 91 | It tries to deduce as much knowledge as possible from the given facts and rules. 92 | If there is no further knowledge to gain, it stops. 93 | 94 | ### Facts and rules 95 | 96 | Facts are plain JavaScript or JSON objects or objects from ES6 classes with getters and setters. 97 | 98 | Rules are specified in pure JavaScript via `new Rule()`. 99 | They have premises (`when`) and actions (`then`). 100 | Both are JavaScript functions, i.e., classic functions or ES6 arrow functions. 101 | Actions can also be asynchronous. 102 | 103 | Rules access the facts in both, premises (`when`) and actions (`then`). 104 | They can access properties directly, e.g., `facts.user.salary`, 105 | or through getters and setters if applicable, e.g., `facts.user.getSalary()`. 106 | 107 | ### Conflict resolution 108 | 109 | If there is more than one rule ready to fire, i.e., the conflict set is greater 1, the following conflict resolution strategies are applied (by default, in this order): 110 | 111 | * Refraction -- Each rule will fire only once, at most, during any one match-resolve-act cycle. 112 | * Priority -- Rules with higher priority will fire first. Set the rule's property `priority` to an integer value. Default priority is `0`. Negative values are supported. 113 | * Specificity -- Rules which are more specific will fire first. For example, there is rule R1 with premises P1 and P2, and rule R2 with premises P1, P2 and P3. R2 is more specific than R1 and will fire first. R2 is more specific than R1 because it has *all* premises of R1 and additional ones. 114 | * Order of rules -- The rules that were registered first will fire first. 115 | 116 | ### Final rules 117 | 118 | For optimization purposes, it can be useful to stop the engine as soon as a specific rule has fired. 119 | This can be achieved by settings the respective rules' property `final` to `true`. 120 | Default, of course, is `false`. 121 | 122 | ### Async actions 123 | 124 | While premises (`when`) are always working synchronously on the facts, 125 | actions (`then`) can be synchronous or asynchronous. 126 | 127 | Example: asynchronous action using async/await 128 | 129 | ```javascript 130 | const rule = new Rule({ 131 | name: 'check availability', 132 | when: (facts) => facts.user.address.country === 'germany', 133 | then: async (facts) => { 134 | facts.products = await availabilityCheck(facts.user.address); 135 | }, 136 | }); 137 | ``` 138 | 139 | Example: asynchronous action using promises 140 | 141 | ```javascript 142 | const rule = new Rule({ 143 | name: 'check availability', 144 | when: (facts) => facts.user.address.country === 'germany', 145 | then: (facts) => 146 | availabilityCheck(facts.user.address) 147 | .then((result) => { 148 | facts.products = result; 149 | }), 150 | }); 151 | ``` 152 | 153 | ### Extended rules 154 | 155 | If a *rule is more specific* than another rule, you can *extend* it rather than having to repeat its premises. 156 | The extended rule simply inherits all the premises from its parents (and their parents). 157 | Use the rule's `extend` property to set its parents. 158 | 159 | Example: extended rule 160 | 161 | ```javascript 162 | const baseRule = new Rule({ 163 | name: 'user lives in Germany', 164 | when: (facts) => facts.user.address.country === 'germany', 165 | ... 166 | }); 167 | const extendedRule = new Rule({ 168 | name: 'user lives in Hamburg, Germany', 169 | extend: baseRule, // can also be an array of rules 170 | when: (facts) => facts.user.address.city === 'hamburg', 171 | ... 172 | }); 173 | ``` 174 | 175 | ### Activation groups 176 | 177 | Only one rule within an activation group will fire during a match-resolve-act cycle, i.e., 178 | the first one to fire discards all other rules within the same activation group. 179 | Use the rule's `activationGroup` property to set its activation group. 180 | 181 | ### Rule groups 182 | 183 | Besides activation groups, Rools has currently *no other concept of grouping rules* such as agenda groups or rule flow groups which you might know from other rule engines. And there are currently no plans to support such features. 184 | 185 | However, if that solves your needs, you can consecutively run different sets of rules against the same facts. 186 | Rules in different instances of Rools are perfectly isolated and can, of course, run against the same facts. 187 | 188 | Example: evaluate different sets of rules on the same facts 189 | 190 | ```javascript 191 | const facts = {...}; 192 | const rools1 = new Rools(); 193 | const rools2 = new Rools(); 194 | await rools1.register(...); // rule set 1 195 | await rools2.register(...); // rule set 2 196 | await rools1.evaluate(facts); 197 | await rools2.evaluate(facts); 198 | ``` 199 | 200 | ### Optimization I 201 | 202 | It is very common that different rules partially share the same premises. 203 | Rools will automatically merge identical premises into one. 204 | You are free to use references or just to repeat the same premise. 205 | Both options are working fine. 206 | 207 | Example 1: by reference 208 | 209 | ```javascript 210 | const isApplicable = (facts) => facts.user.salary >= 2000; 211 | const rule1 = new Rule({ 212 | when: [ 213 | isApplicable, 214 | ... 215 | ], 216 | ... 217 | }); 218 | const rule2 = new Rule({ 219 | when: [ 220 | isApplicable, 221 | ... 222 | ], 223 | ... 224 | }); 225 | ``` 226 | 227 | Example 2: repeat premise 228 | 229 | ```javascript 230 | const rule1 = new Rule({ 231 | when: [ 232 | (facts) => facts.user.salary >= 2000, 233 | ... 234 | ], 235 | ... 236 | }); 237 | const rule2 = new Rule({ 238 | when: [ 239 | (facts) => facts.user.salary >= 2000, 240 | ... 241 | ], 242 | ... 243 | }); 244 | ``` 245 | 246 | Furthermore, it is recommended to de-compose premises with AND relations (`&&`). 247 | For example: 248 | 249 | ```javascript 250 | // this version works... 251 | const rule = new Rule({ 252 | when: (facts) => facts.user.salary >= 2000 && facts.user.age > 25, 253 | ... 254 | }); 255 | // however, it's better to write it like this... 256 | const rule = new Rule({ 257 | when: [ 258 | (facts) => facts.user.salary >= 2000, 259 | (facts) => facts.user.age > 25, 260 | ], 261 | ... 262 | }); 263 | ``` 264 | 265 | ### Optimization II 266 | 267 | When actions fire, changes are made to the facts. 268 | This requires re-evaluation of the premises. 269 | Which may lead to further actions becoming ready to fire. 270 | 271 | To avoid complete re-evaluation of all premises each time changes are made to the facts, Rools detects the parts of the facts (segments) that were actually changed and re-evaluates only those premises affected. 272 | 273 | Change detection is based on *level 1 of the facts*. In the example below, detected changes are based on `user`, `weather`, `posts` and so on. So, whenever a `user` detail changes, all premises and actions that rely on `user` are re-evaluated. But only those. 274 | 275 | ```javascript 276 | const facts = { 277 | user: { ... }, 278 | weather: { ... }, 279 | posts: { ... }, 280 | ... 281 | }; 282 | ... 283 | await rools.evaluate(facts); 284 | ``` 285 | 286 | This optimization targets runtime performance. 287 | It unfolds its full potential with a growing number of rules and fact segments. 288 | 289 | ## Dos and don'ts 290 | 291 | ### Be careful with non-local variables in premises 292 | 293 | Ideally, premises (`when`) are "pure functions" referring to `facts` only. 294 | They should not refer to any other non-local variables. 295 | 296 | If they do so, however, please note that non-local variables are resolved at 297 | evaluation time (`evaluate()`) and *not* at registration time (`register()`). 298 | 299 | Furthermore, please make sure that non-local variables are constant/stable 300 | during evaluation. Otherwise, premises are not working deterministically. 301 | 302 | In the example below, Rools will treat the two premises as identical 303 | assuming that both rules are referring to the exact same `value`. 304 | 305 | ```javascript 306 | let value = 2000; 307 | const rule1 = new Rule({ 308 | when: (facts) => facts.user.salary >= value, 309 | ... 310 | }); 311 | value = 3000; 312 | const rule2 = new Rule({ 313 | when: (facts) => facts.user.salary >= value, 314 | ... 315 | }); 316 | ``` 317 | 318 | ### Don't "generate" rules / Don't create rules in closures 319 | 320 | The example below does not work! 321 | Rools would treat all premises (`when`) as identical 322 | assuming that all rules are referring to the exact same `value`. 323 | 324 | ```javascript 325 | const createRule = (value) => { 326 | rules.push(new Rule({ 327 | name: `Rule evaluating ${value}`, 328 | when: (facts) => facts.foo >= value, 329 | then: (facts) => // ... 330 | })); 331 | }; 332 | ``` 333 | 334 | ### Don't mix premises and actions 335 | 336 | Make sure not to mix premises and actions. 337 | In the example below, the if condition should be in `when`, *not* in `then`. 338 | If you have such cases, think about splitting rules or extending rules. 339 | 340 | ```javascript 341 | const rule = new Rule({ 342 | name: "rule", 343 | when: // ... 344 | then: (facts) => { 345 | if (facts.foo < 0) { // not good here! 346 | // ... 347 | } 348 | }, 349 | }); 350 | ``` 351 | 352 | ## Interface 353 | 354 | ### Create rule engine: `new Rools()` 355 | 356 | Calling `new Rools()` creates a new Rools instance, i.e., a new rule engine. 357 | You usually do this once for a given set of rules. 358 | 359 | Example: 360 | 361 | ```javascript 362 | const { Rools } = require('rools'); 363 | const rools = new Rools(); 364 | ... 365 | ``` 366 | 367 | ### Register rules: `register()` 368 | 369 | Rules are created through `new Rule()` with the following properties: 370 | 371 | | Property | Required | Default | Description | 372 | |-------------|----------|---------|-------------| 373 | | `name` | yes | - | A string value identifying the rule. This is used for logging and debugging purposes only. | 374 | | `when` | yes | - | A synchronous JavaScript function or an array of functions. These are the premises of your rule. The functions' interface is `(facts) => { ... }`. They must return a boolean value. | 375 | | `then` | yes | - | A synchronous or asynchronous JavaScript function to be executed when the rule fires. The function's interface is `(facts) => { ... }` or `async (facts) => { ... }`. | 376 | | `priority` | no | `0` | If during `evaluate()` there is more than one rule ready to fire, i.e., the conflict set is greater 1, rules with higher priority will fire first. Negative values are supported. | 377 | | `final` | no | `false` | Marks a rule as final. If during `evaluate()` a final rule fires, the engine will stop the evaluation. | 378 | | `extend` | no | [] | A reference to a rule or an array of rules. The new rule will inherit all premises from its parents (and their parents). | 379 | | `activationGroup` | no | - | A string identifying an activation group. Only one rule within an activation group will fire. | 380 | 381 | Rules access the facts in both, premises (`when`) and actions (`then`). 382 | They can access properties directly, e.g., `facts.user.salary`, 383 | or through getters and setters if applicable, e.g., `facts.user.getSalary()`. 384 | 385 | `register()` registers one or more rules to the rule engine. 386 | It can be called multiple time. 387 | New rules will become effective immediately. 388 | 389 | `register()` is working asynchronously, i.e., it returns a promise. 390 | If this promise is rejected, the affected Rools instance is inconsistent and should no longer be used. 391 | 392 | Example: 393 | 394 | ```javascript 395 | const { Rools, Rule } = require('rools'); 396 | const ruleMoodGreat = new Rule({ 397 | name: 'mood is great if 200 stars or more', 398 | when: (facts) => facts.user.stars >= 200, 399 | then: (facts) => { 400 | facts.user.mood = 'great'; 401 | }, 402 | }); 403 | const ruleGoWalking = new Rule({ 404 | name: 'go for a walk if mood is great and the weather is fine', 405 | when: [ 406 | (facts) => facts.user.mood === 'great', 407 | (facts) => facts.weather.temperature >= 20, 408 | (facts) => !facts.weather.rainy, 409 | ], 410 | then: (facts) => { 411 | facts.goWalking = true; 412 | }, 413 | }); 414 | const rools = new Rools(); 415 | await rools.register([ruleMoodGreat, ruleGoWalking]); 416 | ``` 417 | 418 | ### Evaluate facts: `evaluate()` 419 | 420 | Facts are plain JavaScript or JSON objects or objects from ES6 classes with getters and setters. 421 | For example: 422 | 423 | ```javascript 424 | const user = { 425 | name: 'frank', 426 | stars: 347, 427 | }; 428 | const weather = { 429 | temperature: 20, 430 | windy: true, 431 | rainy: false, 432 | }; 433 | const rools = new Rools(); 434 | await rools.register(...); 435 | await rools.evaluate({ user, weather }); 436 | ``` 437 | 438 | Please note that Rools reads the facts (`when`) as well as writes to the facts (`then`) during evaluation. 439 | Please make sure you provide a fresh set of facts whenever you call `evaluate()`. 440 | 441 | `evaluate()` is working asynchronously, i.e., it returns a promise. 442 | If a premise (`when`) fails, `evaluate()` will still *not* fail (for robustness reasons). 443 | If an action (`then`) fails, `evaluate()` will reject its promise. 444 | 445 | If there is more than one rule ready to fire, Rools applies a *conflict resolution strategy* to decide which rule/action to fire first. The default conflict resolution strategy is 'ps'. 446 | 447 | * 'ps' -- (1) priority, (2) specificity, (3) order of registration 448 | * 'sp' -- (1) specificity, (2) priority, (3) order of registration 449 | 450 | If you don't like the default 'ps', you can change the conflict resolution strategy like this: 451 | 452 | ```javascript 453 | await rools.evaluate(facts, { strategy: 'sp' }); 454 | ``` 455 | 456 | `evaluate()` returns an object providing some debug information about the past evaluation run: 457 | 458 | * `fired` -- the number of rules that were fired. 459 | * `elapsed` -- the number of milliseconds needed. 460 | * `accessedByPremises` -- the fact segments that were accessed by premises (`when`). 461 | * `accessedByActions` -- the fact segments that were accessed by actions (`then`). Formerly `updated` but renamed for clarification (but still provided for backward compatibility). 462 | 463 | ```javascript 464 | const { accessedByActions, fired, elapsed } = await rools.evaluate(facts); 465 | console.log(accessedByActions, fired, elapsed); // e.g., ["user"] 26 187 466 | ``` 467 | 468 | ### Logging 469 | 470 | By default, Rools is logging errors to the JavaScript `console`. 471 | This can be configured like this. 472 | 473 | ```javascript 474 | const delegate = ({ level, message, rule, error }) => { 475 | console.error(level, message, rule, error); 476 | }; 477 | const rools = new Rools({ 478 | logging: { error: true, debug: false, delegate }, 479 | }); 480 | ... 481 | ``` 482 | 483 | `level` is either `debug` or `error`. 484 | The error log reports failed actions or premises. 485 | The debug log reports the entire evaluation process for debugging purposes. 486 | 487 | ## TypeScript 488 | 489 | This package provides types for TypeScript. 490 | 491 | ```typescript 492 | import { Rools, Rule } from "rools"; 493 | 494 | // ... 495 | ``` 496 | 497 | For this module to work, your **TypeScript compiler options** must include 498 | `"target": "ES2015"` (or later), `"moduleResolution": "node"`, and 499 | `"esModuleInterop": true`. 500 | 501 | ## Migration 502 | 503 | ### Version 1.x.x to Version 2.x.x 504 | 505 | There are a few breaking changes that require changes to your code. 506 | 507 | Rools exposes now two classes, `Rools` and `Rule`. 508 | 509 | ```javascript 510 | // Version 1.x.x 511 | const Rools = require('rools'); 512 | // Version 2.x.x 513 | const { Rools, Rule } = require('rools'); 514 | ``` 515 | 516 | Rules must now be created with `new Rule()`. 517 | 518 | ```javascript 519 | // Version 1.x.x 520 | const rule = { 521 | name: 'my rule', 522 | ... 523 | }; 524 | // Version 2.x.x 525 | const rule = new Rule({ 526 | name: 'my rule', 527 | ... 528 | }); 529 | ``` 530 | 531 | `register()` takes the rules to be registered as an array now. 532 | Reason is to allow a second options parameter in future releases. 533 | 534 | ```javascript 535 | const rools = new Rools(); 536 | ... 537 | // Version 1.x.x 538 | await rools.register(rule1, rule2, rule3); 539 | // Version 2.x.x 540 | await rools.register([rule1, rule2, rule3]); 541 | ``` 542 | 543 | `evaluate()` does not return the facts anymore - which was only for convenience anyway. 544 | Instead, it returns an object with some useful information. 545 | 546 | ```javascript 547 | const rools = new Rools(); 548 | ... 549 | // Version 1.x.x 550 | const facts = await rools.evaluate({ user, weather }); 551 | // Version 2.x.x 552 | const { updated, fired, elapsed } = await rools.evaluate({ user, weather }); 553 | console.log(updated, fired, elapsed); // e.g., ["user"] 26 187 554 | ``` 555 | --------------------------------------------------------------------------------