A powerful, hierarchical rule engine for dynamic field configuration using a condition-action pattern. Flywheel enables complex form logic, calculations, and field state management with automatic dependency tracking and intelligent caching.
- Features
- Installation
- Quick Start
- Core Concepts
- API Reference
- Logic Operators
- Action Types
- Examples
- Advanced Features
- Migration Guide
- 🎯 Condition-Action Rules: Define when conditions trigger specific actions
- 🔄 Automatic Dependency Tracking: Intelligent evaluation order and caching
- ⚡ Performance Optimized: Smart caching with dependency-based invalidation
- 🏗️ Modular Architecture: Separate components for field state, caching, and dependency management
- 🚀 Field Initialization: Context-aware field initialization with init actions
- 🧩 Extensible: Custom operators, actions, and field state properties with dependency tracking
- 🔍 Debug-Friendly: Comprehensive validation and evaluation tracing
- 📊 Rich Logic System: 25+ built-in operators with unlimited nesting
- 🏗️ Type-Safe: Full TypeScript support with comprehensive type definitions
npm install flywheel-rulesimport{RuleEngine}from'flywheel-rules';// Initialize the rule engineconstengine=newRuleEngine({onEvent: (eventType,params)=>{console.log(`Event: ${eventType}`,params);}});// Define rulesconstruleSet={field1: [{condition: {">": [{"var": ["age"]},18]},action: {set: {target: "field1.isVisible",value: true}},priority: 1}]};// Load rules and evaluateengine.loadRuleSet(ruleSet);engine.updateFieldValue({age: 25});constfieldState=engine.evaluateField("field1");console.log(fieldState);// { isVisible: true, isRequired: false, ... }Rules follow a condition-action pattern with priority-based execution:
interfaceFieldRule{condition: Logic;// When to executeaction: Action;// What to execute priority: number;// Execution order (lower = first)}interfaceRuleSet{[fieldName: string]: FieldRule[];}Each field maintains state properties that can be modified by rules:
interfaceFieldState{value?: any;// Field valueisVisible: boolean;// Field visibilityisRequired: boolean;// Field requirementcalculatedValue?: any;// Computed values// ... extensible via onFieldStateCreation}Fields can be initialized with default state and values using init actions:
// Context-based field initialization{condition: {"==": [{"var": ["userType"]},"premium"]},action: {"init": {fieldState: {isVisible: true,theme: "premium"},fieldValue: "default-premium-value"}},priority: 0// Init actions typically run first}constructor(options?: RuleEngineOptions)Options:
onEvent?: Handler for custom events triggered by rulesonFieldStateCreation?: Customize default field state properties
// Load and validate rule setloadRuleSet(ruleSet: RuleSet): void// Update field values and trigger re-evaluationupdateFieldValue(fieldUpdates: Record<string,any>): string[]// Get current field valuegetFieldValue(fieldName: string): any
// Evaluate specific field and return complete stateevaluateField(fieldName: string): FieldState// Register reusable condition logicregisterSharedRules(sharedRules: Record<string,Logic>): void// Register lookup tables for data relationshipsregisterLookupTables(tables: LookupTable[]): void// Register custom action types with optional dependency trackingregisterActionHandler(params: {actionType: string;handler: (payload: any,context: any,helpers?: ActionHandlerOptions)=>void;dependencyVisitor?: CustomActionDependencyVisitor;}): void// Register custom logic operators with optional dependency trackingregisterCustomLogic(params: {operator: string;handler: (args: any[],context: any)=>any;dependencyVisitor?: CustomLogicDependencyVisitor;}): void// Debug utilitiesgetDependenciesOf(fieldName: string): string[]getLogicResolver(): LogicResolverinterfaceLookupTable{table: any[];// Array of lookup recordsprimaryKey: string;// Field to match againstname?: string;// Optional table name}{"var": ["fieldName"]}// Access field value{"var": ["field.nested.property"]}// Dot notation support{"var": ["$"]}// Current item in array operations{"+": [1,2,3]}// Addition: 6{"-": [10,3]}// Subtraction: 7{"*": [4,5]}// Multiplication: 20{"/": [15,3]}// Division: 5{"sqrt": [16]}// Square root: 4{"floor": [3.7]}// Floor: 3{"abs": [-5]}// Absolute: 5{">": [5,3]}// Greater than: true{"<": [2,7]}// Less than: true{">=": [5,5]}// Greater or equal: true{"<=": [3,5]}// Less or equal: true{"==": ["hello","hello"]}// Equal: true{"!=": [1,2]}// Not equal: true{"and": [true,false]}// Logical AND: false{"or": [true,false]}// Logical OR: true{"not": [true]}// Logical NOT: false{"if": [condition,trueValue,falseValue]}// Example{"if": [{">": [{"var": ["age"]},18]},"Adult","Minor"]}// Test if any element matches condition{"some": [{"var": ["items"]},{">": [{"var": ["$"]},10]}]}// Test if all elements match condition {"every": [{"var": ["scores"]},{">=": [{"var": ["$"]},60]}]}// Transform array elements{"map": [{"var": ["prices"]},{"*": [{"var": ["$"]},1.1]}]}{"fieldState": ["otherField.isVisible"]}// Access other field's state{"fieldState": ["field.calculatedValue"]}// Access calculated values// Lookup table syntax sugar{"varTable": "userId@users.name"}// Explicit lookup operation{"lookup": ["users",{"var": ["userId"]},"name"]}// Reference shared rule{"$ref": "isAdult"}// Register shared rulesengine.registerSharedRules({"isAdult": {">=": [{"var": ["age"]},18]},"hasEmail": {"!=": [{"var": ["email"]},""]}});// Initialize field state and/or value (processed before other rules){"init": {fieldState: {isVisible: true,currency: "USD"},fieldValue: "default-value"}}// Conditional initialization based on context{condition: {"==": [{"var": ["user.role"]},"premium"]},action: {"init": {fieldState: {paymentMethods: ["card","paypal","crypto"],allowSavedCards: true},fieldValue: {paymentMethod: "card"}}},priority: 0// Init rules typically use priority 0 or negative}// Set any field property (value or state){"set": {target: "fieldName.value",value: "Hello World"}}{"set": {target: "fieldName.isVisible",value: true}}{"set": {target: "fieldName.customProperty",value: "custom"}}// Copy value from another field{"copy": {source: "sourceField.value",target: "targetField.value"}}// Calculate field values{"calculate": {target: "total.value",formula: {"+": [{"var": ["price.value"]},{"var": ["tax.value"]}]}}}// Calculate field state properties {"calculate": {target: "field.calculatedValue",formula: {"+": [{"var": ["price.value"]},{"var": ["tax.value"]}]}}}// Trigger custom events{"trigger": {event: "validation_failed",params: {field: "email"}}}// Execute multiple actions{"batch": [{"set": {target: "field1.isVisible",value: true}},{"calculate": {target: "total.value",formula: {"+": [1,2]}}},{"trigger": {event: "form_updated"}}]}Flywheel provides powerful dependency tracking for custom operations and actions, ensuring proper cache invalidation and evaluation order.
// For custom logic operatorsinterfaceCustomLogicDependencyVisitor{visitLogic(params: {operator: string;operands: any;}): DependencyInfo;}// For custom actionsinterfaceCustomActionDependencyVisitor{visitAction(params: {actionType: string;payload: any;}): DependencyInfo;}interfaceDependencyInfo{dependencies: string[];// Fields this operation reads fromdependents: string[];// Fields this operation writes to}// Create a dependency visitor for a merge actionconstmergeActionVisitor: CustomActionDependencyVisitor={visitAction: ({ payload }): DependencyInfo=>{return{dependencies: payload.sources||[],// Fields being readdependents: payload.target ? [payload.target] : []// Fields being written};}};// Register action with dependency trackingengine.registerActionHandler({actionType: 'merge',handler: (payload,context,helpers)=>{constresult=payload.sources.map(field=>context[field]?.value||'').join(' ');helpers?.onFieldPropertySet?.(payload.target+'.value',result);},dependencyVisitor: mergeActionVisitor});// Now the engine properly tracks dependenciesconstruleSet={fullName: [{condition: true,action: {merge: {sources: ["firstName","lastName"],target: "fullName"}}asany,priority: 1}]};engine.loadRuleSet(ruleSet);engine.updateFieldValue({firstName: "John",lastName: "Doe"});// When firstName changes, fullName is automatically invalidated and re-evaluatedconstinvalidated=engine.updateFieldValue({firstName: "Jane"});console.log(invalidated);// includes 'fullName'// Create a dependency visitor for a compareFields operatorconstcompareFieldsVisitor: CustomLogicDependencyVisitor={visitLogic: ({ operands }): DependencyInfo=>{// This operator compares two field valuesconst[field1,field2]=operands;return{dependencies: [field1,field2],// Reads from both fieldsdependents: []// Logic operators don't write to fields};}};// Register logic with dependency trackingengine.registerCustomLogic({operator: 'compareFields',handler: (args,context)=>{const[field1,field2]=args;constvalue1=context[field1]?.value;constvalue2=context[field2]?.value;returnvalue1===value2;},dependencyVisitor: compareFieldsVisitor});// Use in rulesconstvalidationRules={passwordMatch: [{condition: {compareFields: ["password","confirmPassword"]},action: {set: {target: "submitButton.isVisible",value: true}},priority: 1}]};engine.loadRuleSet(validationRules);// submitButton now properly depends on both password and confirmPassword fieldsAutomatic Cache Invalidation: When dependencies change, dependent fields are automatically invalidated and re-evaluated.
Correct Evaluation Order: Dependencies are resolved in the correct order, preventing stale data.
Performance Optimization: Only affected fields are re-evaluated when changes occur.
Debug Visibility: Use getDependenciesOf() to see the complete dependency graph including custom operations.
// Debug dependency relationshipsconsole.log(engine.getDependenciesOf("fullName"));// Output: ["firstName", "lastName"] - includes custom action dependencies// ❌ Without dependency visitor - cache invalidation brokenengine.registerActionHandler({actionType: 'concat',handler: (payload,context,helpers)=>{// This works, but dependency tracking is brokenconstresult=payload.sources.map(s=>context[s]?.value||'').join('');helpers?.onFieldPropertySet?.(payload.target+'.value',result);}// No dependencyVisitor provided!});// When firstName changes, fullName won't be invalidated// Leading to stale data and incorrect results// ✅ With dependency visitor - proper cache invalidationconstconcatVisitor: CustomActionDependencyVisitor={visitAction: ({ payload })=>({dependencies: payload.sources||[],dependents: payload.target ? [payload.target] : []})};engine.registerActionHandler({actionType: 'concat',handler: (payload,context,helpers)=>{constresult=payload.sources.map(s=>context[s]?.value||'').join('');helpers?.onFieldPropertySet?.(payload.target+'.value',result);},dependencyVisitor: concatVisitor// ✅ Proper dependency tracking});// Now firstName changes correctly invalidate fullNameconstengine=newRuleEngine({onFieldStateCreation: ()=>({theme: 'light',readOnly: false,customProps: {}})});constinitRules={"userSettings": [// Premium users get advanced settings{condition: {"==": [{"var": ["user.subscription"]},"premium"]},action: {"init": {fieldState: {isVisible: true,theme: 'dark',features: ['advanced-analytics','custom-themes','api-access'],maxExports: 'unlimited'},fieldValue: {theme: 'dark',notifications: true}}},priority: 0},// Free users get basic settings{condition: {"==": [1,1]},// Fallback ruleaction: {"init": {fieldState: {isVisible: true,theme: 'light',features: ['basic-analytics'],maxExports: 5},fieldValue: {theme: 'light',notifications: false}}},priority: 1}]};engine.loadRuleSet(initRules);engine.updateFieldValue({user: {subscription: 'premium'}});constsettings=engine.evaluateField("userSettings");console.log(settings);// {// isVisible: true,// theme: 'dark',// features: ['advanced-analytics', 'custom-themes', 'api-access'],// maxExports: 'unlimited',// readOnly: false,// customProps: {}// }constengine=newRuleEngine();constformRules={"spouseInfo": [{condition: {"==": [{"var": ["maritalStatus.value"]},"married"]},action: {"set": {target: "spouseInfo.isVisible",value: true}},priority: 1}],"dependentCount": [{condition: {">": [{"var": ["children.value"]},0]},action: {"set": {target: "dependentCount.isVisible",value: true}},priority: 1}]};engine.loadRuleSet(formRules);// User selects "married" - spouse info becomes visibleengine.updateFieldValue({maritalStatus: "married"});console.log(engine.evaluateField("spouseInfo").isVisible);// true// User enters children count - dependent section appearsengine.updateFieldValue({children: 2});console.log(engine.evaluateField("dependentCount").isVisible);// trueconstcalculationRules={"totalPrice": [{condition: true,// Always executeaction: {"calculate": {target: "totalPrice.calculatedValue",formula: {"+": [{"*": [{"var": ["quantity.value"]},{"var": ["unitPrice.value"]}]},{"if": [{">=": [{"var": ["quantity.value"]},10]},0,// No tax for bulk orders{"*": [{"*": [{"var": ["quantity.value"]},{"var": ["unitPrice.value"]}]},0.08]}]}]}}},priority: 1}],"submitButton": [{condition: {">": [{"fieldState": ["totalPrice.calculatedValue"]},0]},action: {"set": {target: "submitButton.isVisible",value: true}},priority: 1}]};engine.loadRuleSet(calculationRules);engine.updateFieldValue({quantity: 5,unitPrice: 20.00});consttotalField=engine.evaluateField("totalPrice");console.log(totalField.calculatedValue);// 108.00 (100 + 8% tax)constsubmitButton=engine.evaluateField("submitButton");console.log(submitButton.isVisible);// true// Register product catalogengine.registerLookupTables([{name: "products",primaryKey: "id",table: [{id: "P001",name: "Laptop",category: "electronics",price: 999.99},{id: "P002",name: "Book",category: "media",price: 15.99},{id: "P003",name: "Shirt",category: "clothing",price: 29.99}]}]);constproductRules={"productName": [{condition: {"!=": [{"var": ["selectedProductId.value"]},""]},action: {"calculate": {target: "productName.calculatedValue",formula: {"varTable": "selectedProductId.value@products.name"}}},priority: 1}],"shippingSection": [{condition: {"==": [{"varTable": "selectedProductId.value@products.category"},"electronics"]},action: {"set": {target: "shippingSection.isVisible",value: true}},priority: 1}]};engine.loadRuleSet(productRules);engine.updateFieldValue({selectedProductId: "P001"});console.log(engine.evaluateField("productName").calculatedValue);// "Laptop"console.log(engine.evaluateField("shippingSection").isVisible);// true// Register reusable business logicengine.registerSharedRules({"isAdult": {">=": [{"var": ["age.value"]},18]},"hasValidEmail": {"and": [{"!=": [{"var": ["email.value"]},""]},{"like": [{"var": ["email.value"]},"*@*.*"]}]},"isEligibleForDiscount": {"and": [{"$ref": "isAdult"},{">": [{"var": ["membershipYears.value"]},2]}]}});constmembershipRules={"discountField": [{condition: {"$ref": "isEligibleForDiscount"},action: {"set": {target: "discountField.isVisible",value: true}},priority: 1},{condition: {"$ref": "isEligibleForDiscount"},action: {"calculate": {target: "discountField.calculatedValue",formula: {"*": [{"var": ["orderTotal.value"]},0.1]}}},priority: 2}],"emailRequired": [{condition: {"not": [{"$ref": "hasValidEmail"}]},action: {"set": {target: "email.isRequired",value: true}},priority: 1}]};engine.loadRuleSet(membershipRules);engine.updateFieldValue({age: 25,membershipYears: 3,orderTotal: 100,email: ""});console.log(engine.evaluateField("discountField").isVisible);// trueconsole.log(engine.evaluateField("discountField").calculatedValue);// 10console.log(engine.evaluateField("email").isRequired);// trueconstengine=newRuleEngine({onEvent: (eventType,params)=>{switch(eventType){case'validation_error':
console.error('Validation failed:',params);break;case'calculation_complete':
console.log('Calculation result:',params.result);break;case'audit_log':
// Log to external systembreak;}}});// Register custom action with dependency trackingconstvalidateVisitor: CustomActionDependencyVisitor={visitAction: ({ payload })=>({dependencies: payload.field ? [payload.field] : [],// Reads from the field being validateddependents: []// Validation doesn't write to fields directly})};engine.registerActionHandler({actionType: 'validate',handler: (payload,context,helpers)=>{const{ field, rules }=payload;constfieldValue=engine.getFieldValue(field);constisValid=validateField(fieldValue,rules);if(!isValid){// Trigger event through the helper systemhelpers?.onEvent?.('validation_error',{ field });}},dependencyVisitor: validateVisitor});constvalidationRules={"passwordConfirm": [{condition: {"!=": [{"var": ["password.value"]},{"var": ["confirmPassword.value"]}]},action: {"trigger": {event: "validation_error",params: {field: "confirmPassword",message: "Passwords do not match"}}},priority: 1}],"emailField": [{condition: {"!=": [{"var": ["email.value"]},""]},action: {"validate": {field: "email",rules: ["required","email_format"]}},priority: 1}],"submitButton": [{condition: {"==": [{"var": ["password.value"]},{"var": ["confirmPassword.value"]}]},action: {"set": {target: "submitButton.isVisible",value: true}},priority: 1}]};engine.loadRuleSet(validationRules);// Trigger validation when email is enteredengine.updateFieldValue({email: "invalid-email",password: "secret123",confirmPassword: "secret123"});// This will trigger the custom 'validate' action for emailFieldconstengine=newRuleEngine({onFieldStateCreation: (props)=>({
...props,// Add custom propertiespermissions: {read: true,write: true},validation: {errors: [],warnings: []},metadata: {lastModified: null}})});// Rules can now target custom propertiesconstcustomRules={"adminField": [{condition: {"==": [{"var": ["userRole.value"]},"admin"]},action: {"set": {target: "adminField.permissions.write",value: true}},priority: 1}]};// Register custom operators with dependency trackingconstcontainsVisitor: CustomLogicDependencyVisitor={visitLogic: ({ operands })=>({// If first operand is a field reference, depend on itdependencies: typeofoperands[0]==='string'&&!operands[0].includes('.') ? [operands[0]] : [],dependents: []})};constcurrencyVisitor: CustomLogicDependencyVisitor={visitLogic: ({ operands })=>({dependencies: typeofoperands[0]==='string' ? [operands[0]] : [],dependents: []})};engine.registerCustomLogic({operator: 'contains',handler: (args,context)=>{const[haystack,needle]=args;returnString(haystack).includes(String(needle));},dependencyVisitor: containsVisitor});engine.registerCustomLogic({operator: 'currency',handler: (args,context)=>{const[amount]=args;returnnewIntl.NumberFormat('en-US',{style: 'currency',currency: 'USD'}).format(amount);},dependencyVisitor: currencyVisitor});// Use in rulesconstcustomLogicRules={"warningMessage": [{condition: {"contains": [{"var": ["description.value"]},"urgent"]},action: {"set": {target: "warningMessage.isVisible",value: true}},priority: 1}]};Flywheel automatically optimizes performance through:
// 1. Dependency-based cachingengine.updateFieldValue({age: 25});// Only age-dependent fields re-evaluate// 2. Intelligent invalidationconstinvalidatedFields=engine.updateFieldValue({name: "John"});console.log(invalidatedFields);// ['displayName', 'greeting', ...]// 3. Debug utilities for performance analysisconsole.log(engine.getDependenciesOf("calculatedTotal"));// ['price', 'quantity', 'taxRate', 'discountPercent']// Comprehensive debugging utilitiesconstdependencies=engine.getDependenciesOf("totalPrice");console.log("totalPrice depends on:",dependencies);// Validation utilitiestry{engine.loadRuleSet(ruleSet);}catch(error){console.error("Rule validation failed:",error.message);}// Test rule evaluationengine.updateFieldValue({age: 30,email: "test@example.com"});constresult=engine.evaluateField("userProfile");expect(result.isVisible).toBe(true);Problem: Custom actions don't trigger re-evaluation of dependent fields.
Solution: Ensure you provide a dependencyVisitor when registering the action.
Problem: Fields show stale data after custom logic operations. Solution: Make sure your custom logic dependency visitor correctly identifies field dependencies.
Problem: Circular dependencies detected with custom operations. Solution: Review your dependency visitors to ensure they don't create circular references.
// ❌ Incorrect - missing dependenciesconstbadVisitor: CustomActionDependencyVisitor={visitAction: ()=>({dependencies: [],dependents: []})};// ✅ Correct - properly declares dependenciesconstgoodVisitor: CustomActionDependencyVisitor={visitAction: ({ payload })=>({dependencies: payload.sources||[],dependents: payload.target ? [payload.target] : []})};Flywheel provides a comprehensive solution for complex dynamic form logic, business rule management, and field state orchestration. Its powerful yet intuitive API makes it easy to build sophisticated, reactive user interfaces with minimal code.
For more examples and advanced usage patterns, see the test files in the repository.