This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the current system transform under ECS, and how the structural problems are resolved. For the full design rationale, see ADR 0008.
The source of truth for runtime entity state in one workflow instance is the set of dedicated Pinia stores. Each store is keyed by per-store string IDs. Components are plain data objects. Systems are functions that query the relevant store(s).
graph TD
subgraph Stores["Dedicated Stores (source of truth)"]
direction TB
WidgetValueStore["widgetValueStore
Map<WidgetId, WidgetValue>"]
DomWidgetStore["domWidgetStore
Map<WidgetId, DomWidgetState>"]
LayoutStore["layoutStore (Y.js CRDT)
nodeId / linkId / rerouteId → layout"]
NodeOutputStore["nodeOutputStore
Map<nodeLocatorId, outputs>"]
SubgraphNavStore["subgraphNavigationStore
active subgraph path"]
PreviewExposureStore["previewExposureStore
preview exposure state"]
end
subgraph Systems["Systems (Behavior)"]
direction TB
RS["RenderSystem"]
SS["SerializationSystem"]
CS["ConnectivitySystem"]
LS["LayoutSystem"]
ES["ExecutionSystem"]
VS["VersionSystem"]
end
RS -->|reads| Stores
SS -->|reads/writes| Stores
CS -->|reads/writes| LayoutStore
LS -->|reads/writes| LayoutStore
ES -->|reads| NodeOutputStore
VS -->|reads/writes| LayoutStore
style Stores fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
Each store addresses entities by its own string-key convention.
graph LR
subgraph "Per-store string keys"
WID["WidgetId
graphId:nodeId:name
(branded string, src/types/widgetId.ts)"]
NLID["nodeLocatorId
subgraphId:nodeId"]
NID["nodeId (raw)"]
LID["linkId (raw)"]
RID["rerouteId (raw)"]
end
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
NLID -->|nodeOutputStore| W
NID -->|layoutStore| W
LID -->|layoutStore| W
RID -->|layoutStore| W
WidgetId = graphId:nodeId:name is itself a branded string (see
src/types/widgetId.ts). nodeLocatorId = subgraphId:nodeId addresses node
outputs. layoutStore keys layout records by raw nodeId / linkId /
rerouteId. Each store enforces its own key shape; there is no single shared
entity-ID type across stores.
Note: graphId is a scope identifier. It identifies which graph an entity
belongs to and forms the prefix of WidgetId. Subgraphs are nodes with a
SubgraphStructure component — see Subgraph Boundaries.
Linked subgraph definitions can be shared structurally, but mutable values are instance-scoped.
- Shared definition-level data (interface shape, default metadata) can be reused across instances.
- Runtime state (
WidgetValue, execution/transient state, selection) is scoped to the containinggraphIdchain inside one workflow instance. - "Single source of truth" therefore means one source per workflow instance across the dedicated stores, with no global source shared across all linked instances.
Recursive containment is represented through graph scopes rather than
Subgraph extends LGraph inheritance.
- A subgraph node points to a child graph via
SubgraphStructure.childGraphId. - The scope registry stores
childGraphId -> parentGraphIdlinks. - Depth queries traverse this scope DAG, then filter entities by
graphScope.
graph LR
subgraph Before["LGraphNode (monolith)"]
direction TB
B1["pos, size, bounding"]
B2["color, bgcolor, title"]
B3["type, category, nodeData"]
B4["inputs[], outputs[]"]
B5["order, mode, flags"]
B6["properties, properties_info"]
B7["widgets[]"]
B8["serialize(), configure()"]
B9["drawSlots(), drawWidgets()"]
B10["execute(), triggerSlot()"]
B11["graph._version++"]
B12["connect(), disconnect()"]
end
subgraph After["nodeId-keyed components (across stores)"]
direction TB
A1["Position
{ pos, size, bounding }"]
A2["NodeVisual
{ color, bgcolor, boxcolor, title }"]
A3["NodeType
{ type, category, nodeData }"]
A4["Connectivity
{ inputSlotIds[], outputSlotIds[] }"]
A5["Execution
{ order, mode, flags }"]
A6["Properties
{ properties, propertiesInfo }"]
A7["WidgetContainer
{ widgetIds[] }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.-> A4
B5 -.-> A5
B6 -.-> A6
B7 -.-> A7
B8 -.->|"moves to"| SYS1["SerializationSystem"]
B9 -.->|"moves to"| SYS2["RenderSystem"]
B10 -.->|"moves to"| SYS3["ExecutionSystem"]
B11 -.->|"moves to"| SYS4["VersionSystem"]
B12 -.->|"moves to"| SYS5["ConnectivitySystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
graph LR
subgraph Before["LLink (class)"]
direction TB
B1["origin_id, origin_slot
target_id, target_slot, type"]
B2["color, path, _pos"]
B3["_dragging, data"]
B4["disconnect()"]
B5["resolve()"]
end
subgraph After["linkId-keyed components (layoutStore)"]
direction TB
A1["LinkEndpoints
{ originId, originSlot,
targetId, targetSlot, type }"]
A2["LinkVisual
{ color, path, centerPos }"]
A3["LinkState
{ dragging, data }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["ConnectivitySystem"]
B5 -.->|"moves to"| SYS2["ConnectivitySystem"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
graph LR
subgraph Before["BaseWidget (class)"]
direction TB
B1["name, type, _node"]
B2["value, options, serialize"]
B3["computedHeight, margin"]
B4["drawWidget(), onClick()"]
B5["useWidgetValueStore()"]
end
subgraph After["WidgetId + components"]
direction TB
A1["WidgetIdentity
{ name, widgetType, parentNodeId }"]
A2["WidgetValue
{ value, options, serialize }"]
A3["WidgetLayout
{ computedHeight, constraints }"]
end
B1 -.-> A1
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["RenderSystem"]
B5 -.->|"absorbed by"| SYS2["widgetValueStore"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Each system owns exactly one concern.
graph TD
subgraph InputPhase["Input Phase"]
UserInput["User Input
(pointer, keyboard)"]
APIInput["API Input
(backend execution results)"]
end
subgraph UpdatePhase["Update Phase (ordered)"]
direction TB
CS["ConnectivitySystem
Manages link/slot mutations.
Writes: LinkEndpoints, SlotConnection,
Connectivity"]
VS["VersionSystem
Centralizes change tracking.
Replaces 15+ scattered _version++.
Writes: version counter"]
LS["LayoutSystem
Computes positions and sizes.
Runs BEFORE render, not during.
Reads: Connectivity, WidgetContainer
Writes: Position, SlotVisual, WidgetLayout"]
ES["ExecutionSystem
Determines run order.
Reads: Connectivity, Execution
Writes: Execution.order"]
end
subgraph RenderPhase["Render Phase (read-only)"]
RS["RenderSystem
Pure read of components.
No state mutation.
Reads: Position, *Visual, *Layout"]
end
subgraph PersistPhase["Persist Phase"]
SS["SerializationSystem
Reads/writes all components.
Handles workflow JSON."]
end
UserInput --> CS
APIInput --> ES
CS --> VS
VS --> LS
LS --> RS
CS --> SS
style InputPhase fill:#2a2a4a,stroke:#3a3a5a,color:#e0e0e0
style UpdatePhase fill:#1a3a2a,stroke:#2a4a3a,color:#e0e0e0
style RenderPhase fill:#3a2a1a,stroke:#4a3a2a,color:#e0e0e0
style PersistPhase fill:#2a2a3a,stroke:#3a3a4a,color:#e0e0e0
graph LR
subgraph Systems
RS["Render"]
SS["Serialization"]
CS["Connectivity"]
LS["Layout"]
ES["Execution"]
VS["Version"]
end
subgraph Components
Pos["Position"]
NV["NodeVisual"]
NT["NodeType"]
Con["Connectivity"]
Exe["Execution"]
Props["Properties"]
WC["WidgetContainer"]
LE["LinkEndpoints"]
LV["LinkVisual"]
SC["SlotConnection"]
SV["SlotVisual"]
WVal["WidgetValue"]
WL["WidgetLayout"]
end
RS -.->|read| Pos
RS -.->|read| NV
RS -.->|read| LV
RS -.->|read| SV
RS -.->|read| WL
LS -->|write| Pos
LS -->|write| SV
LS -->|write| WL
LS -.->|read| Con
LS -.->|read| WC
CS -->|write| LE
CS -->|write| SC
CS -->|write| Con
ES -.->|read| Con
ES -->|write| Exe
SS -.->|read/write| Pos
SS -.->|read/write| NT
SS -.->|read/write| Props
SS -.->|read/write| WVal
SS -.->|read/write| LE
VS -.->|read| Pos
VS -.->|read| Con
graph TD
Node["LGraphNode"] <-->|"circular"| Graph["LGraph"]
Graph <-->|"circular"| Subgraph["Subgraph"]
Node -->|"this.graph._links"| Links["LLink Map"]
Node -->|"this.graph.getNodeById"| Node
Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
Canvas -->|"node.graph.remove(node)"| Graph
Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
Node -->|"useLayoutMutations()"| Store3["Layout Store"]
Graph -->|"useLayoutMutations()"| Store3
LLink["LLink"] -->|"useLayoutMutations()"| Store3
style Node fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Graph fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Canvas fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Widget fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
graph TD
subgraph Systems["Systems"]
RS["RenderSystem"]
CS["ConnectivitySystem"]
LS["LayoutSystem"]
ES["ExecutionSystem"]
SS["SerializationSystem"]
VS["VersionSystem"]
end
subgraph Stores["Dedicated Stores (instance-scoped source of truth)"]
LayoutStore["layoutStore"]
WidgetValueStore["widgetValueStore"]
DomWidgetStore["domWidgetStore"]
NodeOutputStore["nodeOutputStore"]
end
Systems -->|"query/mutate"| Stores
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
style Stores fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
Key differences:
- No circular dependencies: entities are IDs, not class instances
- No Demeter violations: systems query stores directly, never reach through entities
- Data lives in dedicated stores: systems are the only writers
- Unidirectional: Input → Systems → Stores → Render (no back-edges)
- Instance safety: linked definitions can be reused without forcing shared mutable widget/execution state across instances
How each problem from entity-problems.md is resolved:
graph LR
subgraph Problems["Current Problems"]
P1["God Objects
(9k+ line classes)"]
P2["Circular Deps
(LGraph ↔ Subgraph)"]
P3["Mixed Concerns
(render + domain + state)"]
P4["Inconsistent IDs
(number|string, no safety)"]
P5["Demeter Violations
(graph._links, graph._version++)"]
P6["Scattered Side Effects
(15+ _version++ sites)"]
P7["Render-Time Mutations
(arrange() during draw)"]
end
subgraph Solutions["ECS Solutions"]
S1["Components: small, focused
data objects (5-10 fields each)"]
S2["Entities are just IDs.
No inheritance hierarchy.
Subgraph = node + component."]
S3["One system per concern.
Systems don't overlap."]
S4["Consistent per-store
string-key conventions
(WidgetId, nodeLocatorId, raw ids)."]
S5["Systems query stores.
No entity→entity refs."]
S6["VersionSystem owns
all change tracking."]
S7["LayoutSystem runs in
update phase, before render.
RenderSystem is read-only."]
end
P1 --> S1
P2 --> S2
P3 --> S3
P4 --> S4
P5 --> S5
P6 --> S6
P7 --> S7
style Problems fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style Solutions fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
The migration is incremental. During the transition, a bridge layer keeps legacy class properties and ECS components in sync.
sequenceDiagram
participant Legacy as Legacy Code
participant Class as LGraphNode (class)
participant Bridge as Bridge Adapter
participant Store as layoutStore (ECS)
participant New as New Code / Systems
Note over Legacy,New: Phase 1: Bridge reads from class, writes to store
Legacy->>Class: node.pos = [100, 200]
Class->>Bridge: pos setter intercepted
Bridge->>Store: useLayoutMutations().moveNode(nodeId, { pos: [100, 200] })
New->>Store: layoutStore read for nodeId
Store-->>New: { pos: [100, 200], size: [...] }
Note over Legacy,New: Phase 2: New features build on ECS directly
New->>Store: useLayoutMutations().moveNode(nodeId, { pos: [150, 250] })
Store->>Bridge: change detected
Bridge->>Class: node._pos = [150, 250]
Legacy->>Class: node.pos
Class-->>Legacy: [150, 250]
Note over Legacy,New: Phase 3: Legacy code migrated, bridge removed
New->>Store: layoutStore read for nodeId
Store-->>New: { pos: [150, 250] }
Layout extraction is staged by node family, not all-at-once:
- Mark
arrange()as deprecated in render paths and collect call-site telemetry. - Run
LayoutSystemduring update for a selected node family behind a feature gate. - Keep a temporary compatibility fallback for un-migrated node families only.
- Remove the fallback once parity tests and frame-time budgets pass.
This keeps RenderSystem read-only for migrated families while preserving
incremental rollout safety.
graph LR
subgraph Phase1["Phase 1: Types Only"]
T1["Define string-key types
(WidgetId, nodeLocatorId)"]
T2["Define component interfaces"]
T3["Define store shapes"]
end
subgraph Phase2["Phase 2: Bridge"]
B1["Bridge adapters
class ↔ store sync"]
B2["New features use
stores as source"]
B3["Old code unchanged"]
end
subgraph Phase3["Phase 3: Extract"]
E1["Migrate one component
at a time"]
E2["Deprecate class
properties"]
E3["Systems replace
methods"]
end
subgraph Phase4["Phase 4: Clean"]
C1["Remove bridge"]
C2["Remove legacy classes"]
C3["Systems are sole
behavior layer"]
end
Phase1 --> Phase2 --> Phase3 --> Phase4
style Phase1 fill:#1a2a4a,stroke:#2a3a5a,color:#e0e0e0
style Phase2 fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
style Phase3 fill:#2a3a1a,stroke:#3a4a2a,color:#e0e0e0
style Phase4 fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
This diagram is intentionally high level. The operational Phase 4 -> 5 entry criteria (compatibility matrix, bridge fallback usage, rollback requirements) are defined in ecs-migration-plan.md.