@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 +889 -0
- package/fesm2022/kaskad-eval-tree.mjs +787 -0
- package/fesm2022/kaskad-eval-tree.mjs.map +1 -0
- package/package.json +29 -0
- package/types/kaskad-eval-tree.d.ts +211 -0
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.
|