@kaskad/eval-tree 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,889 @@
1
+ # @kaskad/eval-tree
2
+
3
+ A reactive formula evaluation engine that transforms AST nodes into MobX-tracked computations with first-class async support.
4
+
5
+ ## Features
6
+
7
+ - **Reactive Evaluation**: MobX-based pull reactive system with automatic dependency tracking
8
+ - **First-Class Async**: Native support for Promises and RxJS Observables
9
+ - **Lazy Evaluation**: Control flow functions only evaluate needed branches
10
+ - **Resource Management**: Explicit disposal pattern for subscription cleanup
11
+ - **Dual Function Model**: Simple imperative functions and advanced reactive functions
12
+ - **State Tracking**: Explicit `evaluating`, `settled`, and `failed` states for UI feedback
13
+
14
+ ## Architecture Overview
15
+
16
+ eval-tree transforms formula AST nodes into reactive MobX computations:
17
+
18
+ ```
19
+ EvalNode (AST) → evaluateNode() → EvaluationResult → EvalState
20
+ ```
21
+
22
+ ### Data Flow
23
+
24
+ 1. **Input**: `EvalNode` - Immutable AST from formula parser
25
+ 2. **Processing**: `evaluateNode()` - Recursive evaluation with MobX computeds
26
+ 3. **Output**: `EvaluationResult` - Contains MobX computed + disposal functions
27
+ 4. **State**: `EvalState` - Tracks value, loading, and error states
28
+
29
+ ### Core Types
30
+
31
+ ```typescript
32
+ // Input: AST nodes
33
+ type EvalNode =
34
+ | ValueEvalNode // { type: 'value', value: unknown }
35
+ | ArrayEvalNode // { type: 'array', items: EvalNode[] }
36
+ | ObjectEvalNode // { type: 'object', properties: [...] }
37
+ | FunctionEvalNode // { type: 'function', name: string, args: EvalNode[] }
38
+
39
+ // Output: Evaluation state
40
+ interface EvalState<T = unknown> {
41
+ value: T; // The computed result
42
+ evaluating: boolean; // Async operation in progress
43
+ settled: boolean; // Evaluation completed (success or failure)
44
+ failed: boolean; // Error occurred during evaluation
45
+ }
46
+
47
+ // Result: Reactive container + cleanup
48
+ interface EvaluationResult<S extends EvalState = EvalState> {
49
+ readonly computed: IComputedValue<S>; // MobX reactive value
50
+ readonly disposers: ReadonlyArray<IReactionDisposer>; // Cleanup functions
51
+ }
52
+ ```
53
+
54
+ ## Quick Start
55
+
56
+ ### Basic Evaluation
57
+
58
+ ```typescript
59
+ import { evaluateNode, EvalState } from '@kaskad/eval-tree';
60
+
61
+ // Simple value node
62
+ const valueNode = { type: 'value', value: 42 };
63
+ const result = evaluateNode(valueNode, {
64
+ componentId: 'root',
65
+ path: []
66
+ });
67
+
68
+ // Read the evaluated state
69
+ const state: EvalState = result.computed.get();
70
+ console.log(state.value); // 42
71
+ console.log(state.settled); // true
72
+ console.log(state.evaluating); // false
73
+
74
+ // Clean up resources when done
75
+ result.disposers.forEach(dispose => dispose());
76
+ ```
77
+
78
+ ### Function Registration
79
+
80
+ ```typescript
81
+ import { FunctionRegistry, FunctionDefinition } from '@kaskad/eval-tree';
82
+
83
+ // Register an imperative function
84
+ const plus: FunctionDefinition = {
85
+ kind: 'imperative',
86
+ execute: (ctx, num1: number, num2: number) => num1 + num2,
87
+ args: [
88
+ { valueType: { type: 'number' } },
89
+ { valueType: { type: 'number' } }
90
+ ],
91
+ returns: { type: 'number' }
92
+ };
93
+
94
+ FunctionRegistry.getInstance().setFunction('plus', plus);
95
+
96
+ // Evaluate a function call
97
+ const functionNode = {
98
+ type: 'function',
99
+ name: 'plus',
100
+ args: [
101
+ { type: 'value', value: 10 },
102
+ { type: 'value', value: 32 }
103
+ ]
104
+ };
105
+
106
+ const position = { componentId: 'root', path: [] };
107
+ const result = evaluateNode(functionNode, position);
108
+ console.log(result.computed.get().value); // 42
109
+ ```
110
+
111
+ ### Async Functions
112
+
113
+ ```typescript
114
+ // Async function returns Promise
115
+ const fetchData: FunctionDefinition = {
116
+ kind: 'imperative',
117
+ execute: async (ctx, url: string) => {
118
+ const response = await fetch(url);
119
+ return response.json();
120
+ },
121
+ args: [{ valueType: { type: 'string' } }],
122
+ returns: { type: 'unknown' }
123
+ };
124
+
125
+ FunctionRegistry.getInstance().setFunction('fetch', fetchData);
126
+
127
+ // Evaluate async function
128
+ const asyncNode = {
129
+ type: 'function',
130
+ name: 'fetch',
131
+ args: [{ type: 'value', value: '/api/data' }]
132
+ };
133
+
134
+ const position = { componentId: 'root', path: [] };
135
+ const result = evaluateNode(asyncNode, position);
136
+
137
+ // Initially evaluating
138
+ console.log(result.computed.get().evaluating); // true
139
+ console.log(result.computed.get().value); // null
140
+
141
+ // After Promise resolves (MobX will auto-update)
142
+ // evaluating: false, value: { ... response data ... }
143
+ ```
144
+
145
+ ## Core Concepts
146
+
147
+ ### EvalNode Types
148
+
149
+ **Value Node** - Wraps a literal value:
150
+ ```typescript
151
+ { type: 'value', value: 42 }
152
+ { type: 'value', value: 'hello' }
153
+ { type: 'value', value: null }
154
+ ```
155
+
156
+ **Array Node** - Evaluates to an array:
157
+ ```typescript
158
+ {
159
+ type: 'array',
160
+ items: [
161
+ { type: 'value', value: 1 },
162
+ { type: 'value', value: 2 },
163
+ { type: 'function', name: 'multiply', args: [...] }
164
+ ]
165
+ }
166
+ ```
167
+
168
+ **Object Node** - Evaluates to an object:
169
+ ```typescript
170
+ {
171
+ type: 'object',
172
+ properties: [
173
+ {
174
+ key: { type: 'value', value: 'name' },
175
+ value: { type: 'value', value: 'Alice' }
176
+ },
177
+ {
178
+ key: { type: 'function', name: 'getDynamicKey', args: [] },
179
+ value: { type: 'value', value: 'dynamic value' }
180
+ }
181
+ ]
182
+ }
183
+ ```
184
+
185
+ **Function Node** - Calls a registered function:
186
+ ```typescript
187
+ {
188
+ type: 'function',
189
+ name: 'plus',
190
+ args: [
191
+ { type: 'value', value: 10 },
192
+ { type: 'value', value: 32 }
193
+ ]
194
+ }
195
+ ```
196
+
197
+ ### EvalState Fields
198
+
199
+ The evaluation state tracks three aspects:
200
+
201
+ **Value** - The computed result:
202
+ ```typescript
203
+ state.value // The actual computed value (any type)
204
+ ```
205
+
206
+ **Loading** - Async operation status:
207
+ ```typescript
208
+ state.evaluating // true if Promise pending or Observable emitting
209
+ state.settled // true when evaluation completed (success or failure)
210
+ ```
211
+
212
+ **Error** - Failure tracking:
213
+ ```typescript
214
+ state.failed // true if evaluation failed or async operation errored
215
+ ```
216
+
217
+ **State Transitions:**
218
+ ```
219
+ Initial: { value: null, evaluating: false, settled: false, failed: false }
220
+ Sync eval: { value: 42, evaluating: false, settled: true, failed: false }
221
+ Async start:{ value: null, evaluating: true, settled: false, failed: false }
222
+ Async done: { value: data, evaluating: false, settled: true, failed: false }
223
+ Error: { value: null, evaluating: false, settled: true, failed: true }
224
+ ```
225
+
226
+ ### Reactive Evaluation
227
+
228
+ eval-tree uses MobX's pull-based reactive system:
229
+
230
+ 1. **All results wrapped in `computed()`** - Automatic memoization
231
+ 2. **Lazy evaluation** - Only computed when `.get()` is called
232
+ 3. **Automatic updates** - MobX tracks dependencies and recomputes when they change
233
+ 4. **Glitch-free** - Updates happen in transactions, no intermediate states
234
+
235
+ ```typescript
236
+ const position = { componentId: 'root', path: [] };
237
+ const result = evaluateNode(node, position);
238
+
239
+ // Subscribe to changes
240
+ autorun(() => {
241
+ const state = result.computed.get();
242
+ console.log('Value changed:', state.value);
243
+ });
244
+
245
+ // When dependencies change, autorun re-executes automatically
246
+ ```
247
+
248
+ ## Function System
249
+
250
+ eval-tree supports two function models: **imperative** (simple) and **reactive** (advanced).
251
+
252
+ ### Imperative Functions
253
+
254
+ Imperative functions are the simplest way to add custom functionality. All arguments are evaluated before execution.
255
+
256
+ ```typescript
257
+ const multiply: FunctionDefinition = {
258
+ kind: 'imperative',
259
+ execute: (ctx, a: number, b: number) => a * b,
260
+ args: [
261
+ { valueType: { type: 'number' } },
262
+ { valueType: { type: 'number' } }
263
+ ],
264
+ returns: { type: 'number' }
265
+ };
266
+
267
+ FunctionRegistry.getInstance().setFunction('multiply', multiply);
268
+ ```
269
+
270
+ **Features:**
271
+ - ✅ Simple API - just write a regular function
272
+ - ✅ Arguments auto-unwrapped from MobX computeds
273
+ - ✅ Can return Promise or Observable for async
274
+ - ❌ Cannot implement lazy evaluation (all args evaluated)
275
+ - ❌ Cannot implement short-circuit logic
276
+
277
+ **When to use:**
278
+ - Math operations: `plus`, `multiply`, `divide`
279
+ - String operations: `concat`, `substring`, `toUpperCase`
280
+ - Data transformations: `map`, `filter`, `reduce`
281
+ - Async operations: `fetch`, `delay`, `timeout`
282
+
283
+ ### Reactive Functions
284
+
285
+ Reactive functions receive unevaluated MobX computeds, enabling lazy evaluation and short-circuit logic.
286
+
287
+ ```typescript
288
+ import { computed, IComputedValue } from 'mobx';
289
+ import {
290
+ ReactiveFunctionDefinition,
291
+ FunctionExecutionStateBuilder,
292
+ EvalState
293
+ } from '@kaskad/eval-tree';
294
+
295
+ const ifFn: ReactiveFunctionDefinition = {
296
+ kind: 'reactive',
297
+ execute: (ctx, args: IComputedValue<EvalState>[]) => {
298
+ return computed(() => {
299
+ const state = new FunctionExecutionStateBuilder();
300
+
301
+ // Evaluate condition
302
+ const conditionState = args[0].get();
303
+ const earlyReturn = state.checkReady(conditionState);
304
+ if (earlyReturn) return earlyReturn;
305
+
306
+ // Lazy: only evaluate the selected branch
307
+ const branchState = conditionState.value
308
+ ? args[1].get() // true branch
309
+ : args[2].get(); // false branch
310
+
311
+ const branchEarlyReturn = state.checkReady(branchState);
312
+ if (branchEarlyReturn) return branchEarlyReturn;
313
+
314
+ return state.success(branchState.value);
315
+ });
316
+ },
317
+ args: { type: 'fixed', count: 3, valueType: { type: 'unknown' } },
318
+ returns: { type: 'unknown' }
319
+ };
320
+ ```
321
+
322
+ **Features:**
323
+ - ✅ Full control over argument evaluation
324
+ - ✅ Can implement lazy evaluation (only evaluate needed args)
325
+ - ✅ Can implement short-circuit logic (`and`, `or`)
326
+ - ✅ Direct access to argument states (evaluating, failed)
327
+ - ❌ More complex API (must handle states manually)
328
+
329
+ **When to use:**
330
+ - Control flow: `if`, `switch`, `case`
331
+ - Short-circuit logic: `and`, `or`, `coalesce`
332
+ - Conditional evaluation: `try`, `catch`, `default`
333
+ - Advanced state handling: `retry`, `debounce`, `throttle`
334
+
335
+ ### FunctionExecutionStateBuilder
336
+
337
+ Helper class for building execution states in reactive functions:
338
+
339
+ ```typescript
340
+ const state = new FunctionExecutionStateBuilder();
341
+
342
+ // Track argument states
343
+ state.trackArg(argState); // Accumulates evaluating/failed flags
344
+
345
+ // Check if argument is ready
346
+ const earlyReturn = state.checkReady(argState);
347
+ if (earlyReturn) return earlyReturn; // Returns notReady() if not settled
348
+
349
+ // Return success
350
+ return state.success(computedValue);
351
+
352
+ // Or return not ready
353
+ return state.notReady(); // Returns partial state with flags
354
+ ```
355
+
356
+ ### FunctionRegistry API
357
+
358
+ ```typescript
359
+ const registry = FunctionRegistry.getInstance();
360
+
361
+ // Register single function
362
+ registry.setFunction('myFn', functionDefinition);
363
+
364
+ // Register multiple functions
365
+ registry.setFunctions({
366
+ plus: plusDefinition,
367
+ minus: minusDefinition,
368
+ multiply: multiplyDefinition
369
+ });
370
+
371
+ // Get function (throws if not found)
372
+ const fn = registry.getFunction('plus');
373
+
374
+ // Check if function exists
375
+ if (registry.hasFunction('myFn')) {
376
+ // ...
377
+ }
378
+
379
+ // Get all function names
380
+ const names = registry.getFunctionNames(); // ['plus', 'minus', 'multiply']
381
+
382
+ // Reset registry (useful for testing)
383
+ FunctionRegistry.reset();
384
+ ```
385
+
386
+ ## Async Handling
387
+
388
+ eval-tree has first-class support for async values (Promises and RxJS Observables).
389
+
390
+ ### Promise Support
391
+
392
+ When a function returns a Promise, it's automatically wrapped in a `PromiseValue` object tracked by MobX:
393
+
394
+ ```typescript
395
+ const asyncFn: FunctionDefinition = {
396
+ kind: 'imperative',
397
+ execute: async (ctx, url: string) => {
398
+ const response = await fetch(url);
399
+ return response.json();
400
+ },
401
+ args: [{ valueType: { type: 'string' } }],
402
+ returns: { type: 'unknown' }
403
+ };
404
+
405
+ // State transitions:
406
+ // 1. Initial: { value: null, evaluating: true, settled: false, failed: false }
407
+ // 2. Resolved: { value: data, evaluating: false, settled: true, failed: false }
408
+ // 3. Rejected: { value: null, evaluating: false, settled: true, failed: true }
409
+ ```
410
+
411
+ **PromiseValue Structure:**
412
+ ```typescript
413
+ interface PromiseValue {
414
+ kind: 'promise';
415
+ current: unknown; // Resolved value (null if pending/rejected)
416
+ error: unknown; // Rejection error (null if pending/resolved)
417
+ pending: boolean; // true until Promise settles
418
+ }
419
+ ```
420
+
421
+ ### Observable Support
422
+
423
+ RxJS Observables are wrapped in `ObservableValue` with automatic subscription management:
424
+
425
+ ```typescript
426
+ import { interval } from 'rxjs';
427
+ import { map } from 'rxjs/operators';
428
+
429
+ const streamFn: FunctionDefinition = {
430
+ kind: 'imperative',
431
+ execute: (ctx) => {
432
+ return interval(1000).pipe(
433
+ map(n => n * 2)
434
+ );
435
+ },
436
+ args: [],
437
+ returns: { type: 'number' }
438
+ };
439
+
440
+ // State transitions:
441
+ // 1. Initial: { value: null, evaluating: true, settled: false, failed: false }
442
+ // 2. First emit: { value: 0, evaluating: false, settled: true, failed: false }
443
+ // 3. Next emit: { value: 2, evaluating: false, settled: true, failed: false }
444
+ // 4. Error: { value: 2, evaluating: false, settled: true, failed: true }
445
+ // (note: current value preserved on error)
446
+ ```
447
+
448
+ **ObservableValue Structure:**
449
+ ```typescript
450
+ interface ObservableValue {
451
+ kind: 'observable';
452
+ current: unknown; // Latest emitted value
453
+ error: unknown; // Error if stream errored
454
+ pending: boolean; // false after first emission or completion
455
+ subscription: Subscription; // RxJS subscription (for cleanup)
456
+ }
457
+ ```
458
+
459
+ ### Subscription Cleanup
460
+
461
+ eval-tree automatically manages RxJS subscriptions:
462
+
463
+ 1. **Creation**: Subscription created when Observable is wrapped
464
+ 2. **Re-evaluation**: Old subscription unsubscribed when function re-evaluates
465
+ 3. **Disposal**: Subscription unsubscribed when disposer called
466
+
467
+ ```typescript
468
+ const position = { componentId: 'root', path: [] };
469
+ const result = evaluateNode(observableNode, position);
470
+
471
+ // Subscription active, receiving values
472
+
473
+ // When done, clean up
474
+ result.disposers.forEach(dispose => dispose());
475
+ // Subscription automatically unsubscribed
476
+ ```
477
+
478
+ ### Type Guards
479
+
480
+ Check async value types:
481
+
482
+ ```typescript
483
+ import {
484
+ isAsyncValue,
485
+ isPromiseValue,
486
+ isObservableValue
487
+ } from '@kaskad/eval-tree';
488
+
489
+ if (isPromiseValue(value)) {
490
+ console.log('Promise:', value.current, value.pending);
491
+ }
492
+
493
+ if (isObservableValue(value)) {
494
+ console.log('Observable:', value.current);
495
+ value.subscription.unsubscribe(); // Manual cleanup if needed
496
+ }
497
+ ```
498
+
499
+ ## API Reference
500
+
501
+ ### evaluateNode()
502
+
503
+ Main entry point for evaluating AST nodes.
504
+
505
+ ```typescript
506
+ function evaluateNode<S extends EvalState = EvalState>(
507
+ evalNode: EvalNode,
508
+ position: NodePosition
509
+ ): EvaluationResult<S>
510
+ ```
511
+
512
+ **Parameters:**
513
+ - `evalNode` - AST node to evaluate (value, array, object, or function)
514
+ - `position` - Node position context (component ID and path)
515
+
516
+ **Returns:**
517
+ - `EvaluationResult` - Contains MobX computed and disposal functions
518
+
519
+ **Example:**
520
+ ```typescript
521
+ const result = evaluateNode(
522
+ { type: 'value', value: 42 },
523
+ { componentId: 'root', path: ['field'] }
524
+ );
525
+
526
+ const state = result.computed.get();
527
+ console.log(state.value); // 42
528
+
529
+ result.disposers.forEach(d => d()); // Cleanup
530
+ ```
531
+
532
+ ### FunctionRegistry
533
+
534
+ Singleton for managing formula functions.
535
+
536
+ ```typescript
537
+ class FunctionRegistry {
538
+ static getInstance(): FunctionRegistry
539
+ static reset(): FunctionRegistry
540
+
541
+ setFunction(name: string, definition: FunctionDefinition | ReactiveFunctionDefinition): void
542
+ setFunctions(definitions: Record<string, FunctionDefinition | ReactiveFunctionDefinition>): void
543
+ getFunction(name: string): ReactiveFunctionDefinition
544
+ hasFunction(name: string): boolean
545
+ getFunctionNames(): string[]
546
+ }
547
+ ```
548
+
549
+ ### ensureReactive()
550
+
551
+ Converts imperative functions to reactive (or returns reactive unchanged).
552
+
553
+ ```typescript
554
+ function ensureReactive(
555
+ definition: FunctionDefinition | ReactiveFunctionDefinition
556
+ ): ReactiveFunctionDefinition
557
+ ```
558
+
559
+ Automatically called by `FunctionRegistry.setFunction()`.
560
+
561
+ ### wrapImperativeAsReactive()
562
+
563
+ Wraps an imperative function for the reactive pipeline.
564
+
565
+ ```typescript
566
+ function wrapImperativeAsReactive(
567
+ imperativeDef: FunctionDefinition
568
+ ): ReactiveFunctionDefinition
569
+ ```
570
+
571
+ Handles argument evaluation, error catching, and async value wrapping.
572
+
573
+ ### FunctionExecutionStateBuilder
574
+
575
+ Helper for building execution states in reactive functions.
576
+
577
+ ```typescript
578
+ class FunctionExecutionStateBuilder {
579
+ trackArg(argState: EvalState): void
580
+ checkReady(argState: EvalState): FunctionExecutionState | null
581
+ notReady(): FunctionExecutionState
582
+ success(value: unknown): FunctionExecutionState
583
+ }
584
+ ```
585
+
586
+ **Usage:**
587
+ ```typescript
588
+ const state = new FunctionExecutionStateBuilder();
589
+
590
+ for (const argComputed of args) {
591
+ const argState = argComputed.get();
592
+ const earlyReturn = state.checkReady(argState);
593
+ if (earlyReturn) return earlyReturn;
594
+
595
+ // Use argState.value here
596
+ }
597
+
598
+ return state.success(result);
599
+ ```
600
+
601
+ ## Design Decisions
602
+
603
+ ### Pull-based Reactivity (MobX) vs Push-based (RxJS)
604
+
605
+ **Choice:** MobX `computed()` for pull-based reactivity
606
+
607
+ **Rationale:**
608
+ - **Lazy evaluation**: Computations only run when values are read
609
+ - **Natural backpressure**: Don't read → don't compute
610
+ - **Glitch-free updates**: MobX transactions prevent intermediate states
611
+ - **Better debugging**: Call stack intact (not async callbacks)
612
+ - **Lower learning curve**: Simpler mental model than reactive streams
613
+
614
+ **Trade-off:** Push-based would enable reactive composition patterns, but pull-based is better suited for formula evaluation where values are queried on-demand.
615
+
616
+ ### Manual Disposal vs Automatic GC
617
+
618
+ **Choice:** Explicit disposal pattern with returned disposers array
619
+
620
+ **Rationale:**
621
+ - **Deterministic cleanup**: Know exactly when subscriptions unsubscribe
622
+ - **Resource control**: Critical for RxJS subscriptions (can't rely on GC)
623
+ - **No GC pressure**: Cleanup happens immediately, not at GC time
624
+ - **Explicit lifecycle**: Caller controls when to dispose (flexible)
625
+
626
+ **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.
627
+
628
+ ### Dual Function Model (Imperative + Reactive)
629
+
630
+ **Choice:** Two function types with automatic adapter
631
+
632
+ **Rationale:**
633
+ - **Progressive complexity**: Simple functions use imperative, advanced use reactive
634
+ - **Optimal for common case**: 80% of functions don't need lazy evaluation
635
+ - **Single internal model**: All functions become reactive internally
636
+ - **Type safety**: Discriminated union prevents mixing
637
+
638
+ **Trade-off:** Two APIs to learn, but the imperative API is much simpler and auto-upgraded.
639
+
640
+ ### Explicit State vs Monadic Error Handling
641
+
642
+ **Choice:** Explicit `{ value, evaluating, failed }` state object
643
+
644
+ **Rationale:**
645
+ - **UI-friendly**: Can show loading spinners (`evaluating` flag)
646
+ - **Partial state**: Can inspect value even if evaluation failed
647
+ - **Multi-state tracking**: Need to distinguish pending/loading/error/success
648
+ - **No type ceremony**: Simpler than Result/Either monads for UI code
649
+
650
+ **Trade-off:** No compile-time guarantee you check `failed` before using `value`, but more practical for UI-driven evaluation.
651
+
652
+ ## Performance Optimization Guide
653
+
654
+ ### MobX Memoization
655
+
656
+ **Automatic Caching:**
657
+ All evaluation results are wrapped in `computed()`, providing automatic memoization:
658
+
659
+ ```typescript
660
+ const position = { componentId: 'root', path: [] };
661
+ const result = evaluateNode(node, position);
662
+
663
+ // First call: evaluates and caches
664
+ const state1 = result.computed.get();
665
+
666
+ // Second call: returns cached value (if deps unchanged)
667
+ const state2 = result.computed.get();
668
+ ```
669
+
670
+ **Best Practice:** Share `IComputedValue` instances across multiple readers to benefit from memoization.
671
+
672
+ ### Avoiding Unnecessary Dependency Tracking
673
+
674
+ **Problem:** Reading all children creates dependencies even if not needed:
675
+
676
+ ```typescript
677
+ // BAD: Reads all children even if first is evaluating
678
+ const evaluating = items.some(item => item.get().evaluating);
679
+ ```
680
+
681
+ **Solution:** Early exit to avoid tracking unused dependencies:
682
+
683
+ ```typescript
684
+ // GOOD: Only reads children until finding evaluating
685
+ let evaluating = false;
686
+ for (const item of items) {
687
+ const state = item.get();
688
+ if (state.evaluating) {
689
+ evaluating = true;
690
+ break; // Don't track remaining children
691
+ }
692
+ }
693
+ ```
694
+
695
+ **Impact:** Reduces recomputation frequency by avoiding dependencies on irrelevant children.
696
+
697
+ ### Function Execution Overhead
698
+
699
+ **Cost:** Each function call has two levels of `computed()` wrapping:
700
+ 1. Function execution computed (from reactive function)
701
+ 2. Subscription tracking computed (for cleanup)
702
+
703
+ **Optimization:** For non-Observable functions, the double wrapping is overhead. Imperative functions are more efficient if you don't need lazy evaluation.
704
+
705
+ **Best Practice:**
706
+ - Use **imperative** for: math, string ops, data transformations
707
+ - Use **reactive** only when you need: lazy eval, short-circuit, custom state handling
708
+
709
+ ### State Aggregation Patterns
710
+
711
+ **Efficient aggregation:**
712
+
713
+ ```typescript
714
+ // Aggregate states with early exit
715
+ let evaluating = false;
716
+ let failed = false;
717
+
718
+ for (const computed of children) {
719
+ const state = computed.get();
720
+
721
+ if (state.evaluating) evaluating = true;
722
+ if (state.failed) failed = true;
723
+
724
+ // Could early-exit here if both flags set
725
+ if (evaluating && failed) break;
726
+ }
727
+ ```
728
+
729
+ **Debug names:** Set meaningful names for computeds to help MobX devtools:
730
+
731
+ ```typescript
732
+ computed(() => { /* ... */ }, {
733
+ name: 'myComponent.field.formula'
734
+ })
735
+ ```
736
+
737
+ ### Memory Management
738
+
739
+ **Dispose when done:**
740
+
741
+ ```typescript
742
+ const { computed, disposers } = evaluateNode(node, position);
743
+
744
+ // Use the computed
745
+
746
+ // CRITICAL: Always dispose to prevent memory leaks
747
+ disposers.forEach(dispose => dispose());
748
+ ```
749
+
750
+ **Best Practice:** Store disposers in a container and batch-dispose:
751
+
752
+ ```typescript
753
+ class EvaluationContext {
754
+ private disposers: IReactionDisposer[] = [];
755
+
756
+ evaluate(node: EvalNode, position: NodePosition): IComputedValue<EvalState> {
757
+ const { computed, disposers } = evaluateNode(node, position);
758
+ this.disposers.push(...disposers);
759
+ return computed;
760
+ }
761
+
762
+ dispose(): void {
763
+ this.disposers.forEach(d => d());
764
+ this.disposers = [];
765
+ }
766
+ }
767
+ ```
768
+
769
+ ## Resource Management
770
+
771
+ ### Disposal Pattern
772
+
773
+ Every evaluation returns disposers that MUST be called to prevent resource leaks:
774
+
775
+ ```typescript
776
+ const { computed, disposers } = evaluateNode(node, position);
777
+
778
+ // When done with the evaluation:
779
+ disposers.forEach(dispose => dispose());
780
+ ```
781
+
782
+ **What disposers clean up:**
783
+ 1. **RxJS subscriptions** - From Observable-returning functions
784
+ 2. **Child disposers** - Propagated from nested evaluations
785
+ 3. **MobX reactions** - If reactive functions create reactions (rare)
786
+
787
+ ### Disposal Tree
788
+
789
+ Disposers follow the evaluation tree structure:
790
+
791
+ ```
792
+ evaluateObject()
793
+ ├─ evaluateNode(key1) → [disposer1, disposer2]
794
+ ├─ evaluateNode(value1) → [disposer3]
795
+ ├─ evaluateNode(key2) → []
796
+ └─ evaluateNode(value2) → [disposer4, disposer5]
797
+
798
+ Result disposers: [disposer1, disposer2, disposer3, disposer4, disposer5]
799
+ ```
800
+
801
+ **Pattern:** Parent collects all child disposers and returns them as a flat array.
802
+
803
+ ### Subscription Lifecycle
804
+
805
+ For Observable-returning functions:
806
+
807
+ 1. **Subscribe**: When `toObservable()` wraps Observable
808
+ 2. **Track**: Subscription stored in `ObservableValue.subscription`
809
+ 3. **Cleanup on re-eval**: Old subscription unsubscribed when function re-executes
810
+ 4. **Cleanup on dispose**: Subscription unsubscribed when disposer called
811
+
812
+ ```typescript
813
+ const position = { componentId: 'root', path: [] };
814
+ const result = evaluateNode(observableNode, position);
815
+
816
+ // Subscription active
817
+ const state = result.computed.get();
818
+
819
+ // Function re-evaluates (dependency changed)
820
+ // → Old subscription auto-unsubscribed
821
+ // → New subscription created
822
+
823
+ // When completely done:
824
+ result.disposers.forEach(d => d());
825
+ // → Current subscription unsubscribed
826
+ ```
827
+
828
+ ### Common Patterns
829
+
830
+ **Single evaluation:**
831
+ ```typescript
832
+ const { computed, disposers } = evaluateNode(node, position);
833
+ try {
834
+ const state = computed.get();
835
+ // Use state
836
+ } finally {
837
+ disposers.forEach(d => d());
838
+ }
839
+ ```
840
+
841
+ **Long-lived evaluation:**
842
+ ```typescript
843
+ const { computed, disposers } = evaluateNode(node, position);
844
+
845
+ // Subscribe to changes
846
+ const reactionDisposer = autorun(() => {
847
+ const state = computed.get();
848
+ console.log('State changed:', state);
849
+ });
850
+
851
+ // Later, clean up everything
852
+ reactionDisposer();
853
+ disposers.forEach(d => d());
854
+ ```
855
+
856
+ **Batched disposal:**
857
+ ```typescript
858
+ const allDisposers: IReactionDisposer[] = [];
859
+
860
+ const result1 = evaluateNode(node1, position);
861
+ allDisposers.push(...result1.disposers);
862
+
863
+ const result2 = evaluateNode(node2, position);
864
+ allDisposers.push(...result2.disposers);
865
+
866
+ // Dispose all at once
867
+ allDisposers.forEach(d => d());
868
+ ```
869
+
870
+ ## Testing
871
+
872
+ Run tests:
873
+ ```bash
874
+ npx nx test eval-tree
875
+ ```
876
+
877
+ Run tests with coverage:
878
+ ```bash
879
+ npx nx test eval-tree --coverage
880
+ ```
881
+
882
+ Build library:
883
+ ```bash
884
+ npx nx build eval-tree
885
+ ```
886
+
887
+ ## License
888
+
889
+ This library is part of the Kaskad monorepo.