@elomarce/reactive-graph-engine
v1.0.0
Published
A reactive graph engine with dependency tracking, computed nodes, middleware, profiling, and persistence
Maintainers
Readme
Reactive Engine — @/lib/graph-engine/
Overview
Custom reactive computation engine untuk low-code IDE (Bandara DEO Sorong).
Engine ini mengelola state management berbasis signal → computed → async → query graph
dengan dependency tracking otomatis, circuit breaker, dan React integration.
Rating Review: ⭐ 4/5 (Februari 2026)
Architecture
┌─────────────────────────────────────────────────────────────┐
│ Engine Core │
├─────────────────────────────────────────────────────────────┤
│ ┌──────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Signal │ │ Computed │ │ Async │ │ Query │ │
│ │ (source) │→ │ (derived│→ │ (effect) │ │ (resource)│ │
│ └──────────┘ └──────────┘ └───────────┘ └───────────┘ │
│ │ │ │ │ │
│ └──────────────┴──────────────┴──────────────┘ │
│ DepGraph │
├─────────────────────────────────────────────────────────────┤
│ MemoryManager │ Profiler │ ErrorRecovery │ CacheManager │
│ PersistenceLayer │ ParallelExecutor │ TestingTools │
└─────────────────────────────────────────────────────────────┘Cara Pakai (React)
1. Define Nodes — nested()
// Client-side (no parser, lightweight):
import { nested } from "@/lib/graph-engine/nestedLite";
const nodeDefinition = nested({
sidebar: {
show: { type: "signal", initial: true },
activeTab: { type: "signal", initial: "pages" },
},
searchQuery: { type: "signal", initial: "" },
filteredData: {
type: "computed",
expr: '{{ val("data").filter(x => x.name.includes(val("searchQuery"))) }}',
},
});
// Flatten result: "sidebar.show", "sidebar.activeTab", "searchQuery", "filteredData"2. Create Engine — useReactiveEngine()
import { useReactiveEngine, ReactiveEngineProvider } from "@/lib/graph-engine/useReactiveEngine";
function Layout({ children }) {
const engine = useReactiveEngine(nodeDefinition, { helpers });
if (!engine) return <Loading />;
return (
<ReactiveEngineProvider engine={engine}>
{children}
</ReactiveEngineProvider>
);
}3. Read/Write — useEngine(), useEngineValue(), engine.set()
import { useEngine, useEngineValue } from "@/lib/graph-engine/useReactiveEngine";
import { batch } from "@/lib/graph-engine/engineScheduler";
function Sidebar() {
const engine = useEngine();
const show = useEngineValue<boolean>(engine, "sidebar.show");
const activeTab = useEngineValue<string>(engine, "sidebar.activeTab");
const handleTabChange = (tab: string) => {
batch(() => {
engine.set("sidebar.show", true);
engine.set("sidebar.activeTab", tab);
});
};
if (!show) return null;
return <div>Active: {activeTab}</div>;
}API Reference
| API | Import | Deskripsi |
|-----|--------|-----------|
| nested() | @/lib/graph-engine/nested | Flatten nested node definitions ke dot-path |
| useReactiveEngine() | @/lib/graph-engine/useReactiveEngine | Hook: create engine + registry + lifecycle |
| ReactiveEngineProvider | @/lib/graph-engine/useReactiveEngine | Context provider |
| useEngine() | @/lib/graph-engine/useReactiveEngine | Hook: get engine dari context (throws jika null) |
| useEngineOptional() | @/lib/graph-engine/useReactiveEngine | Hook: get engine (safe, return null) |
| useEngineValue<T>() | @/lib/graph-engine/useReactiveEngine | Hook: subscribe value via useSyncExternalStore |
| useEngineState() | @/lib/graph-engine/useReactiveEngine | Hook: subscribe state (idle/pending/ready/error) |
| useEngineNode<T>() | @/lib/graph-engine/useReactiveEngine | Hook: subscribe value + state sekaligus |
| engine.val(path) | - | Baca nilai node (synchronous) |
| engine.set(path, value) | - | Update signal value |
| engine.state(path) | - | Baca state node |
| engine.run(path) | - | Trigger run pada async/query node |
| engine.getNode(path) | - | Ambil node instance |
| batch(fn) | @/lib/graph-engine/engineScheduler | Group multiple set() ke satu flush |
⚠️ TIDAK ADA engine.get() — gunakan engine.val(path)
Node Types
Signal — Source of truth
{ type: "signal", initial: 0 }Bisa di-set dari luar via engine.set("path", value).
Computed — Derived value
{ type: "computed", expr: '{{ val("price") * val("quantity") }}' }Otomatis re-evaluate saat dependency berubah. Synchronous.
Async — Async computation
{ type: "async", expr: '{{ await fetch("/api/data") }}', auto: true, initialRun: true }Untuk side-effect atau async operation. Punya state lifecycle (idle → pending → ready/error).
Query — Resource-based data fetching
{
type: "query",
resource: "trigger_action_backend",
params: '{{ ({ id: 123, data: { search: val("searchQuery") } }) }}',
autoGet: true,
initialRun: true
}Delegate ke helper resources[resourceName](params, { signal }). Otomatis re-fetch saat params berubah.
useReactiveEngine vs createEngineAsync — Kapan Pakai Yang Mana
useReactiveEngine() — GUNAKAN INI (untuk hampir semua kasus)
const engine = useReactiveEngine(nodeDefinition, { helpers });Fitur built-in:
- ✅ Engine Registry dengan reference counting
- ✅ React Strict Mode double-mount handling
- ✅ HMR signature-based rebuild
- ✅ Automatic disposal on unmount
- ✅ Synchronous — no loading state untuk engine creation
Cocok untuk: < 500 nodes, atau kapan saja engine creation < 100ms.
createEngineAsync() — HANYA untuk skala besar (1000+ formula nodes)
const { engine, stats } = await createEngineAsync(appJson, helpers, {
onProgress: (phase, current, total) => updateProgressBar(...),
maxWorkers: 4,
chunkSize: 500,
});Fitur tambahan:
- Worker Pool (parallel expression compilation)
- Batch Processing (chunk 500 nodes)
- Pre-compilation + AST caching
- Progress callbacks untuk UI feedback
- Memory optimization
Cocok untuk: 1000+ nodes dengan formula kompleks (spreadsheet-like).
⚠️ TIDAK punya React integration — kamu harus tulis sendiri engine lifecycle di useEffect.
Benchmark Reference
| Skenario | useReactiveEngine | createEngineAsync | |----------|------------------|-------------------| | 50 nodes (editor UI) | ✅ < 5ms | ❌ Overkill | | 200 nodes (dashboard) | ✅ < 20ms | ❌ Overkill | | 500 nodes (complex page) | ✅ < 50ms | 🟡 Opsional | | 1,000+ formula cells | 🟡 ~300ms+ | ✅ 8-10x faster | | 10,000 formula cells | ❌ ~41s freeze | ✅ ~3-5s + progress |
Cost per Node Type
| Node Type | Compile Cost | Keterangan | |-----------|-------------|------------| | Signal | Zero | Tidak ada expression, langsung initial value | | Query | Ringan | Hanya compile params expression | | Async | Medium | Compile code string sebagai function body | | Computed | Mahal | Compile + dependency analysis per expression |
Low-Code IDE: Preview Engine Node Count
Di preview (esbuild-wasm), buildBinding.ts menghasilkan engine definition dari Odoo DB:
Odoo page_components[] → per component:
├── x_studio_state[] → signal nodes + async nodes
└── x_studio_query[] → query nodes
buildBinding() → nested({ ComponentA: {...}, ComponentB: {...} })
→ "ComponentA.stateName", "ComponentA.queryName", ...Estimasi node count per page:
| Kompleksitas Page | Components | Nodes/Component | Total Nodes | Engine Method |
|-------------------|-----------|-----------------|-------------|---------------|
| Simple (form) | 5-10 | 3-5 | 15-50 | useReactiveEngine ✅ |
| Medium (dashboard) | 10-20 | 5-8 | 50-160 | useReactiveEngine ✅ |
| Complex (POS-like) | 20-30 | 5-10 | 100-300 | useReactiveEngine ✅ |
| Heavy (spreadsheet) | 50+ | 10+ | 500-1000+ | Consider createEngineAsync 🟡 |
Catatan: buildBinding.ts saat ini hanya generate signal, async, dan query —
BUKAN computed (yang paling mahal). Jadi bahkan di 500 nodes, mayoritas adalah signal
(zero compile cost) + query (satu compile per params expression).
File Structure
Core Files
| File | Lines | Deskripsi |
|------|-------|-----------|
| reactiveEngine.ts | ~4,300 | Engine class, MemoryManager, Profiler, Persistence, TestingTools |
| engineNodes.ts | ~1,425 | SignalNode, ComputedNode, AsyncNode, QueryNode |
| engineDepGraph.ts | ~1,078 | DepGraph dependency graph (topological sort, cycle detection) |
| engineExpr.ts | ~608 | Expression compiler (compileExpr, sandbox) |
| nested.ts | ~440 | nested() flatten + expression transformer (acorn parser, server-side only) |
| nestedLite.ts | ~95 | Lightweight nested() — flatten only, NO parser (client-side) |
| useReactiveEngine.tsx | ~440 | React hooks, Provider, Engine Registry |
| engineTypes.ts | ~361 | TypeScript type definitions |
| engineScheduler.ts | ~100 | batch(), microtask scheduler |
Enterprise/Optimization Files
| File | Lines | Deskripsi |
|------|-------|-----------|
| engineOptimizedBuilder.ts | ~804 | createEngineAsync, Worker Pool, batch compile |
| engineHMR.ts | ~750 | Hot Module Replacement untuk engine nodes |
| engineEnterpriseCompiler.ts | - | LRU cache + Web Worker expression compiler |
| engineWorker.ts | - | Worker pool management |
| engineRecovery.ts | - | Error recovery, circuit breaker |
Utils
| File | Deskripsi |
|------|-----------|
| utils/engineDynamicOps.ts | Dynamic node add/remove/rename |
| utils/excelModel.ts | Excel-like cell reference helpers |
| utils/batchOperations.ts | Batch node operations |
| utils/astParser.ts | AST-based expression analysis |
| utils/formulaIntelligence.ts | Formula autocomplete/intelligence |
Catatan Penting — Review Findings (Februari 2026)
✅ Kekuatan
- API Design (5/5) —
val(),set(),batch(),nested()sangat ergonomis - React Integration (5/5) —
useSyncExternalStorezero-overhead, no tearing - Feature Completeness (5/5) — Signal/Computed/Async/Query + DevTools + HMR + Profiler
- Error Handling (4/5) — Circuit breaker, graceful cycle handling, node-level isolation
🚀 Optimasi — Parser Split (B + A1)
Masalah: @babel/parser (511KB) di-import oleh nested.ts dan terbawa ke client bundle lewat 6 file node-engine.ts, padahal transformExpression() TIDAK dibutuhkan di client (semua sudah pakai val() eksplisit).
Solusi (2 langkah):
- Opsi B: Ganti
@babel/parser→acorn(ESTree-compatible, 236KB) — net saving 275KB di server - Opsi A1: Buat
nestedLite.ts— versinested()tanpa parser, hanyaflatten()logic (~95 baris)
Hasil:
- Client bundle: -511KB (zero parser, pakai
nestedLite.ts) - Server bundle: -275KB (acorn 236KB vs Babel 511KB, pakai
nested.ts) - 6 client files → import dari
nestedLite - 3 server files tetap pakai
nested.ts:buildBinding.ts(×2),excelModel.ts
| File | Import | Parser |
|------|--------|--------|
| Client node-engine.ts (×6) | nestedLite | ❌ None |
| Server buildBinding.ts (×2) | nested | ✅ acorn |
| Server excelModel.ts | nested | ✅ acorn |
⚠️ Yang Perlu Diperhatikan
1. Fitur yang Belum Diimplementasi
// PersistenceLayer._serialize — compression flag ada tapi TIDAK jalan
private _serialize(state: any) {
const raw = JSON.stringify({ v: 1, ts: now(), state });
if (!this.compression) return raw;
return raw; // ← compression true atau false, hasilnya SAMA
}Status: Flag compression ada, tapi kedua branch return raw. Jangan enable tanpa implementasi.
2. Dead Code — ParallelExecutor
// evaluateNode() return tanpa melakukan apa-apa untuk semua node type
private async evaluateNode(nodeId: string) {
if ((node as any).kind === "computed") return; // no-op
if ((node as any).kind === "async") return; // no-op
if ((node as any).kind === "query") return; // no-op
}Status: ParallelExecutor ada tapi evaluateNode() sudah jadi no-op. Execution sebenarnya dilakukan oleh scheduler. Class ini bisa di-remove atau di-refactor.
3. Fitur Enterprise yang Tidak Dipakai di App
Fitur-fitur berikut ada di engine tapi 0 usage di codebase app:
| Fitur | ~Baris | Dipakai? | Catatan |
|-------|--------|----------|---------|
| MemoryManager (GC, histogram) | ~150 | ❌ | Untuk skenario ribuan node dengan data besar |
| PersistenceLayer (versioning, migration) | ~80 | ❌ | enableAutoSave() tidak pernah dipanggil |
| TestingTools (deterministic, snapshot) | ~65 | ❌ | Untuk unit testing engine sendiri |
| createStateSyncPlugin (BroadcastChannel) | ~70 | ❌ | Sync state antar browser tab |
| createEngineAsync (worker offload) | ~80 | ❌ | Untuk 1000+ formula nodes |
Ini BUKAN bug — fitur ini berguna untuk scale nanti. Tapi menambah ~500+ baris di core file.
4. Rekomendasi Refactor (Non-Urgent)
- Extract
TestingTools,createStateSyncPlugin,createEngineAsynckegraph-engine/extras/ - Fix atau hapus
compressiondead branch diPersistenceLayer - Pertimbangkan remove
ParallelExecutoryang sudah no-op - Split
reactiveEngine.ts(4,300 lines) — pisahkan MemoryManager, Profiler, Persistence ke file terpisah
Rules — JANGAN DILANGGAR
- ❌ JANGAN gunakan
engine.get()— method ini TIDAK ADA. Gunakanengine.val(path) - ❌ JANGAN simpan server data di engine signals — gunakan React Query
- ❌ JANGAN pakai Zustand di editor module — gunakan Reactive Engine untuk UI state
- ❌ JANGAN pakai
useStateuntuk data dari API — gunakan React Query - ❌ JANGAN ubah urutan plugin di
bundler.ts— order MATTERS - ✅ SELALU gunakan
batch()untuk multipleengine.set()calls - ✅ SELALU gunakan
useEngineValue()untuk subscribe — BUKAN polling - ✅ SELALU handle
engine === nullstate (loading) di component - ✅ SELALU import
nestedLite(bukannested) di client-sidenode-engine.ts - ⚠️ Import
nested(dengan acorn parser) HANYA di server-side code yang butuhtransformExpression()
