npm package: @kaskad/eval-tree
Description: A reactive formula evaluation engine that transforms AST nodes into MobX-tracked computations with first-class async support.
Latest version: 0.0.27

# @kaskad/eval-tree

A reactive formula evaluation engine that transforms AST nodes into MobX-tracked computations with first-class async support.

## Features

- **Reactive Evaluation**: MobX-based pull reactive system with automatic dependency tracking
- **First-Class Async**: Native support for Promises and RxJS Observables
- **Lazy Evaluation**: Control flow functions only evaluate needed branches
- **Resource Management**: Explicit disposal pattern for subscription cleanup
- **Dual Function Model**: Simple imperative functions and advanced reactive functions
- **State Tracking**: Explicit `evaluating`, `settled`, and `failed` states for UI feedback

## Architecture Overview

eval-tree transforms formula AST nodes into reactive MobX computations:

```
EvalNode (AST) → evaluateNode() → EvaluationResult → EvalState
```

### Data Flow

1. **Input**: `EvalNode` - Immutable AST from formula parser
2. **Processing**: `evaluateNode()` - Recursive evaluation with MobX computeds
3. **Output**: `EvaluationResult` - Contains MobX computed + disposal functions
4. **State**: `EvalState` - Tracks value, loading, and error states

### Core Types

```typescript
// Input: AST nodes
type EvalNode =
  | ValueEvalNode      // { type: 'value', value: unknown }
  | ArrayEvalNode      // { type: 'array', items: EvalNode[] }
  | ObjectEvalNode     // { type: 'object', properties: [...] }
  | FunctionEvalNode   // { type: 'function', name: string, args: EvalNode[] }

// Output: Evaluation state
interface EvalState<T = unknown> {
  value: T;            // The computed result
  evaluating: boolean; // Async operation in progress
  settled: boolean;    // Evaluation completed (success or failure)
  failed: boolean;     // Error occurred during evaluation
}

// Result: Reactive container + cleanup
interface EvaluationResult<S extends EvalState = EvalState> {
  readonly computed: IComputedValue<S>;  // MobX reactive value
  readonly disposers: ReadonlyArray<IReactionDisposer>; // Cleanup functions
}
```

## Quick Start

### Basic Evaluation

```typescript
import { evaluateNode, EvalState } from '@kaskad/eval-tree';

// Simple value node
const valueNode = { type: 'value', value: 42 };
const result = evaluateNode(valueNode, {
  componentId: 'root',
  path: []
});

// Read the evaluated state
const state: EvalState = result.computed.get();
console.log(state.value);      // 42
console.log(state.settled);    // true
console.log(state.evaluating); // false

// Clean up resources when done
result.disposers.forEach(dispose => dispose());
```

### Function Registration

```typescript
import { FunctionRegistry, FunctionDefinition } from '@kaskad/eval-tree';

// Register an imperative function
const plus: FunctionDefinition = {
  kind: 'imperative',
  execute: (ctx, num1: number, num2: number) => num1 + num2,
  args: [
    { valueType: { type: 'number' } },
    { valueType: { type: 'number' } }
  ],
  returns: { type: 'number' }
};

FunctionRegistry.getInstance().setFunction('plus', plus);

// Evaluate a function call
const functionNode = {
  type: 'function',
  name: 'plus',
  args: [
    { type: 'value', value: 10 },
    { type: 'value', value: 32 }
  ]
};

const position = { componentId: 'root', path: [] };
const result = evaluateNode(functionNode, position);
console.log(result.computed.get().value); // 42
```

### Async Functions

```typescript
// Async function returns Promise
const fetchData: FunctionDefinition = {
  kind: 'imperative',
  execute: async (ctx, url: string) => {
    const response = await fetch(url);
    return response.json();
  },
  args: [{ valueType: { type: 'string' } }],
  returns: { type: 'unknown' }
};

FunctionRegistry.getInstance().setFunction('fetch', fetchData);

// Evaluate async function
const asyncNode = {
  type: 'function',
  name: 'fetch',
  args: [{ type: 'value', value: '/api/data' }]
};

const position = { componentId: 'root', path: [] };
const result = evaluateNode(asyncNode, position);

// Initially evaluating
console.log(result.computed.get().evaluating); // true
console.log(result.computed.get().value);      // null

// After Promise resolves (MobX will auto-update)
// evaluating: false, value: { ... response data ... }
```

