brms-ts
v1.1.0
Published
Motor de reglas de negocio en TypeScript estilo Drools: reglas declarativas JSON/YAML ejecutadas por un motor de inferencia forward-chaining tipo RETE.
Downloads
677
Maintainers
Readme
brms-ts
A business rule management engine for TypeScript, inspired by Drools. Declarative
rules are written in JSON or YAML and executed by a forward-chaining inference
engine (RETE-style) with an incremental agenda, salience-based conflict
resolution, and no-loop control.
- Declarative rules in JSON or YAML, validated against a schema.
- Rich conditions:
eq,neq,gt,gte,lt,lte,in,contains,between,startsWith,endsWith,matches,isEmpty, andexists, combined withand/or/not, plus custom predicates. - Fact type selectors: a rule can be scoped to one fact type or a list of types.
- Field references: rule values can reference fields of the bound fact
(
{ $fact: 'path' }). - Actions: insert, modify, and retract facts, or invoke registered functions.
- Forward chaining: actions change facts, which re-trigger rules incrementally.
- Conflict resolution by salience (highest first), tie-broken by rule order.
- Safe by design: rule data is interpreted, never
eval-ed. - Typed, documented, and dual-published (ESM + CommonJS) with generated docs.
Installation
npm install brms-tsThe package ships as ESM and CommonJS with bundled type definitions.
Quick start
import { RuleEngine } from 'brms-ts';
const engine = new RuleEngine();
// Register a custom function referenced by a rule action.
engine.registerFunction('approve', (facts) => {
const id = facts[0]?.attributes['id'];
console.log('approved', typeof id === 'string' ? id : '');
});
// Load rules from YAML (JSON is also accepted).
engine.loadRules(`
rules:
- name: approve-adults
salience: 10
noLoop: true
when:
kind: comparison
field: age
operator: gte
value: 18
then:
- { kind: invoke, function: approve }
`);
engine.insert({ type: 'Applicant', attributes: { id: 'a1', age: 20 } });
engine.fireAllRules();Rule format
A rules document is an object with a rules array. Each rule has a name, an
optional salience (default 0), noLoop flag (default false), and type
fact selector (default: any type), a when condition, and a then list of
actions.
rules:
- name: vip-discount
salience: 10 # higher fires first
noLoop: true # do not re-activate from its own changes
type: Order # only facts of this type (string, or array of alternatives)
when:
kind: and
conditions:
- { kind: comparison, field: amount, operator: gte, value: 100 }
- { kind: predicate, predicate: isVip } # custom predicate
then:
- { kind: modify, target: 0, attributes: { discount: 0.2 } }
- { kind: invoke, function: audit, args: ['discount-applied'] }Conditions (when)
| Kind | Shape | Meaning |
| ------------ | ----------------------------------- | ---------------------------------------------------------------------- |
| comparison | { kind, field, operator, value? } | Compare a fact field against a value (no value for unary operators). |
| predicate | { kind, predicate, args? } | Call a registered custom predicate. |
| and | { kind, conditions: [...] } | All sub-conditions must hold. |
| or | { kind, conditions: [...] } | At least one sub-condition must hold. |
| not | { kind, condition } | The sub-condition must not hold. |
Comparison operators:
| Operator | Meaning |
| ------------------------------------- | ------------------------------------------------------------------------------------------ |
| eq, neq, gt, gte, lt, lte | Equality and ordering. Ordering only compares number-to-number or string-to-string. |
| in | The field value is a member of the given array (strict equality). |
| contains | Substring match for strings; membership for arrays. |
| between | The field value lies within the inclusive [min, max] range (two numbers or two strings). |
| startsWith, endsWith | String prefix/suffix match. |
| matches | The field string matches the given regular-expression pattern. |
| isEmpty | The field is absent, null, '', [], or {}. Takes no value. |
| exists | The field is present and not null. Takes no value. |
Fields support dot notation for nested attributes, e.g. address.city.
Field references ($fact)
Any rule value position accepts a reference to a field of the fact bound to the
rule, written as { $fact: 'path' }. The path uses the same dot notation as
comparison fields:
rules:
- name: within-limit
when: { kind: comparison, field: discount, operator: lte, value: { $fact: maxDiscount } }
then:
- { kind: invoke, function: notify, args: [{ $fact: id }] }Accepted positions: the value of binary comparisons, predicate.args,
invoke.args, and the attribute values of insert and modify actions (the
type of an inserted fact is always a literal string). $fact is a reserved
key: an object carrying it must be exactly a reference, otherwise the document
is rejected. References that do not resolve behave like a missing field in
comparisons (no match) and are replaced by null in action positions.
matches patterns
Patterns are validated when rules are loaded: they must be a literal string (no
$fact references), at most 256 characters, compilable, and free of a
quantified group with an inner quantifier (for example (a+)+), which guards
against catastrophic backtracking. The check is a conservative heuristic, not
a linear-time proof: harmless patterns such as (a+b|c)+ may be rejected, and
ambiguity through alternation (for example (a|aa)+) is not detected, so
review untrusted patterns like code.
Value semantics
The comparison policy is deliberate and covered by tests:
- No coercion:
1and'1'are never equal, and ordering compares numbers with numbers and strings with strings only; anything else does not match. - A missing field is not
null:eqwithvalue: nullonly matches an explicitnull, whileneqmatches the absent field. inandcontainsuse strict equality;containsis a substring test for strings and membership for arrays.isEmptymatches an absent field,null,'',[], and{};0andfalseare not empty.existsmatches anything present that is notnull.- Binary operators with no
valuedeclared never match (programmatic rules only; the loader rejects such documents). A strayvalueonisEmptyorexistsis ignored at runtime.
Actions (then)
| Kind | Shape | Effect |
| --------- | -------------------------------------- | --------------------------------------- |
| insert | { kind, fact: { type, attributes } } | Insert a new fact. |
| modify | { kind, target: 0, attributes } | Merge attributes into the matched fact. |
| retract | { kind, target: 0 } | Remove the matched fact. |
| invoke | { kind, function, args? } | Call a registered function. |
target: 0 refers to the fact that activated the rule.
Programmatic API
const engine = new RuleEngine({ cycleLimit: 10_000 });
engine.registerPredicate('isVip', (fact) => fact.attributes['tier'] === 'gold');
engine.registerFunction('audit', (facts, args) => {
/* side effect */
});
engine.loadRules(source); // string (YAML/JSON), parsed object, or typed Rule[]
const record = engine.insert({ type: 'Order', attributes: { amount: 150 } });
engine.modify(record.id, { amount: 200 });
engine.retract(record.id);
const fired = engine.fireAllRules(); // number of rules fired
const facts = engine.getFacts(); // current fact snapshotRules can also be built in code with typed factory helpers:
import { defineRule, and, compare, predicate } from 'brms-ts';
const rule = defineRule({
name: 'vip-discount',
salience: 10,
noLoop: true,
when: and(compare('amount', 'gte', 100), predicate('isVip')),
then: [{ kind: 'modify', target: 0, attributes: { discount: 0.2 } }],
});Example
A runnable credit-approval example lives in the examples/credit-approval
directory of the repository. Run it with:
npm run exampleIt loads rules from rules.yaml, registers a custom predicate and functions,
evaluates several applications, and prints the resulting decisions.
Design notes and limitations
- Single-fact matching. A rule's
whenis evaluated against one fact at a time; each match produces one activation bound to that fact. Cross-fact multi-pattern joins are intentionally out of scope for this version. - Termination.
no-loopprevents a rule from re-activating itself from its own changes, and a configurablecycleLimitaborts runaway inference with aCycleLimitExceededError. - Security. Conditions and actions are interpreted from data. The engine
never uses
evalornew Function, and nested field access refuses prototype-polluting keys.
Development
npm run verify # lint + typecheck + test + build
npm test # unit and integration tests with coverage thresholds
npm run lint # ESLint (strict, type-checked, no `any`)
npm run format # Prettier
npm run docs # generate API docs with TypeDoc into ./docs
npm run check:pkg # publint + are-the-types-wrong packaging checksCommits follow Conventional Commits and
are validated by commitlint via a Husky commit-msg hook.
Releasing
Releases are automated with
semantic-release through the
.github/workflows/ci.yml GitHub Actions workflow. An authorize gate first
checks that the pull-request author has write/admin access, so external fork
PRs do not run CI unattended. The quality job then runs on every authorized
pull request and push to main (lint, typecheck, test, build, package
checks); the release job runs only on pushes to main after quality
passes, and publishes to npm (with provenance) and creates a GitHub Release.
Set an NPM_TOKEN repository secret (an npm automation token) to enable
publishing; GITHUB_TOKEN is provided automatically. Provenance requires the
repository to be public.
