Skip to content

yalperg/mairon

Repository files navigation

Mairon

One engine to rule them all

npm version codecov License: MIT TypeScript

Mairon is a lightweight, type-safe rule engine. Define complex business rules declaratively and evaluate them dynamically at runtime.

Features

Declarative Rules: Define rules as JSON-like objects with all, any, and not logic

🔍 45+ Built-in Operators: Comparison, string, array, type-checking, change detection, and more

🧩 Custom Operators: Register your own sync/async operators with alias support

🎯 Type-Safe: Full TypeScript support with generic types

🔄 Change Detection: Track changes between data states

📝 Templates: Dynamic values with time expressions and data references

🎨 Event System: Hook into the evaluation lifecycle

🔒 Immutable Mode: Protect original data from mutation

Async Operators: Support for operators that call external APIs

🔗 Rule Chaining: Trigger dependent rules automatically

🔍 Explainability: Debug why rules matched or didn't match

💾 Serialization: Export/import rules and engine state as JSON

Installation

npm install mairon
# or
yarn add mairon
# or
pnpm add mairon

Quick Start

import Mairon from 'mairon';

// Create engine
const engine = new Mairon();

// Register action handlers
engine.registerHandler('notify', (context, params) => {
  console.log(`Notification: ${params.message}`);
});

// Define a rule
engine.addRule({
  id: 'welcome-new-users',
  name: 'Welcome new users',
  conditions: {
    all: [
      { field: 'isNew', operator: 'equals', value: true },
      { field: 'age', operator: 'greaterThanOrEqual', value: 18 }
    ]
  },
  actions: [
    { type: 'notify', params: { message: 'Welcome!' } }
  ]
});

// Evaluate
const results = await engine.evaluate({
  data: { isNew: true, age: 25 }
});

console.log(results[0].matched); // true

Example: Task Management

This example shows how to build automation on top of a Todo object using Mairon.

import Mairon, { type Rule } from 'mairon';

type Todo = {
  id: string;
  title: string;
  dueAt?: number;
  priority: 'low' | 'normal' | 'high';
  tags: string[];
  completed: boolean;
  assignee?: string;
};

const engine = new Mairon<Todo>({ enableIndexing: true });

const notifications: string[] = [];

engine.registerHandlers({
  addTag: ({ evaluationContext }, params) => {
    const todo = evaluationContext.data;
    const tag = String(params.tag);
    if (!todo.tags.includes(tag)) {
      todo.tags.push(tag);
    }
  },
  assign: ({ evaluationContext }, params) => {
    evaluationContext.data.assignee = String(params.user);
  },
  notify: ({ evaluationContext }, params) => {
    const todo = evaluationContext.data;
    notifications.push(`${params.message}: ${todo.title}`);
  },
});

const rules: Rule<Todo>[] = [
  {
    id: 'overdue-tasks',
    name: 'Mark overdue tasks',
    priority: 100,
    enabled: true,
    conditions: {
      all: [
        { field: 'completed', operator: 'equals', value: false },
        { field: 'dueAt', operator: 'lessThan', value: '{{ now }}' }
      ]
    },
    actions: [
      { type: 'addTag', params: { tag: 'overdue' } },
      { type: 'notify', params: { message: 'Task is overdue' } }
    ]
  },
  {
    id: 'assign-high-priority',
    name: 'Auto-assign high priority tasks',
    priority: 90,
    enabled: true,
    conditions: {
      all: [
        { field: 'priority', operator: 'equals', value: 'high' },
        { field: 'assignee', operator: 'isUndefined' }
      ]
    },
    actions: [
      { type: 'assign', params: { user: 'team-lead' } }
    ]
  }
];

engine.addRules(rules);

// Evaluate a todo
const todo: Todo = {
  id: '1',
  title: 'Complete project',
  dueAt: Date.now() - 1000,  // Overdue
  priority: 'high',
  tags: [],
  completed: false
};

const results = await engine.evaluate({ data: todo });
console.log(`Matched ${results.filter(r => r.matched).length} rules`);
console.log('Notifications:', notifications);

Core Concepts

Rules