## Core Concepts

### EvalNode Types

**Value Node** - Wraps a literal value:
```typescript
{ type: 'value', value: 42 }
{ type: 'value', value: 'hello' }
{ type: 'value', value: null }
```

**Array Node** - Evaluates to an array:
```typescript
{
  type: 'array',
  items: [
    { type: 'value', value: 1 },
    { type: 'value', value: 2 },
    { type: 'function', name: 'multiply', args: [...] }
  ]
}
```

**Object Node** - Evaluates to an object:
```typescript
{
  type: 'object',
  properties: [
    {
      key: { type: 'value', value: 'name' },
      value: { type: 'value', value: 'Alice' }
    },
    {
      key: { type: 'function', name: 'getDynamicKey', args: [] },
      value: { type: 'value', value: 'dynamic value' }
    }
  ]
}
```

**Function Node** - Calls a registered function:
```typescript
{
  type: 'function',
  name: 'plus',
  args: [
    { type: 'value', value: 10 },
    { type: 'value', value: 32 }
  ]
}
```

### EvalState Fields

The evaluation state tracks three aspects:

**Value** - The computed result:
```typescript
state.value  // The actual computed value (any type)
```

**Loading** - Async operation status:
```typescript
state.evaluating  // true if Promise pending or Observable emitting
state.settled     // true when evaluation completed (success or failure)
```

**Error** - Failure tracking:
```typescript
state.failed  // true if evaluation failed or async operation errored
```

**State Transitions:**
```
Initial:    { value: null, evaluating: false, settled: false, failed: false }
Sync eval:  { value: 42,   evaluating: false, settled: true,  failed: false }
Async start:{ value: null, evaluating: true,  settled: false, failed: false }
Async done: { value: data, evaluating: false, settled: true,  failed: false }
Error:      { value: null, evaluating: false, settled: true,  failed: true }
```

### Reactive Evaluation

eval-tree uses MobX's pull-based reactive system:

1. **All results wrapped in `computed()`** - Automatic memoization
2. **Lazy evaluation** - Only computed when `.get()` is called
3. **Automatic updates** - MobX tracks dependencies and recomputes when they change
4. **Glitch-free** - Updates happen in transactions, no intermediate states

```typescript
const position = { componentId: 'root', path: [] };
const result = evaluateNode(node, position);

// Subscribe to changes
autorun(() => {
  const state = result.computed.get();
  console.log('Value changed:', state.value);
});

// When dependencies change, autorun re-executes automatically
```

## Function System

eval-tree supports two function models: **imperative** (simple) and **reactive** (advanced).

### Imperative Functions

Imperative functions are the simplest way to add custom functionality. All arguments are evaluated before execution.

```typescript
const multiply: FunctionDefinition = {
  kind: 'imperative',
  execute: (ctx, a: number, b: number) => a * b,
  args: [
    { valueType: { type: 'number' } },
    { valueType: { type: 'number' } }
  ],
  returns: { type: 'number' }
};

FunctionRegistry.getInstance().setFunction('multiply', multiply);
```

**Features:**
- ✅ Simple API - just write a regular function
- ✅ Arguments auto-unwrapped from MobX computeds
- ✅ Can return Promise or Observable for async
- ❌ Cannot implement lazy evaluation (all args evaluated)
- ❌ Cannot implement short-circuit logic

**When to use:**
- Math operations: `plus`, `multiply`, `divide`
- String operations: `concat`, `substring`, `toUpperCase`
- Data transformations: `map`, `filter`, `reduce`
- Async operations: `fetch`, `delay`, `timeout`

### Reactive Functions

Reactive functions receive unevaluated MobX computeds, enabling lazy evaluation and short-circuit logic.

