diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 233ceeb..3b4ab47 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -5,7 +5,7 @@ name: Deploy Package on: push: - branches: [ master, v6, v8 ] + branches: [master, v6, v8] jobs: build: diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index fc4b90d..d5c0825 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -5,7 +5,7 @@ name: Node.js CI on: pull_request: - branches: [ master, v6, v8 ] + branches: [master, v6, v8] jobs: build: diff --git a/examples/01-hello-world.mts b/examples/01-hello-world.mts index f0cc1f5..f7d1483 100644 --- a/examples/01-hello-world.mts +++ b/examples/01-hello-world.mts @@ -50,7 +50,7 @@ async function start() { // engine.run() evaluates the rule using the facts provided const { events } = await engine.run(facts); - events.map((event) => console.log(event.params!.data.green)); + events.map((event) => console.log(`${event.params!.data}`.green)); } start(); diff --git a/examples/02-nested-boolean-logic.mts b/examples/02-nested-boolean-logic.mts index c596e9c..cf9f3a5 100644 --- a/examples/02-nested-boolean-logic.mts +++ b/examples/02-nested-boolean-logic.mts @@ -76,7 +76,7 @@ async function start() { const { events } = await engine.run(facts); - events.map((event) => console.log(event.params!.message.red)); + events.map((event) => console.log(`${event.params!.message}`.red)); } start(); /* diff --git a/examples/09-rule-results.mts b/examples/09-rule-results.mts index 48a5693..4cabc45 100644 --- a/examples/09-rule-results.mts +++ b/examples/09-rule-results.mts @@ -8,7 +8,12 @@ * DEBUG=json-rules-engine node ./examples/09-rule-results.js */ import "colors"; -import { Engine, NestedCondition, RuleResult } from "json-rules-engine"; +import { + Engine, + NestedCondition, + NestedConditionResult, + RuleResult, +} from "json-rules-engine"; async function start() { /** @@ -48,14 +53,16 @@ async function start() { return console.log(`${message}`.green); } // if rule failed, iterate over each failed condition to determine why the student didn't qualify for athletics honor roll - const detail = (ruleResult.conditions as { all: NestedCondition[] }).all - .filter((condition) => !(condition as { result?: boolean }).result) + const detail = ( + ruleResult.conditions as { all: NestedConditionResult[] } + ).all + .filter(({ result }) => !result) .map((condition) => { - switch ((condition as { operator?: string }).operator) { + switch (condition.operator) { case "equal": - return `was not an ${(condition as { fact?: string }).fact}`; + return `was not an ${condition.fact}`; case "greaterThanInclusive": - return `${(condition as { fact: string }).fact} of ${(condition as { factResult?: unknown }).factResult} was too low`; + return `${condition.fact} of ${condition.factResult} was too low`; default: return ""; } diff --git a/package.json b/package.json index ed2a94b..971d6c8 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Rules Engine expressed in simple json", "main": "dist/index.cjs", "module": "dist/index.js", - "types": "types/index.d.ts", + "types": "dist/index.d.ts", "type": "module", "engines": { "node": ">=18.0.0" @@ -40,6 +40,7 @@ "homepage": "https://github.com/cachecontrol/json-rules-engine", "devDependencies": { "@eslint/js": "^9.13.0", + "@types/node": "^22.8.2", "eslint": "^9.13.0", "globals": "^15.11.0", "lodash": "4.17.21", @@ -52,8 +53,6 @@ "vitest": "^2.1.3" }, "dependencies": { - "clone": "^2.1.2", - "eventemitter2": "^6.4.4", "hash-it": "^6.0.0", "jsonpath-plus": "^10.0.0" } diff --git a/src/almanac.mjs b/src/almanac.mjs deleted file mode 100644 index 9963406..0000000 --- a/src/almanac.mjs +++ /dev/null @@ -1,200 +0,0 @@ -import Fact from "./fact.mjs"; -import { UndefinedFactError } from "./errors.mjs"; -import debug from "./debug.mjs"; - -import { JSONPath } from "jsonpath-plus"; - -function defaultPathResolver(value, path) { - return JSONPath({ path, json: value, wrap: false }); -} - -/** - * Fact results lookup - * Triggers fact computations and saves the results - * A new almanac is used for every engine run() - */ -export default class Almanac { - constructor(options = {}) { - this.factMap = new Map(); - this.factResultsCache = new Map(); // { cacheKey: Promise } - this.allowUndefinedFacts = Boolean(options.allowUndefinedFacts); - this.pathResolver = options.pathResolver || defaultPathResolver; - this.events = { success: [], failure: [] }; - this.ruleResults = []; - } - - /** - * Adds a success event - * @param {Object} event - */ - addEvent(event, outcome) { - if (!outcome) throw new Error('outcome required: "success" | "failure"]'); - this.events[outcome].push(event); - } - - /** - * retrieve successful events - */ - getEvents(outcome = "") { - if (outcome) return this.events[outcome]; - return this.events.success.concat(this.events.failure); - } - - /** - * Adds a rule result - * @param {Object} event - */ - addResult(ruleResult) { - this.ruleResults.push(ruleResult); - } - - /** - * retrieve successful events - */ - getResults() { - return this.ruleResults; - } - - /** - * Retrieve fact by id, raising an exception if it DNE - * @param {String} factId - * @return {Fact} - */ - _getFact(factId) { - return this.factMap.get(factId); - } - - /** - * Registers fact with the almanac - * @param {[type]} fact [description] - */ - _addConstantFact(fact) { - this.factMap.set(fact.id, fact); - this._setFactValue(fact, {}, fact.value); - } - - /** - * Sets the computed value of a fact - * @param {Fact} fact - * @param {Object} params - values for differentiating this fact value from others, used for cache key - * @param {Mixed} value - computed value - */ - _setFactValue(fact, params, value) { - const cacheKey = fact.getCacheKey(params); - const factValue = Promise.resolve(value); - if (cacheKey) { - this.factResultsCache.set(cacheKey, factValue); - } - return factValue; - } - - /** - * Add a fact definition to the engine. Facts are called by rules as they are evaluated. - * @param {object|Fact} id - fact identifier or instance of Fact - * @param {function} definitionFunc - function to be called when computing the fact value for a given rule - * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance - */ - addFact(id, valueOrMethod, options) { - let factId = id; - let fact; - if (id instanceof Fact) { - factId = id.id; - fact = id; - } else { - fact = new Fact(id, valueOrMethod, options); - } - debug("almanac::addFact", { id: factId }); - this.factMap.set(factId, fact); - if (fact.isConstant()) { - this._setFactValue(fact, {}, fact.value); - } - return this; - } - - /** - * Adds a constant fact during runtime. Can be used mid-run() to add additional information - * @deprecated use addFact - * @param {String} fact - fact identifier - * @param {Mixed} value - constant value of the fact - */ - addRuntimeFact(factId, value) { - debug("almanac::addRuntimeFact", { id: factId }); - const fact = new Fact(factId, value); - return this._addConstantFact(fact); - } - - /** - * Returns the value of a fact, based on the given parameters. Utilizes the 'almanac' maintained - * by the engine, which cache's fact computations based on parameters provided - * @param {string} factId - fact identifier - * @param {Object} params - parameters to feed into the fact. By default, these will also be used to compute the cache key - * @param {String} path - object - * @return {Promise} a promise which will resolve with the fact computation. - */ - factValue(factId, params = {}, path = "") { - let factValuePromise; - const fact = this._getFact(factId); - if (fact === undefined) { - if (this.allowUndefinedFacts) { - return Promise.resolve(undefined); - } else { - return Promise.reject( - new UndefinedFactError(`Undefined fact: ${factId}`), - ); - } - } - if (fact.isConstant()) { - factValuePromise = Promise.resolve(fact.calculate(params, this)); - } else { - const cacheKey = fact.getCacheKey(params); - const cacheVal = cacheKey && this.factResultsCache.get(cacheKey); - if (cacheVal) { - factValuePromise = Promise.resolve(cacheVal); - debug("almanac::factValue cache hit for fact", { id: factId }); - } else { - debug("almanac::factValue cache miss, calculating", { id: factId }); - factValuePromise = this._setFactValue( - fact, - params, - fact.calculate(params, this), - ); - } - } - if (path) { - debug("condition::evaluate extracting object", { property: path }); - return factValuePromise.then((factValue) => { - if (factValue != null && typeof factValue === "object") { - const pathValue = this.pathResolver(factValue, path); - debug("condition::evaluate extracting object", { - property: path, - received: pathValue, - }); - return pathValue; - } else { - debug( - "condition::evaluate could not compute object path of non-object", - { path, factValue, type: typeof factValue }, - ); - return factValue; - } - }); - } - - return factValuePromise; - } - - /** - * Interprets value as either a primitive, or if a fact, retrieves the fact value - */ - getValue(value) { - if ( - value != null && - typeof value === "object" && - Object.prototype.hasOwnProperty.call(value, "fact") - ) { - // value = { fact: 'xyz' } - return this.factValue(value.fact, value.params, value.path); - } - return Promise.resolve(value); - } -} diff --git a/src/almanac.mts b/src/almanac.mts new file mode 100644 index 0000000..9a725e8 --- /dev/null +++ b/src/almanac.mts @@ -0,0 +1,188 @@ +import { type DynamicFactCallback, Fact, type FactOptions } from "./fact.mjs"; +import { UndefinedFactError } from "./errors.mjs"; +import debug from "./debug.mjs"; +import { JSONPath } from "jsonpath-plus"; +import { RuleResult } from "./rule.mjs"; +import { Event } from "./events.mjs"; + +export type PathResolver = (value: object, path: string) => unknown; + +function defaultPathResolver(value: object, path: string): unknown { + return JSONPath({ path, json: value, wrap: false }); +} + +export interface AlmanacOptions { + allowUndefinedFacts?: boolean; + pathResolver?: PathResolver; +} + +/** + * Fact results lookup + * Triggers fact computations and saves the results + * A new almanac is used for every engine run() + */ +export class Almanac { + readonly #factMap = new Map(); + readonly #factResultsCache = new Map>(); // { cacheKey: Promise } + readonly #allowUndefinedFacts: boolean; + readonly #pathResolver: PathResolver; + readonly #events = { + success: [] as Event[], + failure: [] as Event[], + }; + readonly #ruleResults: RuleResult[] = []; + + constructor(options: AlmanacOptions = {}) { + this.#allowUndefinedFacts = Boolean(options.allowUndefinedFacts); + this.#pathResolver = options.pathResolver ?? defaultPathResolver; + } + + /** + * Adds a success event + * @param event + */ + addEvent(event: Event, outcome: "success" | "failure") { + if (!outcome) throw new Error('outcome required: "success" | "failure"]'); + this.#events[outcome].push(event); + } + + /** + * retrieve successful events + */ + getEvents(outcome?: "success" | "failure"): Event[] { + if (outcome) return this.#events[outcome]; + return this.#events.success.concat(this.#events.failure); + } + + /** + * Adds a rule result + * @param ruleResult the result of running a set of rules + */ + addResult(ruleResult: RuleResult): void { + this.#ruleResults.push(ruleResult); + } + + /** + * retrieve successful events + */ + getResults(): RuleResult[] { + return this.#ruleResults; + } + + /** + * Retrieve fact by id, raising an exception if it DNE + * @param {String} factId + * @return {Fact} + */ + getFact(factId: string): Fact | undefined { + return this.#factMap.get(factId); + } + + /** + * * Add a fact definition to the engine. Facts are called by rules as they are evaluated. + * @param fact - the instance of the fact to add + */ + addFact(fact: Fact): this; + /** + * Add a fact definition to the engine. Facts are called by rules as they are evaluated. + * @param id - fact identifier + * @param definitionFunc - function to be called when computing the fact value for a given rule + * @param options - options to initialize the fact with. + */ + addFact( + id: string, + valueOrMethod: T | DynamicFactCallback, + options?: FactOptions, + ): this; + addFact( + idOrFact: string | Fact, + valueOrMethod?: T | DynamicFactCallback, + options?: FactOptions, + ): this { + const fact = + idOrFact instanceof Fact + ? idOrFact + : new Fact(idOrFact, valueOrMethod!, options); + + debug("almanac::addFact", { id: fact.id }); + this.#factMap.set(fact.id, fact); + return this; + } + + /** + * Returns the value of a fact, based on the given parameters. Utilizes the 'almanac' maintained + * by the engine, which cache's fact computations based on parameters provided + * @param factId - fact identifier + * @param params - parameters to feed into the fact. By default, these will also be used to compute the cache key + * @param path - object + * @return a promise which will resolve with the fact computation. + */ + async factValue( + factId: string, + params: Record = {}, + path: string = "", + ): Promise { + const fact = this.getFact(factId); + if (fact === undefined) { + if (this.#allowUndefinedFacts) { + return undefined as T; + } else { + throw new UndefinedFactError(`Undefined fact: ${factId}`); + } + } + let factValuePromise: Promise; + const cacheKey = fact.getCacheKey(params); + if (cacheKey !== null) { + const cacheVal = this.#factResultsCache.get(cacheKey); + if (cacheVal) { + factValuePromise = cacheVal; + debug("almanac::factValue cache hit for fact", { id: factId }); + } else { + debug("almanac::factValue cache miss, calculating", { id: factId }); + factValuePromise = Promise.resolve(fact.calculate(params, this)); + this.#factResultsCache.set(cacheKey, factValuePromise); + } + } else { + factValuePromise = Promise.resolve(fact.calculate(params, this)); + } + if (path) { + debug("condition::evaluate extracting object", { property: path }); + const factValue = await factValuePromise; + if (factValue != null && typeof factValue === "object") { + const pathValue = this.#pathResolver(factValue, path); + debug("condition::evaluate extracting object", { + property: path, + received: pathValue, + }); + return pathValue as T; + } else { + debug( + "condition::evaluate could not compute object path of non-object", + { path, factValue, type: typeof factValue }, + ); + } + } + + return factValuePromise as Promise; + } + + /** + * Interprets value as either a primitive, or if a fact, retrieves the fact value + */ + getValue(value: unknown): Promise { + if ( + value != null && + typeof value === "object" && + "fact" in value && + typeof value.fact === "string" + ) { + // value = { fact: 'xyz' } + return this.factValue( + value.fact, + (value as { params?: Record }).params, + (value as { path?: string }).path, + ); + } + return Promise.resolve(value); + } +} diff --git a/src/condition.mjs b/src/condition.mjs deleted file mode 100644 index 4f0ca65..0000000 --- a/src/condition.mjs +++ /dev/null @@ -1,169 +0,0 @@ -import debug from "./debug.mjs"; - -export default class Condition { - constructor(properties) { - if (!properties) throw new Error("Condition: constructor options required"); - const booleanOperator = Condition.booleanOperator(properties); - Object.assign(this, properties); - if (booleanOperator) { - const subConditions = properties[booleanOperator]; - const subConditionsIsArray = Array.isArray(subConditions); - if (booleanOperator !== "not" && !subConditionsIsArray) { - throw new Error(`"${booleanOperator}" must be an array`); - } - if (booleanOperator === "not" && subConditionsIsArray) { - throw new Error(`"${booleanOperator}" cannot be an array`); - } - this.operator = booleanOperator; - // boolean conditions always have a priority; default 1 - this.priority = parseInt(properties.priority, 10) || 1; - if (subConditionsIsArray) { - this[booleanOperator] = subConditions.map((c) => new Condition(c)); - } else { - this[booleanOperator] = new Condition(subConditions); - } - } else if (!Object.prototype.hasOwnProperty.call(properties, "condition")) { - if (!Object.prototype.hasOwnProperty.call(properties, "fact")) { - throw new Error('Condition: constructor "fact" property required'); - } - if (!Object.prototype.hasOwnProperty.call(properties, "operator")) { - throw new Error('Condition: constructor "operator" property required'); - } - if (!Object.prototype.hasOwnProperty.call(properties, "value")) { - throw new Error('Condition: constructor "value" property required'); - } - - // a non-boolean condition does not have a priority by default. this allows - // priority to be dictated by the fact definition - if (Object.prototype.hasOwnProperty.call(properties, "priority")) { - properties.priority = parseInt(properties.priority, 10); - } - } - } - - /** - * Converts the condition into a json-friendly structure - * @param {Boolean} stringify - whether to return as a json string - * @returns {string,object} json string or json-friendly object - */ - toJSON(stringify = true) { - const props = {}; - if (this.priority) { - props.priority = this.priority; - } - if (this.name) { - props.name = this.name; - } - const oper = Condition.booleanOperator(this); - if (oper) { - if (Array.isArray(this[oper])) { - props[oper] = this[oper].map((c) => c.toJSON(false)); - } else { - props[oper] = this[oper].toJSON(false); - } - } else if (this.isConditionReference()) { - props.condition = this.condition; - } else { - props.operator = this.operator; - props.value = this.value; - props.fact = this.fact; - if (this.factResult !== undefined) { - props.factResult = this.factResult; - } - if (this.result !== undefined) { - props.result = this.result; - } - if (this.params) { - props.params = this.params; - } - if (this.path) { - props.path = this.path; - } - } - if (stringify) { - return JSON.stringify(props); - } - return props; - } - - /** - * Takes the fact result and compares it to the condition 'value', using the operator - * LHS OPER RHS - * - * - * @param {Almanac} almanac - * @param {Map} operatorMap - map of available operators, keyed by operator name - * @returns {Boolean} - evaluation result - */ - evaluate(almanac, operatorMap) { - if (!almanac) return Promise.reject(new Error("almanac required")); - if (!operatorMap) return Promise.reject(new Error("operatorMap required")); - if (this.isBooleanOperator()) { - return Promise.reject(new Error("Cannot evaluate() a boolean condition")); - } - - const op = operatorMap.get(this.operator); - if (!op) { - return Promise.reject(new Error(`Unknown operator: ${this.operator}`)); - } - - return Promise.all([ - almanac.getValue(this.value), - almanac.factValue(this.fact, this.params, this.path), - ]).then(([rightHandSideValue, leftHandSideValue]) => { - const result = op.evaluate(leftHandSideValue, rightHandSideValue); - debug("condition::evaluate", { - leftHandSideValue, - operator: this.operator, - rightHandSideValue, - result, - }); - return { - result, - leftHandSideValue, - rightHandSideValue, - operator: this.operator, - }; - }); - } - - /** - * Returns the boolean operator for the condition - * If the condition is not a boolean condition, the result will be 'undefined' - * @return {string 'all', 'any', or 'not'} - */ - static booleanOperator(condition) { - if (Object.prototype.hasOwnProperty.call(condition, "any")) { - return "any"; - } else if (Object.prototype.hasOwnProperty.call(condition, "all")) { - return "all"; - } else if (Object.prototype.hasOwnProperty.call(condition, "not")) { - return "not"; - } - } - - /** - * Returns the condition's boolean operator - * Instance version of Condition.isBooleanOperator - * @returns {string,undefined} - 'any', 'all', 'not' or undefined (if not a boolean condition) - */ - booleanOperator() { - return Condition.booleanOperator(this); - } - - /** - * Whether the operator is boolean ('all', 'any', 'not') - * @returns {Boolean} - */ - isBooleanOperator() { - return Condition.booleanOperator(this) !== undefined; - } - - /** - * Whether the condition represents a reference to a condition - * @returns {Boolean} - */ - isConditionReference() { - return Object.prototype.hasOwnProperty.call(this, "condition"); - } -} diff --git a/src/condition/all.mts b/src/condition/all.mts new file mode 100644 index 0000000..11b480a --- /dev/null +++ b/src/condition/all.mts @@ -0,0 +1,92 @@ +import { Almanac } from "../almanac.mjs"; +import debug from "../debug.mjs"; +import { OperatorMap } from "../operator-map.mjs"; +import { + NestedConditionInstance, + NestedCondition, + NestedConditionResult, +} from "./index.mjs"; +import { prioritize } from "./prioritize.mjs"; +import { ConditionMap } from "./reference.mjs"; + +export interface AllConditionProperties { + name?: string; + priority?: number; + all: NestedCondition[]; +} + +export interface AllConditionResult { + name?: string; + priority: number; + operator: "all"; + all: NestedConditionResult[]; + result: boolean; +} + +export class AllCondition { + readonly name?: string; + readonly priority: number; + readonly #all: NestedConditionInstance[]; + + constructor( + properties: AllConditionProperties, + factory: (properties: NestedCondition) => NestedConditionInstance, + ) { + this.name = properties.name; + this.priority = parseInt( + (properties.priority ?? 1) as unknown as string, + 10, + ); + this.#all = properties.all.map((p) => factory(p)); + } + + toJSON(): AllConditionProperties { + return { + name: this.name, + priority: this.priority, + all: this.#all.map((c) => c.toJSON()), + }; + } + + async evaluate( + almanac: Almanac, + operatorMap: OperatorMap, + conditionMap: ConditionMap, + ): Promise { + if (this.#all.length === 0) { + return { + name: this.name, + priority: this.priority, + operator: "all", + all: [], + result: true, + }; + } + if (!almanac) throw new Error("almanac required"); + const all: NestedConditionResult[] = []; + for (const conditions of prioritize(this.#all, almanac)) { + const results = await Promise.all( + conditions.map((c) => c.evaluate(almanac, operatorMap, conditionMap)), + ); + debug("AllCondition::evaluate", { results }); + all.push(...results); + const result = results.every(({ result }) => result); + if (!result) { + return { + name: this.name, + priority: this.priority, + operator: "all", + all, + result, + }; + } + } + return { + name: this.name, + priority: this.priority, + operator: "all", + all, + result: true, + }; + } +} diff --git a/src/condition/any.mts b/src/condition/any.mts new file mode 100644 index 0000000..ff466a1 --- /dev/null +++ b/src/condition/any.mts @@ -0,0 +1,92 @@ +import { Almanac } from "../almanac.mjs"; +import debug from "../debug.mjs"; +import { OperatorMap } from "../operator-map.mjs"; +import { + NestedConditionInstance, + NestedCondition, + NestedConditionResult, +} from "./index.mjs"; +import { prioritize } from "./prioritize.mjs"; +import { ConditionMap } from "./reference.mjs"; + +export interface AnyConditionProperties { + name?: string; + priority?: number; + any: NestedCondition[]; +} + +export interface AnyConditionResult { + name?: string; + priority: number; + operator: "any"; + any: NestedConditionResult[]; + result: boolean; +} + +export class AnyCondition { + readonly name?: string; + readonly priority: number; + readonly #any: NestedConditionInstance[]; + + constructor( + properties: AnyConditionProperties, + factory: (properties: NestedCondition) => NestedConditionInstance, + ) { + this.name = properties.name; + this.priority = parseInt( + (properties.priority ?? 1) as unknown as string, + 10, + ); + this.#any = properties.any.map((p) => factory(p)); + } + + toJSON(): AnyConditionProperties { + return { + name: this.name, + priority: this.priority, + any: this.#any.map((c) => c.toJSON()), + }; + } + + async evaluate( + almanac: Almanac, + operatorMap: OperatorMap, + conditionMap: ConditionMap, + ): Promise { + if (this.#any.length === 0) { + return { + name: this.name, + priority: this.priority, + operator: "any", + any: [], + result: true, + }; + } + if (!almanac) throw new Error("almanac required"); + const any: NestedConditionResult[] = []; + for (const conditions of prioritize(this.#any, almanac)) { + const results = await Promise.all( + conditions.map((c) => c.evaluate(almanac, operatorMap, conditionMap)), + ); + debug("AnyCondition::evaluate", { results }); + any.push(...results); + const result = results.some(({ result }) => result); + if (result) { + return { + name: this.name, + priority: this.priority, + operator: "any", + any, + result, + }; + } + } + return { + name: this.name, + priority: this.priority, + operator: "any", + any, + result: false, + }; + } +} diff --git a/src/condition/comparison.mts b/src/condition/comparison.mts new file mode 100644 index 0000000..12da9dc --- /dev/null +++ b/src/condition/comparison.mts @@ -0,0 +1,95 @@ +import { Almanac } from "../almanac.mjs"; +import debug from "../debug.mjs"; +import { OperatorMap } from "../operator-map.mjs"; + +export interface ComparisonConditionProperties { + name?: string; + priority?: number; + fact: string; + params?: Record; + path?: string; + operator: string; + value: unknown; +} + +export interface ComparisonConditionResult { + name?: string; + priority?: number; + fact: string; + params?: Record; + path?: string; + factResult: unknown; + operator: string; + value: unknown; + valueResult: unknown; + result: boolean; +} + +export class ComparisonCondition { + readonly name?: string; + readonly priority?: number; + readonly fact: string; + readonly #params?: Record; + readonly #path?: string; + readonly #operator: string; + readonly #value: unknown; + + constructor(properties: ComparisonConditionProperties) { + this.name = properties.name; + this.priority = + properties.priority !== undefined + ? parseInt(properties.priority as unknown as string, 10) + : undefined; + this.fact = properties.fact; + this.#params = properties.params; + this.#path = properties.path; + this.#operator = properties.operator; + this.#value = properties.value; + } + + toJSON(): ComparisonConditionProperties { + return { + name: this.name, + priority: this.priority, + fact: this.fact, + params: this.#params, + path: this.#path, + operator: this.#operator, + value: this.#value, + }; + } + + async evaluate( + almanac: Almanac, + operatorMap: OperatorMap, + ): Promise { + if (!almanac) return Promise.reject(new Error("almanac required")); + if (!operatorMap) return Promise.reject(new Error("operatorMap required")); + + const op = operatorMap.get(this.#operator); + const [valueResult, factResult] = await Promise.all([ + almanac.getValue(this.#value), + almanac.factValue(this.fact, this.#params, this.#path), + ]); + + const result = op.evaluate(factResult, valueResult); + debug("condition::evaluate", { + factResult, + operator: op.name, + valueResult, + result, + }); + return { + name: this.name, + priority: this.priority, + fact: this.fact, + params: this.#params, + path: this.#path, + factResult, + operator: op.name, + value: this.#value, + valueResult, + result, + }; + } +} diff --git a/src/condition/index.mts b/src/condition/index.mts new file mode 100644 index 0000000..f327a36 --- /dev/null +++ b/src/condition/index.mts @@ -0,0 +1,127 @@ +import { + AllCondition, + AllConditionProperties, + AllConditionResult, +} from "./all.mjs"; +import { + AnyCondition, + AnyConditionProperties, + AnyConditionResult, +} from "./any.mjs"; +import { + ComparisonCondition, + ComparisonConditionProperties, + ComparisonConditionResult, +} from "./comparison.mjs"; +import { + NeverCondition, + NeverConditionProperties, + NeverConditionResult, +} from "./never.mjs"; +import { + NotCondition, + NotConditionProperties, + NotConditionResult, +} from "./not.mjs"; +import { + ConditionReference, + ConditionReferenceProperties, +} from "./reference.mjs"; + +export type TopLevelCondition = + | AllConditionProperties + | AnyConditionProperties + | NotConditionProperties + | ConditionReferenceProperties + | NeverConditionProperties; +export type NestedCondition = TopLevelCondition | ComparisonConditionProperties; + +export type TopLevelConditionInstance = + | AllCondition + | AnyCondition + | NotCondition + | ConditionReference + | typeof NeverCondition; +export type NestedConditionInstance = + | TopLevelConditionInstance + | ComparisonCondition; + +export type TopLevelConditionResult = + | AllConditionResult + | AnyConditionResult + | NotConditionResult + | NeverConditionResult; +export type NestedConditionResult = + | TopLevelConditionResult + | ComparisonConditionResult; + +function nestedConditionFactory( + properties: NestedCondition, +): NestedConditionInstance { + if (!properties) throw new Error("Condition: factory properties required"); + if (typeof properties !== "object" || Array.isArray(properties)) { + throw new Error("Condition: factory properties must be an object"); + } + if ("never" in properties && properties.never === true) { + return NeverCondition; + } + if ( + "name" in properties && + properties.name !== undefined && + typeof properties.name !== "string" + ) { + throw new Error("Condition: factory properties.name must be a string"); + } + if ( + "priority" in properties && + properties.priority !== undefined && + typeof properties.priority !== "number" + ) { + throw new Error("Condition: factory properties.priority must be a number"); + } + if ("all" in properties) { + if (!Array.isArray(properties.all)) { + throw new Error("Condition: factory properties.all must be an array"); + } + return new AllCondition(properties, nestedConditionFactory); + } + if ("any" in properties) { + if (!Array.isArray(properties.any)) { + throw new Error("Condition: factory properties.any must be an array"); + } + return new AnyCondition(properties, nestedConditionFactory); + } + if ("not" in properties) { + return new NotCondition(properties, nestedConditionFactory); + } + if ("condition" in properties) { + if (typeof properties.condition !== "string") { + throw new Error( + "Condition: factory properties.condition must be a string", + ); + } + return new ConditionReference(properties); + } + + if (!("fact" in properties) || typeof properties.fact !== "string") { + throw new Error('Condition: constructor "fact" property required'); + } + + if (!("operator" in properties) || typeof properties.operator !== "string") { + throw new Error('Condition: constructor "operator" property required'); + } + + return new ComparisonCondition(properties); +} + +export function conditionFactory( + properties: TopLevelCondition, +): TopLevelConditionInstance { + const result = nestedConditionFactory(properties); + if (result instanceof ComparisonCondition) { + throw new Error( + '"conditions" root must contain a single instance of "all", "any", "not", or "condition"', + ); + } + return result; +} diff --git a/src/condition/never.mts b/src/condition/never.mts new file mode 100644 index 0000000..ed5c8cb --- /dev/null +++ b/src/condition/never.mts @@ -0,0 +1,22 @@ +export interface NeverConditionResult { + name?: undefined; + priority?: undefined; + result: false; + operator: "never"; +} + +export interface NeverConditionProperties { + operator: "never"; +} + +export const NeverCondition = { + priority: 1, + + toJSON(): NeverConditionProperties { + return { operator: "never" }; + }, + + evaluate(): Promise { + return Promise.resolve({ result: false, operator: "never" }); + }, +}; diff --git a/src/condition/not.mts b/src/condition/not.mts new file mode 100644 index 0000000..6ae68d6 --- /dev/null +++ b/src/condition/not.mts @@ -0,0 +1,75 @@ +import { Almanac } from "../almanac.mjs"; +import { OperatorMap } from "../operator-map.mjs"; +import { + NestedConditionInstance, + NestedCondition, + NestedConditionResult, +} from "./index.mjs"; +import { ConditionMap } from "./reference.mjs"; + +export interface NotConditionResult { + name?: string; + priority: number; + operator: "not"; + not?: NestedConditionResult; + result: boolean; +} + +export interface NotConditionProperties { + name?: string; + priority?: number; + not: NestedCondition; +} + +export class NotCondition { + readonly name?: string; + readonly priority: number; + readonly #not: NestedConditionInstance; + + constructor( + properties: NotConditionProperties, + factory: (properties: NestedCondition) => NestedConditionInstance, + ) { + this.name = properties.name; + this.priority = parseInt( + (properties.priority ?? 1) as unknown as string, + 10, + ); + this.#not = factory(properties.not); + } + + /** + * Converts the condition into a json-friendly structure + */ + toJSON(): NotConditionProperties { + return { + name: this.name, + priority: this.priority, + not: this.#not.toJSON(), + }; + } + + /** + * Takes the fact result and compares it to the condition 'value', using the operator + * LHS OPER RHS + * + * + * @param {Almanac} almanac + * @param {Map} operatorMap - map of available operators, keyed by operator name + * @returns {Boolean} - evaluation result + */ + async evaluate( + almanac: Almanac, + operatorMap: OperatorMap, + conditionMap: ConditionMap, + ): Promise { + const not = await this.#not.evaluate(almanac, operatorMap, conditionMap); + return { + name: this.name, + priority: this.priority, + operator: "not", + not, + result: !not.result, + }; + } +} diff --git a/src/condition/prioritize.mts b/src/condition/prioritize.mts new file mode 100644 index 0000000..908a882 --- /dev/null +++ b/src/condition/prioritize.mts @@ -0,0 +1,24 @@ +import { Almanac } from "../almanac.mjs"; +import { ComparisonCondition } from "./comparison.mjs"; +import { NestedConditionInstance } from "./index.mjs"; + +export function prioritize( + conditions: NestedConditionInstance[], + almanac: Almanac, +): NestedConditionInstance[][] { + return conditions + .reduce((byPriority, condition) => { + const priority = + condition.priority ?? + almanac.getFact((condition as ComparisonCondition).fact)?.priority ?? + 1; + const priorityList = byPriority.get(priority) ?? []; + priorityList.push(condition); + byPriority.set(priority, priorityList); + return byPriority; + }, new Map()) + .entries() + .toArray() + .sort(([a], [b]) => a - b) + .map(([, value]) => value); +} diff --git a/src/condition/reference.mts b/src/condition/reference.mts new file mode 100644 index 0000000..81aaf3e --- /dev/null +++ b/src/condition/reference.mts @@ -0,0 +1,51 @@ +import { Almanac } from "../almanac.mjs"; +import { OperatorMap } from "../operator-map.mjs"; +import { + TopLevelConditionInstance, + TopLevelConditionResult, +} from "./index.mjs"; + +export interface ConditionMap { + get(name: string): TopLevelConditionInstance; +} + +export interface ConditionReferenceProperties { + name?: string; + priority?: number; + condition: string; +} + +export class ConditionReference { + readonly name?: string; + readonly priority: number; + readonly #condition: string; + + constructor(properties: ConditionReferenceProperties) { + this.name = properties.name; + this.priority = parseInt( + (properties.priority ?? 1) as unknown as string, + 10, + ); + this.#condition = properties.condition; + } + + toJSON(): ConditionReferenceProperties { + return { + name: this.name, + priority: this.priority, + condition: this.#condition, + }; + } + + evaluate( + almanac: Almanac, + operatorMap: OperatorMap, + conditionMap: ConditionMap, + ): Promise { + if (!conditionMap) + return Promise.reject(new Error("conditionMap required")); + return conditionMap + .get(this.#condition) + .evaluate(almanac, operatorMap, conditionMap); + } +} diff --git a/src/debug.mjs b/src/debug.mts similarity index 100% rename from src/debug.mjs rename to src/debug.mts diff --git a/src/engine-default-operator-decorators.mjs b/src/engine-default-operator-decorators.mjs deleted file mode 100644 index 6959c4d..0000000 --- a/src/engine-default-operator-decorators.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import OperatorDecorator from "./operator-decorator.mjs"; - -const OperatorDecorators = []; - -OperatorDecorators.push( - new OperatorDecorator( - "someFact", - (factValue, jsonValue, next) => factValue.some((fv) => next(fv, jsonValue)), - Array.isArray, - ), -); -OperatorDecorators.push( - new OperatorDecorator("someValue", (factValue, jsonValue, next) => - jsonValue.some((jv) => next(factValue, jv)), - ), -); -OperatorDecorators.push( - new OperatorDecorator( - "everyFact", - (factValue, jsonValue, next) => - factValue.every((fv) => next(fv, jsonValue)), - Array.isArray, - ), -); -OperatorDecorators.push( - new OperatorDecorator("everyValue", (factValue, jsonValue, next) => - jsonValue.every((jv) => next(factValue, jv)), - ), -); -OperatorDecorators.push( - new OperatorDecorator("swap", (factValue, jsonValue, next) => - next(jsonValue, factValue), - ), -); -OperatorDecorators.push( - new OperatorDecorator( - "not", - (factValue, jsonValue, next) => !next(factValue, jsonValue), - ), -); - -export default OperatorDecorators; diff --git a/src/engine-default-operator-decorators.mts b/src/engine-default-operator-decorators.mts new file mode 100644 index 0000000..b4fef5d --- /dev/null +++ b/src/engine-default-operator-decorators.mts @@ -0,0 +1,29 @@ +import { OperatorDecorator } from "./operator-decorator.mjs"; + +export default [ + new OperatorDecorator( + "someFact", + (factValue: unknown[], jsonValue, next) => + factValue.some((fv) => next(fv, jsonValue)), + Array.isArray, + ), + new OperatorDecorator("someValue", (factValue, jsonValue: unknown[], next) => + jsonValue.some((jv) => next(factValue, jv)), + ), + new OperatorDecorator( + "everyFact", + (factValue: unknown[], jsonValue, next) => + factValue.every((fv) => next(fv, jsonValue)), + Array.isArray, + ), + new OperatorDecorator("everyValue", (factValue, jsonValue: unknown[], next) => + jsonValue.every((jv) => next(factValue, jv)), + ), + new OperatorDecorator("swap", (factValue, jsonValue, next) => + next(jsonValue, factValue), + ), + new OperatorDecorator( + "not", + (factValue, jsonValue, next) => !next(factValue, jsonValue), + ), +]; diff --git a/src/engine-default-operators.mjs b/src/engine-default-operators.mjs deleted file mode 100644 index 77872ee..0000000 --- a/src/engine-default-operators.mjs +++ /dev/null @@ -1,28 +0,0 @@ -import Operator from "./operator.mjs"; - -const Operators = []; -Operators.push(new Operator("equal", (a, b) => a === b)); -Operators.push(new Operator("notEqual", (a, b) => a !== b)); -Operators.push(new Operator("in", (a, b) => b.indexOf(a) > -1)); -Operators.push(new Operator("notIn", (a, b) => b.indexOf(a) === -1)); - -Operators.push( - new Operator("contains", (a, b) => a.indexOf(b) > -1, Array.isArray), -); -Operators.push( - new Operator("doesNotContain", (a, b) => a.indexOf(b) === -1, Array.isArray), -); - -function numberValidator(factValue) { - return Number.parseFloat(factValue).toString() !== "NaN"; -} -Operators.push(new Operator("lessThan", (a, b) => a < b, numberValidator)); -Operators.push( - new Operator("lessThanInclusive", (a, b) => a <= b, numberValidator), -); -Operators.push(new Operator("greaterThan", (a, b) => a > b, numberValidator)); -Operators.push( - new Operator("greaterThanInclusive", (a, b) => a >= b, numberValidator), -); - -export default Operators; diff --git a/src/engine-default-operators.mts b/src/engine-default-operators.mts new file mode 100644 index 0000000..93d74cd --- /dev/null +++ b/src/engine-default-operators.mts @@ -0,0 +1,32 @@ +import { Operator } from "./operator.mjs"; + +function numberValidator(factValue: unknown): factValue is number { + return Number.parseFloat(factValue as string).toString() !== "NaN"; +} + +export default [ + new Operator("equal", (a, b) => a === b), + new Operator("notEqual", (a, b) => a !== b), + + new Operator("in", (a, b: unknown[]) => b.indexOf(a) > -1), + new Operator("notIn", (a, b: unknown[]) => b.indexOf(a) === -1), + new Operator( + "contains", + (a: unknown[], b) => a.indexOf(b) > -1, + Array.isArray, + ), + new Operator( + "doesNotContain", + (a: unknown[], b) => a.indexOf(b) === -1, + Array.isArray, + ), + + new Operator("lessThan", (a, b: number) => a < b, numberValidator), + new Operator("lessThanInclusive", (a, b: number) => a <= b, numberValidator), + new Operator("greaterThan", (a, b: number) => a > b, numberValidator), + new Operator( + "greaterThanInclusive", + (a, b: number) => a >= b, + numberValidator, + ), +]; diff --git a/src/engine.mjs b/src/engine.mjs deleted file mode 100644 index 86b1dcd..0000000 --- a/src/engine.mjs +++ /dev/null @@ -1,373 +0,0 @@ -import Fact from "./fact.mjs"; -import Rule from "./rule.mjs"; -import Almanac from "./almanac.mjs"; -import EventEmitter from "eventemitter2"; -import defaultOperators from "./engine-default-operators.mjs"; -import defaultDecorators from "./engine-default-operator-decorators.mjs"; -import debug from "./debug.mjs"; -import Condition from "./condition.mjs"; -import OperatorMap from "./operator-map.mjs"; - -export const READY = "READY"; -export const RUNNING = "RUNNING"; -export const FINISHED = "FINISHED"; - -class Engine extends EventEmitter { - /** - * Returns a new Engine instance - * @param {Rule[]} rules - array of rules to initialize with - */ - constructor(rules = [], options = {}) { - super(); - this.rules = []; - this.allowUndefinedFacts = options.allowUndefinedFacts || false; - this.allowUndefinedConditions = options.allowUndefinedConditions || false; - this.replaceFactsInEventParams = options.replaceFactsInEventParams || false; - this.pathResolver = options.pathResolver; - this.operators = new OperatorMap(); - this.facts = new Map(); - this.conditions = new Map(); - this.status = READY; - rules.map((r) => this.addRule(r)); - defaultOperators.map((o) => this.addOperator(o)); - defaultDecorators.map((d) => this.addOperatorDecorator(d)); - } - - /** - * Add a rule definition to the engine - * @param {object|Rule} properties - rule definition. can be JSON representation, or instance of Rule - * @param {integer} properties.priority (>1) - higher runs sooner. - * @param {Object} properties.event - event to fire when rule evaluates as successful - * @param {string} properties.event.type - name of event to emit - * @param {string} properties.event.params - parameters to pass to the event listener - * @param {Object} properties.conditions - conditions to evaluate when processing this rule - */ - addRule(properties) { - if (!properties) throw new Error("Engine: addRule() requires options"); - - let rule; - if (properties instanceof Rule) { - rule = properties; - } else { - if (!Object.prototype.hasOwnProperty.call(properties, "event")) - throw new Error('Engine: addRule() argument requires "event" property'); - if (!Object.prototype.hasOwnProperty.call(properties, "conditions")) - throw new Error( - 'Engine: addRule() argument requires "conditions" property', - ); - rule = new Rule(properties); - } - rule.setEngine(this); - this.rules.push(rule); - this.prioritizedRules = null; - return this; - } - - /** - * update a rule in the engine - * @param {object|Rule} rule - rule definition. Must be a instance of Rule - */ - updateRule(rule) { - const ruleIndex = this.rules.findIndex( - (ruleInEngine) => ruleInEngine.name === rule.name, - ); - if (ruleIndex > -1) { - this.rules.splice(ruleIndex, 1); - this.addRule(rule); - this.prioritizedRules = null; - } else { - throw new Error("Engine: updateRule() rule not found"); - } - } - - /** - * Remove a rule from the engine - * @param {object|Rule|string} rule - rule definition. Must be a instance of Rule - */ - removeRule(rule) { - let ruleRemoved = false; - if (!(rule instanceof Rule)) { - const filteredRules = this.rules.filter( - (ruleInEngine) => ruleInEngine.name !== rule, - ); - ruleRemoved = filteredRules.length !== this.rules.length; - this.rules = filteredRules; - } else { - const index = this.rules.indexOf(rule); - if (index > -1) { - ruleRemoved = Boolean(this.rules.splice(index, 1).length); - } - } - if (ruleRemoved) { - this.prioritizedRules = null; - } - return ruleRemoved; - } - - /** - * sets a condition that can be referenced by the given name. - * If a condition with the given name has already been set this will replace it. - * @param {string} name - the name of the condition to be referenced by rules. - * @param {object} conditions - the conditions to use when the condition is referenced. - */ - setCondition(name, conditions) { - if (!name) throw new Error("Engine: setCondition() requires name"); - if (!conditions) - throw new Error("Engine: setCondition() requires conditions"); - if ( - !Object.prototype.hasOwnProperty.call(conditions, "all") && - !Object.prototype.hasOwnProperty.call(conditions, "any") && - !Object.prototype.hasOwnProperty.call(conditions, "not") && - !Object.prototype.hasOwnProperty.call(conditions, "condition") - ) { - throw new Error( - '"conditions" root must contain a single instance of "all", "any", "not", or "condition"', - ); - } - this.conditions.set(name, new Condition(conditions)); - return this; - } - - /** - * Removes a condition that has previously been added to this engine - * @param {string} name - the name of the condition to remove. - * @returns true if the condition existed, otherwise false - */ - removeCondition(name) { - return this.conditions.delete(name); - } - - /** - * Add a custom operator definition - * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc - * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. - */ - addOperator(operatorOrName, cb) { - this.operators.addOperator(operatorOrName, cb); - } - - /** - * Remove a custom operator definition - * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc - */ - removeOperator(operatorOrName) { - return this.operators.removeOperator(operatorOrName); - } - - /** - * Add a custom operator decorator - * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc - * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered. - */ - addOperatorDecorator(decoratorOrName, cb) { - this.operators.addOperatorDecorator(decoratorOrName, cb); - } - - /** - * Remove a custom operator decorator - * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc - */ - removeOperatorDecorator(decoratorOrName) { - return this.operators.removeOperatorDecorator(decoratorOrName); - } - - /** - * Add a fact definition to the engine. Facts are called by rules as they are evaluated. - * @param {object|Fact} id - fact identifier or instance of Fact - * @param {function} definitionFunc - function to be called when computing the fact value for a given rule - * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance - */ - addFact(id, valueOrMethod, options) { - let factId = id; - let fact; - if (id instanceof Fact) { - factId = id.id; - fact = id; - } else { - fact = new Fact(id, valueOrMethod, options); - } - debug("engine::addFact", { id: factId }); - this.facts.set(factId, fact); - return this; - } - - /** - * Remove a fact definition to the engine. Facts are called by rules as they are evaluated. - * @param {object|Fact} id - fact identifier or instance of Fact - */ - removeFact(factOrId) { - let factId; - if (!(factOrId instanceof Fact)) { - factId = factOrId; - } else { - factId = factOrId.id; - } - - return this.facts.delete(factId); - } - - /** - * Iterates over the engine rules, organizing them by highest -> lowest priority - * @return {Rule[][]} two dimensional array of Rules. - * Each outer array element represents a single priority(integer). Inner array is - * all rules with that priority. - */ - prioritizeRules() { - if (!this.prioritizedRules) { - const ruleSets = this.rules.reduce((sets, rule) => { - const priority = rule.priority; - if (!sets[priority]) sets[priority] = []; - sets[priority].push(rule); - return sets; - }, {}); - this.prioritizedRules = Object.keys(ruleSets) - .sort((a, b) => { - return Number(a) > Number(b) ? -1 : 1; // order highest priority -> lowest - }) - .map((priority) => ruleSets[priority]); - } - return this.prioritizedRules; - } - - /** - * Stops the rules engine from running the next priority set of Rules. All remaining rules will be resolved as undefined, - * and no further events emitted. Since rules of the same priority are evaluated in parallel(not series), other rules of - * the same priority may still emit events, even though the engine is in a "finished" state. - * @return {Engine} - */ - stop() { - this.status = FINISHED; - return this; - } - - /** - * Returns a fact by fact-id - * @param {string} factId - fact identifier - * @return {Fact} fact instance, or undefined if no such fact exists - */ - getFact(factId) { - return this.facts.get(factId); - } - - /** - * Runs an array of rules - * @param {Rule[]} array of rules to be evaluated - * @return {Promise} resolves when all rules in the array have been evaluated - */ - evaluateRules(ruleArray, almanac) { - return Promise.all( - ruleArray.map((rule) => { - if (this.status !== RUNNING) { - debug("engine::run, skipping remaining rules", { - status: this.status, - }); - return Promise.resolve(); - } - return rule.evaluate(almanac).then((ruleResult) => { - debug("engine::run", { ruleResult: ruleResult.result }); - almanac.addResult(ruleResult); - if (ruleResult.result) { - almanac.addEvent(ruleResult.event, "success"); - return this.emitAsync( - "success", - ruleResult.event, - almanac, - ruleResult, - ).then(() => - this.emitAsync( - ruleResult.event.type, - ruleResult.event.params, - almanac, - ruleResult, - ), - ); - } else { - almanac.addEvent(ruleResult.event, "failure"); - return this.emitAsync( - "failure", - ruleResult.event, - almanac, - ruleResult, - ); - } - }); - }), - ); - } - - /** - * Runs the rules engine - * @param {Object} runtimeFacts - fact values known at runtime - * @param {Object} runOptions - run options - * @return {Promise} resolves when the engine has completed running - */ - run(runtimeFacts = {}, runOptions = {}) { - debug("engine::run started"); - this.status = RUNNING; - - const almanac = - runOptions.almanac || - new Almanac({ - allowUndefinedFacts: this.allowUndefinedFacts, - pathResolver: this.pathResolver, - }); - - this.facts.forEach((fact) => { - almanac.addFact(fact); - }); - for (const factId in runtimeFacts) { - let fact; - if (runtimeFacts[factId] instanceof Fact) { - fact = runtimeFacts[factId]; - } else { - fact = new Fact(factId, runtimeFacts[factId]); - } - - almanac.addFact(fact); - debug("engine::run initialized runtime fact", { - id: fact.id, - value: fact.value, - type: typeof fact.value, - }); - } - const orderedSets = this.prioritizeRules(); - let cursor = Promise.resolve(); - // for each rule set, evaluate in parallel, - // before proceeding to the next priority set. - return new Promise((resolve, reject) => { - orderedSets.map((set) => { - cursor = cursor - .then(() => { - return this.evaluateRules(set, almanac); - }) - .catch(reject); - return cursor; - }); - cursor - .then(() => { - this.status = FINISHED; - debug("engine::run completed"); - const ruleResults = almanac.getResults(); - const { results, failureResults } = ruleResults.reduce( - (hash, ruleResult) => { - const group = ruleResult.result ? "results" : "failureResults"; - hash[group].push(ruleResult); - return hash; - }, - { results: [], failureResults: [] }, - ); - - resolve({ - almanac, - results, - failureResults, - events: almanac.getEvents("success"), - failureEvents: almanac.getEvents("failure"), - }); - }) - .catch(reject); - }); - } -} - -export default Engine; diff --git a/src/engine.mts b/src/engine.mts new file mode 100644 index 0000000..7f612d6 --- /dev/null +++ b/src/engine.mts @@ -0,0 +1,386 @@ +import { DynamicFactCallback, Fact, FactOptions } from "./fact.mjs"; +import { Rule, RuleProperties, RuleResult } from "./rule.mjs"; +import { Almanac, type AlmanacOptions } from "./almanac.mjs"; +import defaultOperators from "./engine-default-operators.mjs"; +import defaultDecorators from "./engine-default-operator-decorators.mjs"; +import debug from "./debug.mjs"; +import { + conditionFactory, + TopLevelCondition, + type TopLevelConditionInstance, +} from "./condition/index.mjs"; +import { OperatorMap } from "./operator-map.mjs"; +import { EventEmitter, type Event } from "./events.mjs"; +import { NeverCondition } from "./condition/never.mjs"; +import { + OperatorDecorator, + OperatorDecoratorEvaluator, +} from "./operator-decorator.mjs"; +import { Operator, OperatorEvaluator } from "./operator.mjs"; + +export interface EngineOptions extends AlmanacOptions { + allowUndefinedConditions?: boolean; + replaceFactsInEventParams?: boolean; +} + +export interface RunOptions { + almanac?: Almanac; + signal?: AbortSignal; +} + +export interface EngineResult { + events: Event[]; + failureEvents: Event[]; + almanac: Almanac; + results: RuleResult[]; + failureResults: RuleResult[]; +} + +class ConditionMap { + #conditions = new Map(); + #allowUndefinedConditions: boolean; + + constructor(allowUndefinedConditions: boolean) { + this.#allowUndefinedConditions = allowUndefinedConditions; + } + + setCondition(name: string, condition: TopLevelCondition): void { + if (!name) throw new Error("Engine: setCondition() requires name"); + this.#conditions.set(name, conditionFactory(condition)); + } + + removeCondition(name: string): boolean { + return this.#conditions.delete(name); + } + + get(name: string): TopLevelConditionInstance { + const condition = this.#conditions.get(name); + if (!condition) { + if (this.#allowUndefinedConditions) { + return NeverCondition; + } + throw new Error(`No condition ${name} exists`); + } + return condition; + } +} + +export class Engine extends EventEmitter { + readonly #conditions: ConditionMap; + readonly #operators = new OperatorMap(); + readonly #facts = new Map(); + readonly #rules: Rule[] = []; + readonly #almanacOptions: AlmanacOptions; + readonly #eventProcessor: (almanac: Almanac, event: Event) => Promise; + + /** + * Returns a new Engine instance + * @param {Rule[]} rules - array of rules to initialize with + */ + constructor( + rules: (Rule | RuleProperties | string)[] = [], + options: EngineOptions = {}, + ) { + super(); + this.#conditions = new ConditionMap( + options.allowUndefinedConditions ?? false, + ); + this.#almanacOptions = { + allowUndefinedFacts: options.allowUndefinedFacts, + pathResolver: options.pathResolver, + }; + this.#eventProcessor = options.replaceFactsInEventParams + ? async (almanac: Almanac, event: Event) => { + if (event.params !== null && typeof event.params === "object") { + return { + type: event.type, + params: Object.fromEntries( + await Promise.all( + Object.entries(event.params).map(async ([key, value]) => [ + key, + await almanac.getValue(value), + ]), + ), + ), + }; + } + return event; + } + : (_almanac: Almanac, event: Event) => Promise.resolve(event); + rules.map((r) => this.addRule(r)); + defaultOperators.map((o) => this.addOperator(o as Operator)); + defaultDecorators.map((d) => + this.addOperatorDecorator(d as OperatorDecorator), + ); + } + + /** + * Add a rule definition to the engine + * @param {object|Rule} properties - rule definition. can be JSON representation, or instance of Rule + * @param {integer} properties.priority (>1) - higher runs sooner. + * @param {Object} properties.event - event to fire when rule evaluates as successful + * @param {string} properties.event.type - name of event to emit + * @param {string} properties.event.params - parameters to pass to the event listener + * @param {Object} properties.conditions - conditions to evaluate when processing this rule + */ + addRule(properties: string | Rule | RuleProperties) { + if (!properties) throw new Error("Engine: addRule() requires options"); + + const rule = properties instanceof Rule ? properties : new Rule(properties); + this.#rules.push(rule); + return this; + } + + /** + * update a rule in the engine + * @param {object|Rule} rule - rule definition. Must be a instance of Rule + */ + updateRule(properties: Rule | RuleProperties | string) { + if (!properties) throw new Error("Engine: updateRule() requires options"); + const rule = properties instanceof Rule ? properties : new Rule(properties); + const ruleIndex = this.#rules.findIndex( + (ruleInEngine) => ruleInEngine.name === rule.name, + ); + if (ruleIndex > -1) { + this.#rules[ruleIndex] = rule; + } else { + throw new Error("Engine: updateRule() rule not found"); + } + } + + /** + * Remove a rule from the engine + * @param {object|Rule|string} rule - rule definition. Must be a instance of Rule + */ + removeRule(rule: string | { name?: string }) { + const name = typeof rule === "string" ? rule : rule.name; + if (name !== undefined) { + const index = this.#rules.findIndex( + (ruleInEngine) => ruleInEngine.name === name, + ); + if (index >= 0) { + this.#rules.splice(index, 1); + return true; + } + } + return false; + } + + /** + * sets a condition that can be referenced by the given name. + * If a condition with the given name has already been set this will replace it. + * @param {string} name - the name of the condition to be referenced by rules. + * @param {object} conditions - the conditions to use when the condition is referenced. + */ + setCondition(name: string, conditions: TopLevelCondition) { + this.#conditions.setCondition(name, conditions); + return this; + } + + /** + * Removes a condition that has previously been added to this engine + * @param {string} name - the name of the condition to remove. + * @returns true if the condition existed, otherwise false + */ + removeCondition(name: string): boolean { + return this.#conditions.removeCondition(name); + } + + /** + * Add a custom operator definition + * @param operator - operator to add + */ + addOperator(operator: Operator): void; + /** + * Add a custom operator definition + * @param name - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + * @param callback - the method to execute when the operator is encountered. + */ + addOperator( + name: string, + callback: OperatorEvaluator, + ): void; + addOperator( + operatorOrName: Operator | string, + cb?: OperatorEvaluator, + ): void { + this.#operators.addOperator(operatorOrName as string, cb!); + } + + /** + * Remove a custom operator definition + * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + */ + removeOperator( + operatorOrName: Operator | string, + ): boolean { + return this.#operators.removeOperator(operatorOrName); + } + + /** + * Add a custom operator decorator + * @param decorator - decorator to add + */ + addOperatorDecorator( + decorator: OperatorDecorator, + ): void; + /** + * Add a custom operator decorator + * @param name - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc + * @param callback - the method to execute when the decorator is encountered. + */ + addOperatorDecorator( + name: string, + callback: OperatorDecoratorEvaluator, + ): void; + addOperatorDecorator( + decoratorOrName: + | OperatorDecorator + | string, + cb?: OperatorDecoratorEvaluator, + ): void { + this.#operators.addOperatorDecorator(decoratorOrName as string, cb!); + } + + /** + * Remove a custom operator decorator + * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'someFact', 'everyValue', etc + */ + removeOperatorDecorator( + decoratorOrName: + | OperatorDecorator + | string, + ) { + return this.#operators.removeOperatorDecorator(decoratorOrName); + } + + /** + * Add a fact definition to the engine. Facts are called by rules as they are evaluated. + * @param {object|Fact} id - fact identifier or instance of Fact + * @param {function} definitionFunc - function to be called when computing the fact value for a given rule + * @param {Object} options - options to initialize the fact with. used when "id" is not a Fact instance + */ + addFact(fact: Fact): this; + addFact( + id: string, + valueOrMethod: T | DynamicFactCallback, + options?: FactOptions, + ): this; + addFact( + id: string | Fact, + valueOrMethod?: T | DynamicFactCallback, + options?: FactOptions, + ): this { + const fact = + typeof id === "string" ? new Fact(id, valueOrMethod!, options!) : id; + debug("engine::addFact", { id: fact.id }); + this.#facts.set(fact.id, fact); + return this; + } + + /** + * Remove a fact definition to the engine. Facts are called by rules as they are evaluated. + * @param {object|Fact} id - fact identifier or instance of Fact + */ + removeFact(factOrId: string | Fact) { + const factId = typeof factOrId === "string" ? factOrId : factOrId.id; + + return this.#facts.delete(factId); + } + + /** + * Returns a fact by fact-id + * @param {string} factId - fact identifier + * @return {Fact} fact instance, or undefined if no such fact exists + */ + getFact(factId: string): Fact | undefined { + return this.#facts.get(factId); + } + + /** + * Runs the rules engine + * @param {Object} runtimeFacts - fact values known at runtime + * @param {Object} runOptions - run options + * @return {Promise} resolves when the engine has completed running + */ + async run( + runtimeFacts: Record = {}, + { almanac = new Almanac(this.#almanacOptions), signal }: RunOptions = {}, + ): Promise { + debug("engine::run started"); + + this.#facts.forEach((fact) => { + almanac.addFact(fact); + }); + Object.entries(runtimeFacts).forEach(([factId, value]) => { + const fact = value instanceof Fact ? value : new Fact(factId, value); + almanac.addFact(fact); + debug("engine::run initialized runtime fact", { + id: fact.id, + value: value, + type: typeof value, + }); + }); + + const eventProcessor = this.#eventProcessor.bind(null, almanac); + + // sort rules in priority order + const prioritizedRules = this.#rules + .reduce((byPriority: Map, rule: Rule) => { + const priorityList = byPriority.get(rule.priority) ?? []; + priorityList.push(rule); + byPriority.set(rule.priority, priorityList); + return byPriority; + }, new Map()) + .entries() + .toArray() + .sort(([a], [b]) => a - b) + .map(([, rules]) => rules); + + const results: RuleResult[] = []; + const failureResults: RuleResult[] = []; + for (const ruleSet of prioritizedRules) { + if (signal && signal.aborted) { + debug("engine::run, skipping remaining rules"); + break; + } + await Promise.all( + ruleSet.map(async (rule) => { + const ruleResult = await rule.evaluate( + almanac, + this.#operators, + this.#conditions, + eventProcessor, + ); + debug("engine::run", { ruleResult: ruleResult.result }); + await this.emit( + ruleResult.result ? "success" : "failure", + ruleResult.event, + almanac, + ruleResult, + ); + if (ruleResult.result) { + await this.emit( + ruleResult.event.type, + ruleResult.event.params, + almanac, + ruleResult, + ); + results.push(ruleResult); + } else { + failureResults.push(ruleResult); + } + }), + ); + } + + debug("engine::run completed"); + return { + events: almanac.getEvents("success"), + failureEvents: almanac.getEvents("failure"), + almanac: almanac, + results, + failureResults, + }; + } +} diff --git a/src/errors.mjs b/src/errors.mjs deleted file mode 100644 index 6a14990..0000000 --- a/src/errors.mjs +++ /dev/null @@ -1,6 +0,0 @@ -export class UndefinedFactError extends Error { - constructor(...props) { - super(...props); - this.code = "UNDEFINED_FACT"; - } -} diff --git a/src/errors.mts b/src/errors.mts new file mode 100644 index 0000000..3190590 --- /dev/null +++ b/src/errors.mts @@ -0,0 +1,3 @@ +export class UndefinedFactError extends Error { + readonly code = "UNDEFINED_FACT" as const; +} diff --git a/src/events.mts b/src/events.mts new file mode 100644 index 0000000..89e3e54 --- /dev/null +++ b/src/events.mts @@ -0,0 +1,91 @@ +import type { Almanac } from "./almanac.mjs"; +import type { RuleResult } from "./rule.mjs"; + +export interface Event { + type: string; + params?: Record; +} + +export type EventHandler = ( + event: T, + almanac: Almanac, + ruleResult: RuleResult, +) => void; + +export class EventEmitter { + readonly #listeners = new Map< + string, + { once: boolean; handler: EventHandler }[] + >(); + + on(eventName: string, handler: EventHandler): this { + return this.addEventListener(eventName, handler); + } + + once(eventName: string, handler: EventHandler): this { + this.#listeners.set( + eventName, + (this.#listeners.get(eventName) ?? []).concat([ + { once: true, handler: handler as EventHandler }, + ]), + ); + return this; + } + + addEventListener( + eventName: string, + handler: EventHandler, + ): this { + this.#listeners.set( + eventName, + (this.#listeners.get(eventName) ?? []).concat([ + { once: false, handler: handler as EventHandler }, + ]), + ); + return this; + } + + removeEventListener( + eventName: string, + handler: EventHandler, + ): this { + this.#listeners.set( + eventName, + (this.#listeners.get(eventName) ?? []).filter( + (l) => l.handler === handler, + ), + ); + return this; + } + + protected async emit( + eventName: string, + event: unknown, + almanac: Almanac, + ruleResult: RuleResult, + ): Promise { + const listeners = this.#listeners.get(eventName); + if (listeners) { + // remove once listeners + this.#listeners.set( + eventName, + listeners.filter(({ once }) => !once), + ); + try { + while (listeners.length > 0) { + const { handler } = listeners.shift()!; + await handler(event, almanac, ruleResult); + } + } catch (error) { + // add any unused once listeners back to the list of listeners + this.#listeners.set( + eventName, + (this.#listeners.get(eventName) ?? []).concat( + listeners.filter(({ once }) => once), + ), + ); + throw error; + } + } + } +} diff --git a/src/fact.mjs b/src/fact.mjs deleted file mode 100644 index 2a8cf19..0000000 --- a/src/fact.mjs +++ /dev/null @@ -1,95 +0,0 @@ -import hash from "hash-it"; - -class Fact { - /** - * Returns a new fact instance - * @param {string} id - fact unique identifer - * @param {object} options - * @param {boolean} options.cache - whether to cache the fact's value for future rules - * @param {primitive|function} valueOrMethod - constant primitive, or method to call when computing the fact's value - * @return {Fact} - */ - constructor(id, valueOrMethod, options) { - this.id = id; - const defaultOptions = { cache: true }; - if (typeof options === "undefined") { - options = defaultOptions; - } - if (typeof valueOrMethod !== "function") { - this.value = valueOrMethod; - this.type = this.constructor.CONSTANT; - } else { - this.calculationMethod = valueOrMethod; - this.type = this.constructor.DYNAMIC; - } - - if (!this.id) throw new Error("factId required"); - - this.priority = parseInt(options.priority || 1, 10); - this.options = Object.assign({}, defaultOptions, options); - this.cacheKeyMethod = this.defaultCacheKeys; - return this; - } - - isConstant() { - return this.type === this.constructor.CONSTANT; - } - - isDynamic() { - return this.type === this.constructor.DYNAMIC; - } - - /** - * Return the fact value, based on provided parameters - * @param {object} params - * @param {Almanac} almanac - * @return {any} calculation method results - */ - calculate(params, almanac) { - // if constant fact w/set value, return immediately - if (Object.prototype.hasOwnProperty.call(this, "value")) { - return this.value; - } - return this.calculationMethod(params, almanac); - } - - /** - * Return a cache key (MD5 string) based on parameters - * @param {object} obj - properties to generate a hash key from - * @return {string} MD5 string based on the hash'd object - */ - static hashFromObject(obj) { - return hash(obj); - } - - /** - * Default properties to use when caching a fact - * Assumes every fact is a pure function, whose computed value will only - * change when input params are modified - * @param {string} id - fact unique identifer - * @param {object} params - parameters passed to fact calcution method - * @return {object} id + params - */ - defaultCacheKeys(id, params) { - return { params, id }; - } - - /** - * Generates the fact's cache key(MD5 string) - * Returns nothing if the fact's caching has been disabled - * @param {object} params - parameters that would be passed to the computation method - * @return {string} cache key - */ - getCacheKey(params) { - if (this.options.cache === true) { - const cacheProperties = this.cacheKeyMethod(this.id, params); - const hash = Fact.hashFromObject(cacheProperties); - return hash; - } - } -} - -Fact.CONSTANT = "CONSTANT"; -Fact.DYNAMIC = "DYNAMIC"; - -export default Fact; diff --git a/src/fact.mts b/src/fact.mts new file mode 100644 index 0000000..dbcacc9 --- /dev/null +++ b/src/fact.mts @@ -0,0 +1,75 @@ +import type { Almanac } from "./almanac.mjs"; +import hashIt from "hash-it"; + +export type DynamicFactCallback = ( + params: Record, + almanac: Almanac, +) => T; + +export interface FactOptions { + cache?: boolean; + priority?: number; +} + +export class Fact { + readonly id: string; + readonly priority: number; + + readonly #calculationMethod: DynamicFactCallback; + readonly #cache: boolean; + + /** + * Returns a new fact instance + * @param id - fact unique identifer + * @param valueOrMethod - constant primitive, or method to call when computing the fact's value + * @param options - options for the fact, such as caching + */ + constructor( + id: string, + valueOrMethod: T | DynamicFactCallback, + options: FactOptions = { cache: true }, + ) { + this.id = id; + if (!this.id) throw new Error("factId required"); + if (typeof valueOrMethod !== "function") { + this.#calculationMethod = () => valueOrMethod; + this.#cache = false; + } else { + this.#calculationMethod = valueOrMethod as DynamicFactCallback; + this.#cache = options.cache ?? true; + } + + this.priority = parseInt((options.priority ?? 1) as unknown as string, 10); + } + + /** + * Return the fact value, based on provided parameters + * @param {object} params + * @param {Almanac} almanac + * @return {any} calculation method results + */ + calculate(params: Record, almanac: Almanac): T { + return this.#calculationMethod(params, almanac); + } + + protected cacheParams( + params: Record, + ): Record | undefined { + return params; + } + + /** + * Generates the fact's cache key + * Returns nothing if the fact's caching has been disabled + * @param params - parameters that would be passed to the computation method + * @return cache key, null if not cached + */ + getCacheKey(params: Record): unknown { + if (this.#cache) { + const cacheProperties = this.cacheParams(params); + const hash = hashIt({ id: this.id, params: cacheProperties }); + return hash; + } + return null; + } +} diff --git a/src/index.mjs b/src/index.mjs deleted file mode 100644 index c5eb2bf..0000000 --- a/src/index.mjs +++ /dev/null @@ -1,11 +0,0 @@ -import Engine from "./engine.mjs"; -import Fact from "./fact.mjs"; -import Rule from "./rule.mjs"; -import Operator from "./operator.mjs"; -import Almanac from "./almanac.mjs"; -import OperatorDecorator from "./operator-decorator.mjs"; - -export { Fact, Rule, Operator, Engine, Almanac, OperatorDecorator }; -export default function (rules, options) { - return new Engine(rules, options); -} diff --git a/src/index.mts b/src/index.mts new file mode 100644 index 0000000..e633308 --- /dev/null +++ b/src/index.mts @@ -0,0 +1,31 @@ +import { Engine } from "./engine.mjs"; + +export { Almanac, type AlmanacOptions, type PathResolver } from "./almanac.mjs"; +export { Fact, type DynamicFactCallback, type FactOptions } from "./fact.mjs"; +export { + OperatorDecorator, + type OperatorDecoratorEvaluator, +} from "./operator-decorator.mjs"; +export { + Operator, + type FactValueValidator, + type OperatorEvaluator, +} from "./operator.mjs"; + +export type { + TopLevelCondition, + TopLevelConditionResult, + NestedCondition, + NestedConditionResult, +} from "./condition/index.mjs"; + +export type { EngineOptions, EngineResult, RunOptions } from "./engine.mjs"; +export { Rule, type RuleProperties, type RuleResult } from "./rule.mjs"; + +export type { Event } from "./events.mjs"; + +export { Engine }; + +export default function (...params: ConstructorParameters) { + return new Engine(...params); +} diff --git a/src/operator-decorator.mjs b/src/operator-decorator.mjs deleted file mode 100644 index b66f886..0000000 --- a/src/operator-decorator.mjs +++ /dev/null @@ -1,35 +0,0 @@ -import Operator from "./operator.mjs"; - -export default class OperatorDecorator { - /** - * Constructor - * @param {string} name - decorator identifier - * @param {function(factValue, jsonValue, next)} callback - callback that takes the next operator as a parameter - * @param {function} [factValueValidator] - optional validator for asserting the data type of the fact - * @returns {OperatorDecorator} - instance - */ - constructor(name, cb, factValueValidator) { - this.name = String(name); - if (!name) throw new Error("Missing decorator name"); - if (typeof cb !== "function") throw new Error("Missing decorator callback"); - this.cb = cb; - this.factValueValidator = factValueValidator; - if (!this.factValueValidator) this.factValueValidator = () => true; - } - - /** - * Takes the fact result and compares it to the condition 'value', using the callback - * @param {Operator} operator - fact result - * @returns {Operator} - whether the values pass the operator test - */ - decorate(operator) { - const next = operator.evaluate.bind(operator); - return new Operator( - `${this.name}:${operator.name}`, - (factValue, jsonValue) => { - return this.cb(factValue, jsonValue, next); - }, - this.factValueValidator, - ); - } -} diff --git a/src/operator-decorator.mts b/src/operator-decorator.mts new file mode 100644 index 0000000..f87ef88 --- /dev/null +++ b/src/operator-decorator.mts @@ -0,0 +1,63 @@ +import { + Operator, + type OperatorEvaluator, + type FactValueValidator, +} from "./operator.mjs"; + +export type OperatorDecoratorEvaluator = ( + factValue: TFact, + compareToValue: TValue, + next: OperatorEvaluator, +) => boolean; + +export class OperatorDecorator< + TFact = unknown, + TValue = unknown, + TNextFact = unknown, + TNextValue = unknown, +> { + readonly name: string; + readonly #cb: OperatorDecoratorEvaluator< + TFact, + TValue, + TNextFact, + TNextValue + >; + readonly #factValueValidator: FactValueValidator; + + /** + * Constructor + * @param name - decorator identifier + * @param callback - callback that takes the next operator as a parameter + * @param factValueValidator - optional validator for asserting the data type of the fact + */ + constructor( + name: string, + cb: OperatorDecoratorEvaluator, + factValueValidator: FactValueValidator = ( + fact: unknown, + ): fact is TFact => true, + ) { + this.name = String(name); + if (!name) throw new Error("Missing decorator name"); + if (typeof cb !== "function") throw new Error("Missing decorator callback"); + this.#cb = cb; + this.#factValueValidator = factValueValidator; + } + + /** + * Takes the fact result and compares it to the condition 'value', using the callback + * @param operator - the operator to decorate + * @returns a new Operator with this decoration applied + */ + decorate(operator: Operator) { + const next = operator.evaluate.bind(operator); + return new Operator( + `${this.name}:${operator.name}`, + (factValue, jsonValue) => { + return this.#cb(factValue, jsonValue, next); + }, + this.#factValueValidator, + ); + } +} diff --git a/src/operator-map.mjs b/src/operator-map.mjs deleted file mode 100644 index 9fe55b6..0000000 --- a/src/operator-map.mjs +++ /dev/null @@ -1,135 +0,0 @@ -import Operator from "./operator.mjs"; -import OperatorDecorator from "./operator-decorator.mjs"; -import debug from "./debug.mjs"; - -export default class OperatorMap { - constructor() { - this.operators = new Map(); - this.decorators = new Map(); - } - - /** - * Add a custom operator definition - * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc - * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. - */ - addOperator(operatorOrName, cb) { - let operator; - if (operatorOrName instanceof Operator) { - operator = operatorOrName; - } else { - operator = new Operator(operatorOrName, cb); - } - debug("operatorMap::addOperator", { name: operator.name }); - this.operators.set(operator.name, operator); - } - - /** - * Remove a custom operator definition - * @param {string} operatorOrName - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc - * @param {function(factValue, jsonValue)} callback - the method to execute when the operator is encountered. - */ - removeOperator(operatorOrName) { - let operatorName; - if (operatorOrName instanceof Operator) { - operatorName = operatorOrName.name; - } else { - operatorName = operatorOrName; - } - - // Delete all the operators that end in :operatorName these - // were decorated on-the-fly leveraging this operator - const suffix = ":" + operatorName; - const operatorNames = Array.from(this.operators.keys()); - for (let i = 0; i < operatorNames.length; i++) { - if (operatorNames[i].endsWith(suffix)) { - this.operators.delete(operatorNames[i]); - } - } - - return this.operators.delete(operatorName); - } - - /** - * Add a custom operator decorator - * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc - * @param {function(factValue, jsonValue, next)} callback - the method to execute when the decorator is encountered. - */ - addOperatorDecorator(decoratorOrName, cb) { - let decorator; - if (decoratorOrName instanceof OperatorDecorator) { - decorator = decoratorOrName; - } else { - decorator = new OperatorDecorator(decoratorOrName, cb); - } - debug("operatorMap::addOperatorDecorator", { name: decorator.name }); - this.decorators.set(decorator.name, decorator); - } - - /** - * Remove a custom operator decorator - * @param {string} decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc - */ - removeOperatorDecorator(decoratorOrName) { - let decoratorName; - if (decoratorOrName instanceof OperatorDecorator) { - decoratorName = decoratorOrName.name; - } else { - decoratorName = decoratorOrName; - } - - // Delete all the operators that include decoratorName: these - // were decorated on-the-fly leveraging this decorator - const prefix = decoratorName + ":"; - const operatorNames = Array.from(this.operators.keys()); - for (let i = 0; i < operatorNames.length; i++) { - if (operatorNames[i].includes(prefix)) { - this.operators.delete(operatorNames[i]); - } - } - - return this.decorators.delete(decoratorName); - } - - /** - * Get the Operator, or null applies decorators as needed - * @param {string} name - the name of the operator including any decorators - * @returns an operator or null - */ - get(name) { - const decorators = []; - let opName = name; - // while we don't already have this operator - while (!this.operators.has(opName)) { - // try splitting on the decorator symbol (:) - const firstDecoratorIndex = opName.indexOf(":"); - if (firstDecoratorIndex > 0) { - // if there is a decorator, and it's a valid decorator - const decoratorName = opName.slice(0, firstDecoratorIndex); - const decorator = this.decorators.get(decoratorName); - if (!decorator) { - debug("operatorMap::get invalid decorator", { name: decoratorName }); - return null; - } - // we're going to apply this later, use unshift since we'll apply in reverse order - decorators.unshift(decorator); - // continue looking for a known operator with the rest of the name - opName = opName.slice(firstDecoratorIndex + 1); - } else { - debug("operatorMap::get no operator", { name: opName }); - return null; - } - } - - let op = this.operators.get(opName); - // apply all the decorators - for (let i = 0; i < decorators.length; i++) { - op = decorators[i].decorate(op); - // create an entry for the decorated operation so we don't need - // to do this again - this.operators.set(op.name, op); - } - // return the operation - return op; - } -} diff --git a/src/operator-map.mts b/src/operator-map.mts new file mode 100644 index 0000000..45aed6e --- /dev/null +++ b/src/operator-map.mts @@ -0,0 +1,159 @@ +import { Operator, OperatorEvaluator } from "./operator.mjs"; +import { + OperatorDecorator, + OperatorDecoratorEvaluator, +} from "./operator-decorator.mjs"; +import debug from "./debug.mjs"; + +export class OperatorMap { + readonly #operators = new Map(); + readonly #decorators = new Map(); + + /** + * Add a custom operator definition + * @param operator - operator to add + */ + addOperator(operator: Operator): void; + /** + * Add a custom operator definition + * @param name - operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + * @param callback - the method to execute when the operator is encountered. + */ + addOperator( + name: string, + callback: OperatorEvaluator, + ): void; + addOperator( + operatorOrName: Operator | string, + cb?: OperatorEvaluator, + ): void { + const operator = + operatorOrName instanceof Operator + ? operatorOrName + : new Operator(operatorOrName, cb!); + + debug("operatorMap::addOperator", { name: operator.name }); + this.#operators.set(operator.name, operator as Operator); + } + + /** + * Remove a custom operator definition + * @param operatorOrName - operator or operator identifier within the condition; i.e. instead of 'equals', 'greaterThan', etc + */ + removeOperator( + operatorOrName: Operator | string, + ): boolean { + const operatorName = + operatorOrName instanceof Operator ? operatorOrName.name : operatorOrName; + + // Delete all the operators that end in :operatorName these + // were decorated on-the-fly leveraging this operator + const suffix = ":" + operatorName; + this.#operators + .keys() + .filter((name) => name.endsWith(suffix)) + .forEach((name) => this.#operators.delete(name)); + + return this.#operators.delete(operatorName); + } + + /** + * Add a custom operator decorator + * @param decorator - decorator to add + */ + addOperatorDecorator( + decorator: OperatorDecorator, + ): void; + /** + * Add a custom operator decorator + * @param name - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc + * @param callback - the method to execute when the decorator is encountered. + */ + addOperatorDecorator( + name: string, + callback: OperatorDecoratorEvaluator, + ): void; + addOperatorDecorator( + decoratorOrName: + | OperatorDecorator + | string, + cb?: OperatorDecoratorEvaluator, + ): void { + const decorator = + decoratorOrName instanceof OperatorDecorator + ? decoratorOrName + : new OperatorDecorator(decoratorOrName, cb!); + debug("operatorMap::addOperatorDecorator", { name: decorator.name }); + this.#decorators.set(decorator.name, decorator as OperatorDecorator); + } + + /** + * Remove a custom operator decorator + * @param decoratorOrName - decorator identifier within the condition; i.e. instead of 'everyFact', 'someValue', etc + */ + removeOperatorDecorator( + decoratorOrName: + | OperatorDecorator + | string, + ) { + let decoratorName; + if (decoratorOrName instanceof OperatorDecorator) { + decoratorName = decoratorOrName.name; + } else { + decoratorName = decoratorOrName; + } + + // Delete all the operators that include decoratorName: these + // were decorated on-the-fly leveraging this decorator + const prefix = decoratorName + ":"; + this.#operators + .keys() + .filter((name) => name.includes(prefix)) + .forEach((name) => this.#operators.delete(name)); + + return this.#decorators.delete(decoratorName); + } + + /** + * Get the Operator, or null applies decorators as needed + * @param name - the name of the operator including any decorators + * @returns an operator or null + */ + get(name: string): Operator { + const decorators: OperatorDecorator[] = []; + let opName = name; + // while we don't already have this operator + while (!this.#operators.has(opName)) { + // try splitting on the decorator symbol (:) + const firstDecoratorIndex = opName.indexOf(":"); + if (firstDecoratorIndex > 0) { + // if there is a decorator, and it's a valid decorator + const decoratorName = opName.slice(0, firstDecoratorIndex); + const decorator = this.#decorators.get(decoratorName); + if (!decorator) { + debug("operatorMap::get invalid decorator", { name: decoratorName }); + throw new Error(`Unknown operator: ${name}`); + } + // we're going to apply this later + decorators.push(decorator); + // continue looking for a known operator with the rest of the name + opName = opName.slice(firstDecoratorIndex + 1); + } else { + debug("operatorMap::get no operator", { name: opName }); + throw new Error(`Unknown operator: ${name}`); + } + } + + // apply all the decorators + return decorators.reduceRight( + (op: Operator, decorator: OperatorDecorator) => { + const decorated = decorator.decorate(op); + // create an entry for the decorated operation so we don't need + // to do this again + this.addOperator(decorated); + return decorated; + }, + this.#operators.get(opName)!, + ); + } +} diff --git a/src/operator.mjs b/src/operator.mjs deleted file mode 100644 index 66664aa..0000000 --- a/src/operator.mjs +++ /dev/null @@ -1,27 +0,0 @@ -export default class Operator { - /** - * Constructor - * @param {string} name - operator identifier - * @param {function(factValue, jsonValue)} callback - operator evaluation method - * @param {function} [factValueValidator] - optional validator for asserting the data type of the fact - * @returns {Operator} - instance - */ - constructor(name, cb, factValueValidator) { - this.name = String(name); - if (!name) throw new Error("Missing operator name"); - if (typeof cb !== "function") throw new Error("Missing operator callback"); - this.cb = cb; - this.factValueValidator = factValueValidator; - if (!this.factValueValidator) this.factValueValidator = () => true; - } - - /** - * Takes the fact result and compares it to the condition 'value', using the callback - * @param {mixed} factValue - fact result - * @param {mixed} jsonValue - "value" property of the condition - * @returns {Boolean} - whether the values pass the operator test - */ - evaluate(factValue, jsonValue) { - return this.factValueValidator(factValue) && this.cb(factValue, jsonValue); - } -} diff --git a/src/operator.mts b/src/operator.mts new file mode 100644 index 0000000..0f9e233 --- /dev/null +++ b/src/operator.mts @@ -0,0 +1,44 @@ +export type OperatorEvaluator = ( + factValue: TFact, + compareToValue: TValue, +) => boolean; +export type FactValueValidator = (value: unknown) => value is TFact; + +export class Operator { + readonly name: string; + readonly #cb: OperatorEvaluator; + readonly #factValueValidator: FactValueValidator; + + /** + * Constructor + * @param name - operator identifier + * @param callback - operator evaluation method + * @param factValueValidator - optional validator for asserting the data type of the fact + */ + constructor( + name: string, + cb: OperatorEvaluator, + factValueValidator: FactValueValidator = ( + value: unknown, + ): value is TFact => true, + ) { + this.name = String(name); + if (!name) throw new Error("Missing operator name"); + if (typeof cb !== "function") throw new Error("Missing operator callback"); + this.#cb = cb; + this.#factValueValidator = factValueValidator; + } + + /** + * Takes the fact result and compares it to the condition 'value', using the callback + * @param factValue - fact result + * @param jsonValue - "value" property of the condition + * @returns whether the values pass the operator test + */ + evaluate(factValue: unknown, jsonValue: unknown) { + return ( + this.#factValueValidator(factValue) && + this.#cb(factValue, jsonValue as TValue) + ); + } +} diff --git a/src/rule-result.mjs b/src/rule-result.mjs deleted file mode 100644 index 3ee5c83..0000000 --- a/src/rule-result.mjs +++ /dev/null @@ -1,46 +0,0 @@ -import deepClone from "clone"; - -export default class RuleResult { - constructor(conditions, event, priority, name) { - this.conditions = deepClone(conditions); - this.event = deepClone(event); - this.priority = deepClone(priority); - this.name = deepClone(name); - this.result = null; - } - - setResult(result) { - this.result = result; - } - - resolveEventParams(almanac) { - if (this.event.params !== null && typeof this.event.params === "object") { - const updates = []; - for (const key in this.event.params) { - if (Object.prototype.hasOwnProperty.call(this.event.params, key)) { - updates.push( - almanac - .getValue(this.event.params[key]) - .then((val) => (this.event.params[key] = val)), - ); - } - } - return Promise.all(updates); - } - return Promise.resolve(); - } - - toJSON(stringify = true) { - const props = { - conditions: this.conditions.toJSON(false), - event: this.event, - priority: this.priority, - name: this.name, - result: this.result, - }; - if (stringify) { - return JSON.stringify(props); - } - return props; - } -} diff --git a/src/rule.mjs b/src/rule.mjs deleted file mode 100644 index 922b570..0000000 --- a/src/rule.mjs +++ /dev/null @@ -1,382 +0,0 @@ -import Condition from "./condition.mjs"; -import RuleResult from "./rule-result.mjs"; -import debug from "./debug.mjs"; -import deepClone from "clone"; -import EventEmitter from "eventemitter2"; - -class Rule extends EventEmitter { - /** - * returns a new Rule instance - * @param {object,string} options, or json string that can be parsed into options - * @param {integer} options.priority (>1) - higher runs sooner. - * @param {Object} options.event - event to fire when rule evaluates as successful - * @param {string} options.event.type - name of event to emit - * @param {string} options.event.params - parameters to pass to the event listener - * @param {Object} options.conditions - conditions to evaluate when processing this rule - * @param {any} options.name - identifier for a particular rule, particularly valuable in RuleResult output - * @return {Rule} instance - */ - constructor(options) { - super(); - if (typeof options === "string") { - options = JSON.parse(options); - } - if (options && options.conditions) { - this.setConditions(options.conditions); - } - if (options && options.onSuccess) { - this.on("success", options.onSuccess); - } - if (options && options.onFailure) { - this.on("failure", options.onFailure); - } - if (options && (options.name || options.name === 0)) { - this.setName(options.name); - } - - const priority = (options && options.priority) || 1; - this.setPriority(priority); - - const event = (options && options.event) || { type: "unknown" }; - this.setEvent(event); - } - - /** - * Sets the priority of the rule - * @param {integer} priority (>=1) - increasing the priority causes the rule to be run prior to other rules - */ - setPriority(priority) { - priority = parseInt(priority, 10); - if (priority <= 0) throw new Error("Priority must be greater than zero"); - this.priority = priority; - return this; - } - - /** - * Sets the name of the rule - * @param {any} name - any truthy input and zero is allowed - */ - setName(name) { - if (!name && name !== 0) { - throw new Error('Rule "name" must be defined'); - } - this.name = name; - return this; - } - - /** - * Sets the conditions to run when evaluating the rule. - * @param {object} conditions - conditions, root element must be a boolean operator - */ - setConditions(conditions) { - if ( - !Object.prototype.hasOwnProperty.call(conditions, "all") && - !Object.prototype.hasOwnProperty.call(conditions, "any") && - !Object.prototype.hasOwnProperty.call(conditions, "not") && - !Object.prototype.hasOwnProperty.call(conditions, "condition") - ) { - throw new Error( - '"conditions" root must contain a single instance of "all", "any", "not", or "condition"', - ); - } - this.conditions = new Condition(conditions); - return this; - } - - /** - * Sets the event to emit when the conditions evaluate truthy - * @param {object} event - event to emit - * @param {string} event.type - event name to emit on - * @param {string} event.params - parameters to emit as the argument of the event emission - */ - setEvent(event) { - if (!event) throw new Error("Rule: setEvent() requires event object"); - if (!Object.prototype.hasOwnProperty.call(event, "type")) { - throw new Error( - 'Rule: setEvent() requires event object with "type" property', - ); - } - this.ruleEvent = { - type: event.type, - }; - if (event.params) this.ruleEvent.params = event.params; - return this; - } - - /** - * returns the event object - * @returns {Object} event - */ - getEvent() { - return this.ruleEvent; - } - - /** - * returns the priority - * @returns {Number} priority - */ - getPriority() { - return this.priority; - } - - /** - * returns the event object - * @returns {Object} event - */ - getConditions() { - return this.conditions; - } - - /** - * returns the engine object - * @returns {Object} engine - */ - getEngine() { - return this.engine; - } - - /** - * Sets the engine to run the rules under - * @param {object} engine - * @returns {Rule} - */ - setEngine(engine) { - this.engine = engine; - return this; - } - - toJSON(stringify = true) { - const props = { - conditions: this.conditions.toJSON(false), - priority: this.priority, - event: this.ruleEvent, - name: this.name, - }; - if (stringify) { - return JSON.stringify(props); - } - return props; - } - - /** - * Priorizes an array of conditions based on "priority" - * When no explicit priority is provided on the condition itself, the condition's priority is determine by its fact - * @param {Condition[]} conditions - * @return {Condition[][]} prioritized two-dimensional array of conditions - * Each outer array element represents a single priority(integer). Inner array is - * all conditions with that priority. - */ - prioritizeConditions(conditions) { - const factSets = conditions.reduce((sets, condition) => { - // if a priority has been set on this specific condition, honor that first - // otherwise, use the fact's priority - let priority = condition.priority; - if (!priority) { - const fact = this.engine.getFact(condition.fact); - priority = (fact && fact.priority) || 1; - } - if (!sets[priority]) sets[priority] = []; - sets[priority].push(condition); - return sets; - }, {}); - return Object.keys(factSets) - .sort((a, b) => { - return Number(a) > Number(b) ? -1 : 1; // order highest priority -> lowest - }) - .map((priority) => factSets[priority]); - } - - /** - * Evaluates the rule, starting with the root boolean operator and recursing down - * All evaluation is done within the context of an almanac - * @return {Promise(RuleResult)} rule evaluation result - */ - evaluate(almanac) { - const ruleResult = new RuleResult( - this.conditions, - this.ruleEvent, - this.priority, - this.name, - ); - - /** - * Evaluates the rule conditions - * @param {Condition} condition - condition to evaluate - * @return {Promise(true|false)} - resolves with the result of the condition evaluation - */ - const evaluateCondition = (condition) => { - if (condition.isConditionReference()) { - return realize(condition); - } else if (condition.isBooleanOperator()) { - const subConditions = condition[condition.operator]; - let comparisonPromise; - if (condition.operator === "all") { - comparisonPromise = all(subConditions); - } else if (condition.operator === "any") { - comparisonPromise = any(subConditions); - } else { - comparisonPromise = not(subConditions); - } - // for booleans, rule passing is determined by the all/any/not result - return comparisonPromise.then((comparisonValue) => { - const passes = comparisonValue === true; - condition.result = passes; - return passes; - }); - } else { - return condition - .evaluate(almanac, this.engine.operators) - .then((evaluationResult) => { - const passes = evaluationResult.result; - condition.factResult = evaluationResult.leftHandSideValue; - condition.result = passes; - return passes; - }); - } - }; - - /** - * Evalutes an array of conditions, using an 'every' or 'some' array operation - * @param {Condition[]} conditions - * @param {string(every|some)} array method to call for determining result - * @return {Promise(boolean)} whether conditions evaluated truthy or falsey based on condition evaluation + method - */ - const evaluateConditions = (conditions, method) => { - if (!Array.isArray(conditions)) conditions = [conditions]; - - return Promise.all( - conditions.map((condition) => evaluateCondition(condition)), - ).then((conditionResults) => { - debug("rule::evaluateConditions", { results: conditionResults }); - return method.call(conditionResults, (result) => result === true); - }); - }; - - /** - * Evaluates a set of conditions based on an 'all', 'any', or 'not' operator. - * First, orders the top level conditions based on priority - * Iterates over each priority set, evaluating each condition - * If any condition results in the rule to be guaranteed truthy or falsey, - * it will short-circuit and not bother evaluating any additional rules - * @param {Condition[]} conditions - conditions to be evaluated - * @param {string('all'|'any'|'not')} operator - * @return {Promise(boolean)} rule evaluation result - */ - const prioritizeAndRun = (conditions, operator) => { - if (conditions.length === 0) { - return Promise.resolve(true); - } - if (conditions.length === 1) { - // no prioritizing is necessary, just evaluate the single condition - // 'all' and 'any' will give the same results with a single condition so no method is necessary - // this also covers the 'not' case which should only ever have a single condition - return evaluateCondition(conditions[0]); - } - const orderedSets = this.prioritizeConditions(conditions); - let cursor = Promise.resolve(operator === "all"); - // use for() loop over Array.forEach to support IE8 without polyfill - for (let i = 0; i < orderedSets.length; i++) { - const set = orderedSets[i]; - cursor = cursor.then((setResult) => { - // rely on the short-circuiting behavior of || and && to avoid evaluating subsequent conditions - return operator === "any" - ? setResult || evaluateConditions(set, Array.prototype.some) - : setResult && evaluateConditions(set, Array.prototype.every); - }); - } - return cursor; - }; - - /** - * Runs an 'any' boolean operator on an array of conditions - * @param {Condition[]} conditions to be evaluated - * @return {Promise(boolean)} condition evaluation result - */ - const any = (conditions) => { - return prioritizeAndRun(conditions, "any"); - }; - - /** - * Runs an 'all' boolean operator on an array of conditions - * @param {Condition[]} conditions to be evaluated - * @return {Promise(boolean)} condition evaluation result - */ - const all = (conditions) => { - return prioritizeAndRun(conditions, "all"); - }; - - /** - * Runs a 'not' boolean operator on a single condition - * @param {Condition} condition to be evaluated - * @return {Promise(boolean)} condition evaluation result - */ - const not = (condition) => { - return prioritizeAndRun([condition], "not").then((result) => !result); - }; - - /** - * Dereferences the condition reference and then evaluates it. - * @param {Condition} conditionReference - * @returns {Promise(boolean)} condition evaluation result - */ - const realize = (conditionReference) => { - const condition = this.engine.conditions.get( - conditionReference.condition, - ); - if (!condition) { - if (this.engine.allowUndefinedConditions) { - // undefined conditions always fail - conditionReference.result = false; - return Promise.resolve(false); - } else { - throw new Error( - `No condition ${conditionReference.condition} exists`, - ); - } - } else { - // project the referenced condition onto reference object and evaluate it. - delete conditionReference.condition; - Object.assign(conditionReference, deepClone(condition)); - return evaluateCondition(conditionReference); - } - }; - - /** - * Emits based on rule evaluation result, and decorates ruleResult with 'result' property - * @param {RuleResult} ruleResult - */ - const processResult = (result) => { - ruleResult.setResult(result); - let processEvent = Promise.resolve(); - if (this.engine.replaceFactsInEventParams) { - processEvent = ruleResult.resolveEventParams(almanac); - } - const event = result ? "success" : "failure"; - return processEvent - .then(() => - this.emitAsync(event, ruleResult.event, almanac, ruleResult), - ) - .then(() => ruleResult); - }; - - if (ruleResult.conditions.any) { - return any(ruleResult.conditions.any).then((result) => - processResult(result), - ); - } else if (ruleResult.conditions.all) { - return all(ruleResult.conditions.all).then((result) => - processResult(result), - ); - } else if (ruleResult.conditions.not) { - return not(ruleResult.conditions.not).then((result) => - processResult(result), - ); - } else { - return realize(ruleResult.conditions).then((result) => - processResult(result), - ); - } - } -} - -export default Rule; diff --git a/src/rule.mts b/src/rule.mts new file mode 100644 index 0000000..2f3b697 --- /dev/null +++ b/src/rule.mts @@ -0,0 +1,181 @@ +import { + conditionFactory, + TopLevelConditionInstance, + type TopLevelConditionResult, + type TopLevelCondition, +} from "./condition/index.mjs"; +import { EventEmitter, type Event, type EventHandler } from "./events.mjs"; +import { NeverCondition } from "./condition/never.mjs"; +import type { Almanac } from "./almanac.mjs"; +import type { OperatorMap } from "./operator-map.mjs"; +import type { ConditionMap } from "./condition/reference.mjs"; + +export interface RuleResult { + readonly name?: string; + readonly conditions: TopLevelConditionResult; + readonly event: Event; + readonly priority: number; + readonly result: boolean; +} + +export interface RuleProperties { + conditions: TopLevelCondition; + event: Event; + name?: string; + priority?: number; + onSuccess?: EventHandler; + onFailure?: EventHandler; +} + +export class Rule extends EventEmitter { + #name?: string; + #priority: number = 1; + #condition: TopLevelConditionInstance = NeverCondition; + #event: Event = { type: "unknown" }; + + /** + * returns a new Rule instance + * @param options, Rule Properties or json string that can be parsed into options + * @param {integer} options.priority (>1) - higher runs sooner. + * @param {Object} options.event - event to fire when rule evaluates as successful + * @param {string} options.event.type - name of event to emit + * @param {string} options.event.params - parameters to pass to the event listener + * @param {Object} options.conditions - conditions to evaluate when processing this rule + * @param {any} options.name - identifier for a particular rule, particularly valuable in RuleResult output + */ + constructor(options?: string | RuleProperties) { + super(); + if (typeof options === "string") { + options = JSON.parse(options) as RuleProperties; + } + if (options) { + if (options.conditions) { + this.conditions = options.conditions; + } + if (options.onSuccess) { + this.on("success", options.onSuccess); + } + if (options.onFailure) { + this.on("failure", options.onFailure); + } + if (options.name) { + this.name = options.name; + } + if (options.priority) { + this.priority = options.priority; + } + if (options.event) { + this.event = options.event; + } + } + } + + get name(): string | undefined { + return this.#name; + } + + /** + * Sets the name of the rule + */ + set name(name: string) { + if (!name) { + throw new Error('Rule "name" must be defined'); + } + this.#name = name; + } + + get priority(): number { + return this.#priority; + } + + /** + * Sets the priority of the rule + * @param {integer} priority (>=1) - increasing the priority causes the rule to be run prior to other rules + */ + set priority(priority: number) { + priority = parseInt(priority as unknown as string, 10); + if (priority <= 0) throw new Error("Priority must be greater than zero"); + this.#priority = priority; + } + + /** + * returns the event object + */ + get conditions(): TopLevelCondition { + return this.#condition.toJSON(); + } + + /** + * Sets the conditions to run when evaluating the rule. + */ + set conditions(conditions: TopLevelCondition) { + this.#condition = conditionFactory(conditions); + } + + /** + * returns the event object + * @returns event + */ + get event(): Event { + return this.#event; + } + + /** + * Sets the event to emit when the conditions evaluate truthy + * @param event - event to emit + */ + set event(event: Event) { + if (!event) throw new Error("Rule: event requires event object"); + if (!("type" in event)) { + throw new Error('Rule: event requires event object with "type" property'); + } + this.#event = { + type: event.type, + }; + if (event.params) this.#event.params = event.params; + } + + toJSON(): Omit { + return { + conditions: this.conditions!, + priority: this.priority, + event: this.#event, + name: this.name, + }; + } + + /** + * Evaluates the rule, starting with the root boolean operator and recursing down + * All evaluation is done within the context of an almanac + * @return {Promise(RuleResult)} rule evaluation result + */ + async evaluate( + almanac: Almanac, + operatorMap: OperatorMap, + conditionMap: ConditionMap, + eventProcessor: (event: Event) => Promise, + ): Promise { + const conditions = await this.#condition.evaluate( + almanac, + operatorMap, + conditionMap, + ); + + const ruleResult: RuleResult = { + conditions, + priority: this.priority, + name: this.name, + event: await eventProcessor(this.event), + result: conditions.result, + }; + + almanac.addResult(ruleResult); + + const event = ruleResult.result ? "success" : "failure"; + almanac.addEvent(ruleResult.event, event); + + await this.emit(event, ruleResult.event, almanac, ruleResult); + + return ruleResult; + } +} diff --git a/test/types.test-d.mts b/test/types.test-d.mts index 86c3f5a..2f39b8d 100644 --- a/test/types.test-d.mts +++ b/test/types.test-d.mts @@ -14,8 +14,7 @@ import rulesEngine, { Rule, RuleProperties, RuleResult, - RuleSerializable, -} from "../types/index.js"; +} from "../src/index.mjs"; // setup basic fixture data const ruleProps: RuleProperties = { @@ -81,27 +80,6 @@ describe("type tests", () => { it("returns void when updating a rule", () => { expectTypeOf(engine.updateRule(ruleFromString)); }); - - it("returns rule when setting conditions", () => { - expectTypeOf(rule.setConditions({ any: [] })); - }); - - it("returns rule when setting event", () => { - expectTypeOf(rule.setEvent({ type: "test" })); - }); - - it("returns rule when setting priority", () => { - expectTypeOf(rule.setPriority(1)); - }); - - it("returns string when json stringifying", () => { - expectTypeOf(rule.toJSON()); - expectTypeOf(rule.toJSON(true)); - }); - - it("returns serializable props when converting to json", () => { - expectTypeOf(rule.toJSON(false)); - }); }); describe("operator tests", () => { @@ -110,10 +88,10 @@ describe("type tests", () => { b: number, ) => a === b; - const operator: Operator = new Operator( + const operator: Operator = new Operator( "test", operatorEvaluator, - (num: number) => num > 0, + (num: unknown): num is number => Number(num) > 0, ); it("returns void when adding an operatorEvaluator", () => { @@ -137,10 +115,10 @@ describe("type tests", () => { number > = (a: number[], b: number, next: OperatorEvaluator) => next(a[0], b); - const operatorDecorator: OperatorDecorator = new OperatorDecorator( + const operatorDecorator: OperatorDecorator = new OperatorDecorator( "first", operatorDecoratorEvaluator, - (a: number[]) => a.length > 0, + (a: unknown): a is number[] => Array.isArray(a) && a.length > 0, ); it("returns void when adding a decorator evaluator", () => { @@ -181,7 +159,7 @@ describe("type tests", () => { }); it("returns fact when getting a fact", () => { - expectTypeOf>(engine.getFact("test")); + expectTypeOf(engine.getFact("test")); }); }); @@ -191,10 +169,6 @@ describe("type tests", () => { it("factValue returns promise of value", () => { expectTypeOf>(almanac.factValue("test-fact")); }); - - it("addRuntimeFact returns void", () => { - expectTypeOf(almanac.addRuntimeFact("test-fact", "some-value")); - }); }); describe("event tests", () => { diff --git a/tsup.config.ts b/tsup.config.ts index 4f77f3c..bed1b67 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,8 @@ import { defineConfig } from "tsup"; export default defineConfig({ - entry: ["src/index.mjs"], + entry: ["src/index.mts"], + dts: true, sourcemap: true, format: ["esm", "cjs"], target: ["es2015"], diff --git a/types/index.d.ts b/types/index.d.ts deleted file mode 100644 index bf3fd2c..0000000 --- a/types/index.d.ts +++ /dev/null @@ -1,227 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ - -export interface AlmanacOptions { - allowUndefinedFacts?: boolean; - pathResolver?: PathResolver; -} - -export interface EngineOptions extends AlmanacOptions { - allowUndefinedConditions?: boolean; - replaceFactsInEventParams?: boolean; -} - -export interface RunOptions { - almanac?: Almanac; -} - -export interface EngineResult { - events: Event[]; - failureEvents: Event[]; - almanac: Almanac; - results: RuleResult[]; - failureResults: RuleResult[]; -} - -export default function engineFactory( - rules: Array, - options?: EngineOptions, -): Engine; - -export class Engine { - constructor(rules?: Array, options?: EngineOptions); - - addRule(rule: RuleProperties): this; - removeRule(ruleOrName: Rule | string): boolean; - updateRule(rule: Rule): void; - - setCondition(name: string, conditions: TopLevelCondition): this; - removeCondition(name: string): boolean; - - addOperator(operator: Operator): void; - addOperator( - operatorName: string, - callback: OperatorEvaluator, - ): void; - removeOperator(operator: Operator | string): boolean; - - addOperatorDecorator(decorator: OperatorDecorator): void; - addOperatorDecorator( - decoratorName: string, - callback: OperatorDecoratorEvaluator, - ): void; - removeOperatorDecorator(decorator: OperatorDecorator | string): boolean; - - addFact(fact: Fact): this; - addFact( - id: string, - valueCallback: DynamicFactCallback | T, - options?: FactOptions, - ): this; - removeFact(factOrId: string | Fact): boolean; - getFact(factId: string): Fact; - - on(eventName: string, handler: EventHandler): this; - - run( - facts?: Record, - runOptions?: RunOptions, - ): Promise; - stop(): this; -} - -export interface OperatorEvaluator { - (factValue: A, compareToValue: B): boolean; -} - -export class Operator { - public name: string; - constructor( - name: string, - evaluator: OperatorEvaluator, - validator?: (factValue: A) => boolean, - ); -} - -export interface OperatorDecoratorEvaluator { - ( - factValue: A, - compareToValue: B, - next: OperatorEvaluator, - ): boolean; -} - -export class OperatorDecorator< - A = unknown, - B = unknown, - NextA = unknown, - NextB = unknown, -> { - public name: string; - constructor( - name: string, - evaluator: OperatorDecoratorEvaluator, - validator?: (factValue: A) => boolean, - ); -} - -export class Almanac { - constructor(options?: AlmanacOptions); - factValue( - factId: string, - params?: Record, - path?: string, - ): Promise; - addFact(fact: Fact): this; - addFact( - id: string, - valueCallback: DynamicFactCallback | T, - options?: FactOptions, - ): this; - addRuntimeFact(factId: string, value: any): void; -} - -export type FactOptions = { - cache?: boolean; - priority?: number; -}; - -export type DynamicFactCallback = ( - params: Record, - almanac: Almanac, -) => T; - -export class Fact { - id: string; - priority: number; - options: FactOptions; - value?: T; - calculationMethod?: DynamicFactCallback; - - constructor( - id: string, - value: T | DynamicFactCallback, - options?: FactOptions, - ); -} - -export interface Event { - type: string; - params?: Record; -} - -export type PathResolver = (value: object, path: string) => any; - -export type EventHandler = ( - event: T, - almanac: Almanac, - ruleResult: RuleResult, -) => void; - -export interface RuleProperties { - conditions: TopLevelCondition; - event: Event; - name?: string; - priority?: number; - onSuccess?: EventHandler; - onFailure?: EventHandler; -} -export type RuleSerializable = Pick< - Required, - "conditions" | "event" | "name" | "priority" ->; - -export interface RuleResult { - name: string; - conditions: TopLevelCondition; - event?: Event; - priority?: number; - result: any; -} - -export class Rule implements RuleProperties { - constructor(ruleProps: RuleProperties | string); - name: string; - conditions: TopLevelCondition; - event: Event; - priority: number; - setConditions(conditions: TopLevelCondition): this; - setEvent(event: Event): this; - setPriority(priority: number): this; - toJSON(): string; - toJSON( - stringify: T, - ): T extends true ? string : RuleSerializable; -} - -interface ConditionProperties { - fact: string; - operator: string; - value: { fact: string } | any; - path?: string; - priority?: number; - params?: Record; - name?: string; -} - -type NestedCondition = ConditionProperties | TopLevelCondition; -type AllConditions = { - all: NestedCondition[]; - name?: string; - priority?: number; -}; -type AnyConditions = { - any: NestedCondition[]; - name?: string; - priority?: number; -}; -type NotConditions = { not: NestedCondition; name?: string; priority?: number }; -type ConditionReference = { - condition: string; - name?: string; - priority?: number; -}; -export type TopLevelCondition = - | AllConditions - | AnyConditions - | NotConditions - | ConditionReference;