A rule consists of:

  • Conditions: Logic tree that evaluates to true/false
  • Actions: Operations to perform when conditions match
  • Priority: Higher priority rules execute first
  • Triggers: Other rules to chain when this rule matches
  • Metadata: Tags, description, and custom data
{
  id: 'rule-id',
  name: 'Human readable name',
  priority: 100,
  conditions: {
    all: [  // AND logic
      { field: 'status', operator: 'equals', value: 'active' },
      { field: 'age', operator: 'greaterThan', value: 18 }
    ]
  },
  actions: [
    { type: 'actionName', params: { key: 'value' } }
  ],
  triggers: ['another-rule-id']  // Optional: chain to other rules
}

Conditions

Simple Conditions:

{ field: 'age', operator: 'greaterThan', value: 21 }

Logical Groups (all = AND, any = OR, not = NOT):

{
  any: [  // OR logic
    { field: 'role', operator: 'equals', value: 'admin' },
    {
      all: [  // Nested AND
        { field: 'role', operator: 'equals', value: 'moderator' },
        { field: 'verified', operator: 'equals', value: true }
      ]
    }
  ]
}

// NOT logic
{
  not: { field: 'banned', operator: 'equals', value: true }
}

Operators

Mairon includes 45+ operators across multiple categories:

  • Comparison: equals, greaterThan, lessThan, between, etc.
  • String: contains, startsWith, endsWith, matches (regex)
  • Array: includes, includesAll, includesAny, isEmpty
  • Existence: exists, isNull, isDefined, isUndefined
  • Type: isString, isNumber, isBoolean, isArray, isObject
  • Change: changed, changedFrom, changedTo, increased, decreased
  • Membership: in, notIn
  • Length: lengthEquals, lengthGreaterThan, etc.

See Operators Guide for complete reference.

Actions & Handlers

Actions are executed when rules match. Register handlers to define behavior:

engine.registerHandler('sendEmail', async (context, params) => {
  await emailService.send({
    to: params.recipient,
    subject: params.subject,
    body: params.body
  });
});

// Use in rules
{
  actions: [
    {
      type: 'sendEmail',
      params: {
        recipient: 'user@example.com',
        subject: 'Welcome!',
        body: 'Thanks for signing up'
      }
    }
  ]
}

Templates

Dynamic values using {{ }} syntax:

Time Expressions:

{ field: 'dueAt', operator: 'lessThan', value: '{{ now }}' }
{ field: 'createdAt', operator: 'greaterThan', value: '{{ now - 7d }}' }

Data References:

{ field: 'confirmEmail', operator: 'equals', value: '{{ data.email }}' }

In Actions:

{
  type: 'notify',
  params: {
    message: 'Welcome {{ data.name }}! Your ID is {{ data.id }}'
  }
}

See Templates Guide for complete reference.

Change Detection

Compare current and previous states:

const results = await engine.evaluate({
  data: { status: 'active', lastLogin: Date.now() },
  previousData: { status: 'pending', lastLogin: Date.now() - 86400000 }
});

// Use change operators
{ field: 'status', operator: 'changed' }
{ field: 'status', operator: 'changedFrom', value: 'pending' }
{ field: 'status', operator: 'changedTo', value: 'active' }
{ field: 'loginCount', operator: 'increased' }

API Overview

// Create engine
const engine = new Mairon<DataType>(config);

// Add rules
engine.addRule(rule);
engine.addRules([rule1, rule2]);

// Manage rules
engine.updateRule('rule-id', { enabled: false });
engine.removeRule('rule-id');
engine.enableRule('rule-id');
engine.disableRule('rule-id');

// Query rules
const rule = engine.getRule('rule-id');
const all = engine.getRules();
const enabled = engine.getRules({ enabled: true });
const priority = engine.getRules({ priority: { min: 50 } });

// Register handlers
engine.registerHandler('actionType', handler);
engine.registerHandlers({ action1: handler1, action2: handler2 });
engine.unregisterHandler('actionType');
const handlers = engine.getRegisteredHandlers(); // ['action1', 'action2']

// Custom operators
engine.registerOperator('isWeekend', (value) => {
  const day = new Date(value).getDay();
  return day === 0 || day === 6;
});

// Evaluate
let results = await engine.evaluate({ data });
results = await engine.evaluate({ data, previousData, context });