```typescript
import { computed, IComputedValue } from 'mobx';
import {
  ReactiveFunctionDefinition,
  FunctionExecutionStateBuilder,
  EvalState
} from '@kaskad/eval-tree';

const ifFn: ReactiveFunctionDefinition = {
  kind: 'reactive',
  execute: (ctx, args: IComputedValue<EvalState>[]) => {
    return computed(() => {
      const state = new FunctionExecutionStateBuilder();

      // Evaluate condition
      const conditionState = args[0].get();
      const earlyReturn = state.checkReady(conditionState);
      if (earlyReturn) return earlyReturn;

      // Lazy: only evaluate the selected branch
      const branchState = conditionState.value
        ? args[1].get()  // true branch
        : args[2].get(); // false branch

      const branchEarlyReturn = state.checkReady(branchState);
      if (branchEarlyReturn) return branchEarlyReturn;

      return state.success(branchState.value);
    });
  },
  args: { type: 'fixed', count: 3, valueType: { type: 'unknown' } },
  returns: { type: 'unknown' }
};
```

**Features:**
- ✅ Full control over argument evaluation
- ✅ Can implement lazy evaluation (only evaluate needed args)
- ✅ Can implement short-circuit logic (`and`, `or`)
- ✅ Direct access to argument states (evaluating, failed)
- ❌ More complex API (must handle states manually)

**When to use:**
- Control flow: `if`, `switch`, `case`
- Short-circuit logic: `and`, `or`, `coalesce`
- Conditional evaluation: `try`, `catch`, `default`
- Advanced state handling: `retry`, `debounce`, `throttle`

### FunctionExecutionStateBuilder

Helper class for building execution states in reactive functions:

```typescript
const state = new FunctionExecutionStateBuilder();

// Track argument states
state.trackArg(argState);  // Accumulates evaluating/failed flags

// Check if argument is ready
const earlyReturn = state.checkReady(argState);
if (earlyReturn) return earlyReturn;  // Returns notReady() if not settled

// Return success
return state.success(computedValue);

// Or return not ready
return state.notReady();  // Returns partial state with flags
```

### FunctionRegistry API

```typescript
const registry = FunctionRegistry.getInstance();

// Register single function
registry.setFunction('myFn', functionDefinition);

// Register multiple functions
registry.setFunctions({
  plus: plusDefinition,
  minus: minusDefinition,
  multiply: multiplyDefinition
});

// Get function (throws if not found)
const fn = registry.getFunction('plus');

// Check if function exists
if (registry.hasFunction('myFn')) {
  // ...
}

// Get all function names
const names = registry.getFunctionNames();  // ['plus', 'minus', 'multiply']

// Reset registry (useful for testing)
FunctionRegistry.reset();
```

## Async Handling

eval-tree has first-class support for async values (Promises and RxJS Observables).

### Promise Support

When a function returns a Promise, it's automatically wrapped in a `PromiseValue` object tracked by MobX:

```typescript
const asyncFn: FunctionDefinition = {
  kind: 'imperative',
  execute: async (ctx, url: string) => {
    const response = await fetch(url);
    return response.json();
  },
  args: [{ valueType: { type: 'string' } }],
  returns: { type: 'unknown' }
};

// State transitions:
// 1. Initial: { value: null, evaluating: true, settled: false, failed: false }
// 2. Resolved: { value: data, evaluating: false, settled: true, failed: false }
// 3. Rejected: { value: null, evaluating: false, settled: true, failed: true }
```

**PromiseValue Structure:**
```typescript
interface PromiseValue {
  kind: 'promise';
  current: unknown;   // Resolved value (null if pending/rejected)
  error: unknown;     // Rejection error (null if pending/resolved)
  pending: boolean;   // true until Promise settles
}
```

### Observable Support

RxJS Observables are wrapped in `ObservableValue` with automatic subscription management:

```typescript
import { interval } from 'rxjs';
import { map } from 'rxjs/operators';

const streamFn: FunctionDefinition = {
  kind: 'imperative',
  execute: (ctx) => {
    return interval(1000).pipe(
      map(n => n * 2)
    );
  },
  args: [],
  returns: { type: 'number' }
};

// State transitions:
// 1. Initial: { value: null, evaluating: true, settled: false, failed: false }
// 2. First emit: { value: 0, evaluating: false, settled: true, failed: false }
// 3. Next emit: { value: 2, evaluating: false, settled: true, failed: false }
// 4. Error: { value: 2, evaluating: false, settled: true, failed: true }
//    (note: current value preserved on error)
```

**ObservableValue Structure:**
```typescript
interface ObservableValue {
  kind: 'observable';
  current: unknown;      // Latest emitted value
  error: unknown;        // Error if stream errored
  pending: boolean;      // false after first emission or completion
  subscription: Subscription;  // RxJS subscription (for cleanup)
}
```

### Subscription Cleanup

eval-tree automatically manages RxJS subscriptions:

1. **Creation**: Subscription created when Observable is wrapped
2. **Re-evaluation**: Old subscription unsubscribed when function re-evaluates
3. **Disposal**: Subscription unsubscribed when disposer called

```typescript
const position = { componentId: 'root', path: [] };
const result = evaluateNode(observableNode, position);

// Subscription active, receiving values

// When done, clean up
result.disposers.forEach(dispose => dispose());
// Subscription automatically unsubscribed
```

### Type Guards

Check async value types:

```typescript
import {
  isAsyncValue,
  isPromiseValue,
  isObservableValue
} from '@kaskad/eval-tree';

if (isPromiseValue(value)) {
  console.log('Promise:', value.current, value.pending);
}

if (isObservableValue(value)) {
  console.log('Observable:', value.current);
  value.subscription.unsubscribe();  // Manual cleanup if needed
}
```

## API Reference

### evaluateNode()

Main entry point for evaluating AST nodes.

```typescript
function evaluateNode<S extends EvalState = EvalState>(
  evalNode: EvalNode,
  position: NodePosition
): EvaluationResult<S>
```

**Parameters:**
- `evalNode` - AST node to evaluate (value, array, object, or function)
- `position` - Node position context (component ID and path)

**Returns:**
- `EvaluationResult` - Contains MobX computed and disposal functions

**Example:**
```typescript
const result = evaluateNode(
  { type: 'value', value: 42 },
  { componentId: 'root', path: ['field'] }
);

const state = result.computed.get();
console.log(state.value);  // 42

result.disposers.forEach(d => d());  // Cleanup
```

### FunctionRegistry

Singleton for managing formula functions.

```typescript
class FunctionRegistry {
  static getInstance(): FunctionRegistry
  static reset(): FunctionRegistry

  setFunction(name: string, definition: FunctionDefinition | ReactiveFunctionDefinition): void
  setFunctions(definitions: Record<string, FunctionDefinition | ReactiveFunctionDefinition>): void
  getFunction(name: string): ReactiveFunctionDefinition
  hasFunction(name: string): boolean
  getFunctionNames(): string[]
}
```

### ensureReactive()

Converts imperative functions to reactive (or returns reactive unchanged).

```typescript
function ensureReactive(
  definition: FunctionDefinition | ReactiveFunctionDefinition
): ReactiveFunctionDefinition
```

Automatically called by `FunctionRegistry.setFunction()`.

### wrapImperativeAsReactive()

Wraps an imperative function for the reactive pipeline.

```typescript
function wrapImperativeAsReactive(
  imperativeDef: FunctionDefinition
): ReactiveFunctionDefinition
```

Handles argument evaluation, error catching, and async value wrapping.

### FunctionExecutionStateBuilder

Helper for building execution states in reactive functions.

```typescript
class FunctionExecutionStateBuilder {
  trackArg(argState: EvalState): void
  checkReady(argState: EvalState): FunctionExecutionState | null
  notReady(): FunctionExecutionState
  success(value: unknown): FunctionExecutionState
}
```

**Usage:**
```typescript
const state = new FunctionExecutionStateBuilder();

for (const argComputed of args) {
  const argState = argComputed.get();
  const earlyReturn = state.checkReady(argState);
  if (earlyReturn) return earlyReturn;

  // Use argState.value here
}

return state.success(result);
```

## Design Decisions

### Pull-based Reactivity (MobX) vs Push-based (RxJS)

**Choice:** MobX `computed()` for pull-based reactivity

**Rationale:**
- **Lazy evaluation**: Computations only run when values are read
- **Natural backpressure**: Don't read → don't compute
- **Glitch-free updates**: MobX transactions prevent intermediate states
- **Better debugging**: Call stack intact (not async callbacks)
- **Lower learning curve**: Simpler mental model than reactive streams

**Trade-off:** Push-based would enable reactive composition patterns, but pull-based is better suited for formula evaluation where values are queried on-demand.

### Manual Disposal vs Automatic GC

**Choice:** Explicit disposal pattern with returned disposers array

**Rationale:**
- **Deterministic cleanup**: Know exactly when subscriptions unsubscribe
- **Resource control**: Critical for RxJS subscriptions (can't rely on GC)
- **No GC pressure**: Cleanup happens immediately, not at GC time
- **Explicit lifecycle**: Caller controls when to dispose (flexible)

**Trade-off:** Requires caller to remember to call disposers (risk of leaks), but provides control needed for production systems. Future: Could use TC39 `using` declarations for automatic disposal.

### Dual Function Model (Imperative + Reactive)

**Choice:** Two function types with automatic adapter

**Rationale:**
- **Progressive complexity**: Simple functions use imperative, advanced use reactive
- **Optimal for common case**: 80% of functions don't need lazy evaluation
- **Single internal model**: All functions become reactive internally
- **Type safety**: Discriminated union prevents mixing

**Trade-off:** Two APIs to learn, but the imperative API is much simpler and auto-upgraded.

### Explicit State vs Monadic Error Handling

**Choice:** Explicit `{ value, evaluating, failed }` state object

**Rationale:**
- **UI-friendly**: Can show loading spinners (`evaluating` flag)
- **Partial state**: Can inspect value even if evaluation failed
- **Multi-state tracking**: Need to distinguish pending/loading/error/success
- **No type ceremony**: Simpler than Result/Either monads for UI code

**Trade-off:** No compile-time guarantee you check `failed` before using `value`, but more practical for UI-driven evaluation.

## Performance Optimization Guide

### MobX Memoization

**Automatic Caching:**
All evaluation results are wrapped in `computed()`, providing automatic memoization:

```typescript
const position = { componentId: 'root', path: [] };
const result = evaluateNode(node, position);

// First call: evaluates and caches
const state1 = result.computed.get();

// Second call: returns cached value (if deps unchanged)
const state2 = result.computed.get();
```

**Best Practice:** Share `IComputedValue` instances across multiple readers to benefit from memoization.

### Avoiding Unnecessary Dependency Tracking

**Problem:** Reading all children creates dependencies even if not needed:

```typescript
// BAD: Reads all children even if first is evaluating
const evaluating = items.some(item => item.get().evaluating);
```

**Solution:** Early exit to avoid tracking unused dependencies:

```typescript
// GOOD: Only reads children until finding evaluating
let evaluating = false;
for (const item of items) {
  const state = item.get();
  if (state.evaluating) {
    evaluating = true;
    break;  // Don't track remaining children
  }
}
```

**Impact:** Reduces recomputation frequency by avoiding dependencies on irrelevant children.

### Function Execution Overhead

**Cost:** Each function call has two levels of `computed()` wrapping:
1. Function execution computed (from reactive function)
2. Subscription tracking computed (for cleanup)

**Optimization:** For non-Observable functions, the double wrapping is overhead. Imperative functions are more efficient if you don't need lazy evaluation.

**Best Practice:**
- Use **imperative** for: math, string ops, data transformations
- Use **reactive** only when you need: lazy eval, short-circuit, custom state handling

### State Aggregation Patterns

**Efficient aggregation:**

```typescript
// Aggregate states with early exit
let evaluating = false;
let failed = false;

for (const computed of children) {
  const state = computed.get();

  if (state.evaluating) evaluating = true;
  if (state.failed) failed = true;

  // Could early-exit here if both flags set
  if (evaluating && failed) break;
}
```

**Debug names:** Set meaningful names for computeds to help MobX devtools:

```typescript
computed(() => { /* ... */ }, {
  name: 'myComponent.field.formula'
})
```

### Memory Management

**Dispose when done:**

```typescript
const { computed, disposers } = evaluateNode(node, position);

// Use the computed

// CRITICAL: Always dispose to prevent memory leaks
disposers.forEach(dispose => dispose());
```

**Best Practice:** Store disposers in a container and batch-dispose:

```typescript
class EvaluationContext {
  private disposers: IReactionDisposer[] = [];

  evaluate(node: EvalNode, position: NodePosition): IComputedValue<EvalState> {
    const { computed, disposers } = evaluateNode(node, position);
    this.disposers.push(...disposers);
    return computed;
  }

  dispose(): void {
    this.disposers.forEach(d => d());
    this.disposers = [];
  }
}
```

## Resource Management

### Disposal Pattern

Every evaluation returns disposers that MUST be called to prevent resource leaks:

```typescript
const { computed, disposers } = evaluateNode(node, position);

// When done with the evaluation:
disposers.forEach(dispose => dispose());
```

**What disposers clean up:**
1. **RxJS subscriptions** - From Observable-returning functions
2. **Child disposers** - Propagated from nested evaluations
3. **MobX reactions** - If reactive functions create reactions (rare)

### Disposal Tree

Disposers follow the evaluation tree structure:

```
evaluateObject()
  ├─ evaluateNode(key1)    → [disposer1, disposer2]
  ├─ evaluateNode(value1)  → [disposer3]
  ├─ evaluateNode(key2)    → []
  └─ evaluateNode(value2)  → [disposer4, disposer5]

Result disposers: [disposer1, disposer2, disposer3, disposer4, disposer5]
```

**Pattern:** Parent collects all child disposers and returns them as a flat array.

### Subscription Lifecycle

For Observable-returning functions:

1. **Subscribe**: When `toObservable()` wraps Observable
2. **Track**: Subscription stored in `ObservableValue.subscription`
3. **Cleanup on re-eval**: Old subscription unsubscribed when function re-executes
4. **Cleanup on dispose**: Subscription unsubscribed when disposer called

```typescript
const position = { componentId: 'root', path: [] };
const result = evaluateNode(observableNode, position);

// Subscription active
const state = result.computed.get();

// Function re-evaluates (dependency changed)
// → Old subscription auto-unsubscribed
// → New subscription created

// When completely done:
result.disposers.forEach(d => d());
// → Current subscription unsubscribed
```

### Common Patterns

**Single evaluation:**
```typescript
const { computed, disposers } = evaluateNode(node, position);
try {
  const state = computed.get();
  // Use state
} finally {
  disposers.forEach(d => d());
}
```

**Long-lived evaluation:**
```typescript
const { computed, disposers } = evaluateNode(node, position);

// Subscribe to changes
const reactionDisposer = autorun(() => {
  const state = computed.get();
  console.log('State changed:', state);
});

// Later, clean up everything
reactionDisposer();
disposers.forEach(d => d());
```

**Batched disposal:**
```typescript
const allDisposers: IReactionDisposer[] = [];

const result1 = evaluateNode(node1, position);
allDisposers.push(...result1.disposers);

const result2 = evaluateNode(node2, position);
allDisposers.push(...result2.disposers);

// Dispose all at once
allDisposers.forEach(d => d());
```

## Testing

Run tests:
```bash
npx nx test eval-tree
```

Run tests with coverage:
```bash
npx nx test eval-tree --coverage
```

Build library:
```bash
npx nx build eval-tree
```

## License

This library is part of the Kaskad monorepo.