// Explain (debug why rules matched/didn't match)
const explanations = await engine.explain({ data });

// Serialization
const snapshot = engine.toJSON();
engine.loadJSON(snapshot);
const rules = engine.exportRules();
engine.importRules(rules, { replace: true });

// Events
engine.on('ruleMatched', (data) => console.log(data));
engine.on('actionExecuted', (data) => console.log(data));

// Stats (evaluations, rules, actions)
const stats = engine.getStats();

Configuration

const engine = new Mairon({
  strict: true,              // Throw on missing handlers
  immutable: true,           // Protect original data from mutation
  enableIndexing: true,      // Performance optimization for large rule sets
  maxRulesPerExecution: 100, // Limit rules per evaluation
  stopOnFirstError: false,   // Continue on action errors
});

Advanced Features

Event System

Hook into the evaluation lifecycle:

engine.on('beforeEvaluate', (data) => {
  console.log(`Evaluating ${data.ruleCount} rules`);
});

engine.on('ruleMatched', (data) => {
  console.log(`Rule ${data.rule.name} matched`);
});

engine.on('ruleTriggered', (data) => {
  console.log(`${data.sourceRule.name} triggered ${data.triggeredRule.name}`);
});

engine.on('actionFailed', (data) => {
  console.error(`Action failed:`, data.error);
});

engine.on('afterEvaluate', (data) => {
  console.log(`Completed in ${data.duration}ms`);
});

Rule Filtering

Query specific subsets of rules:

// By enabled status
engine.getRules({ enabled: true });

// By priority range
engine.getRules({ priority: { min: 50, max: 100 } });

// By tags
engine.getRules({ tags: ['critical', 'security'] });

// By IDs
engine.getRules({ ids: ['rule-1', 'rule-2'] });

Custom Context

Pass additional data for evaluation:

await engine.evaluate({
  data: order,
  context: {
    userId: 'user-123',
    requestId: 'req-456',
    environment: 'production',
    features: { betaAccess: true }
  }
});

// Access in templates
{ field: 'environment', operator: 'equals', value: '{{ context.environment }}' }

Immutable Mode

Protect your data from accidental mutation by action handlers:

const engine = new Mairon({ immutable: true });

engine.registerHandler('modify', (ctx) => {
  ctx.data.value = 999; // This modifies a clone, not original
});

const data = { value: 1 };
await engine.evaluate({ data });
console.log(data.value); // Still 1

Custom Operators

Add domain-specific operators, with optional aliases:

engine.registerOperator('isWeekend', (value) => {
  const day = new Date(value).getDay();
  return day === 0 || day === 6;
});

// Use in rules
{ field: 'timestamp', operator: 'isWeekend' }

// With aliases
engine.registerOperator('greaterThan', (a, cond) => a > cond.value, {
  aliases: ['gt', 'moreThan']
});

// Both work
{ field: 'age', operator: 'gt', value: 18 }

Async Operators

Operators can call external services:

engine.registerOperator('hasPermission', async (value, condition, ctx) => {
  const perms = await fetchPermissions(value);
  return perms.includes(condition.value);
});

Rule Chaining

Trigger dependent rules automatically:

engine.addRule({
  id: 'calculate-discount',
  name: 'Calculate Discount',
  conditions: { field: 'total', operator: 'greaterThan', value: 100 },
  actions: [{ type: 'applyDiscount' }],
  triggers: ['send-notification', 'update-loyalty-points']
});

Explainability

Debug why rules matched or didn't:

const explanations = await engine.explain({ data: user });

for (const exp of explanations) {
  console.log(`Rule: ${exp.ruleName}, Matched: ${exp.matched}`);
  // Inspect exp.explanation for detailed condition breakdown
}

Serialization

Export and import rules:

// Export
const snapshot = engine.toJSON();
fs.writeFileSync('rules.json', JSON.stringify(snapshot));

// Import
engine.loadJSON(JSON.parse(fs.readFileSync('rules.json')));

// Or just rules
const rules = engine.exportRules();
engine.importRules(rules, { replace: true });

License

MIT

About

A lightweight, type-safe rule engine. Define complex business rules declaratively and evaluate them dynamically at runtime.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors