## Summary
Architecture documentation proposing an Entity Component System for the
litegraph layer.
```mermaid
graph LR
subgraph Today["Today: Spaghetti"]
God["🍝 God Objects"]
Circ["🔄 Circular Deps"]
Mut["💥 Render Mutations"]
end
subgraph Tomorrow["Tomorrow: ECS"]
ID["🏷️ Branded IDs"]
Comp["📦 Components"]
Sys["⚙️ Systems"]
World["🌍 World"]
end
God -->|"decompose"| Comp
Circ -->|"flatten"| ID
Mut -->|"separate"| Sys
Comp --> World
ID --> World
Sys -->|"query"| World
```
## Changes
- **What**: ADR 0008 + 4 architecture docs (no code changes)
- `docs/adr/0008-entity-component-system.md` — entity taxonomy, branded
IDs, component decomposition, migration strategy
- `docs/architecture/entity-interactions.md` — as-is Mermaid diagrams of
all entity relationships
- `docs/architecture/entity-problems.md` — structural problems with
file:line evidence
- `docs/architecture/ecs-target-architecture.md` — target architecture
diagrams
- `docs/architecture/proto-ecs-stores.md` — analysis of existing Pinia
stores as proto-ECS patterns
## Review Focus
- Does the entity taxonomy (Node, Link, Subgraph, Widget, Slot, Reroute,
Group) cover all cases?
- Are the component decompositions reasonable starting points?
- Is the migration strategy (bridge layer, incremental extraction)
feasible?
- Are there entity interactions or problems we missed?
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10420-docs-ADR-0008-Entity-Component-System-32d6d73d365081feb048d16a5231d350)
by [Unito](https://www.unito.io)
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: GitHub Action <action@github.com>
Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Christian Byrne <cbyrne@comfy.org>
27 KiB
ECS Lifecycle Scenarios
This document walks through the major entity lifecycle operations — showing the current imperative implementation and how each transforms under the ECS architecture from ADR 0008.
Each scenario follows the same structure: Current Flow (what happens today), ECS Flow (what it looks like with the World), and a Key Differences table.
1. Node Removal
Current Flow
LGraph.remove(node) — 107 lines, touches 6+ entity types and 4+ external systems:
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant L as LLink
participant R as Reroute
participant C as LGraphCanvas
participant LS as LayoutStore
Caller->>G: remove(node)
G->>G: beforeChange() [undo checkpoint]
loop each input slot
G->>N: disconnectInput(i)
N->>L: link.disconnect(network)
L->>G: _links.delete(linkId)
L->>R: cleanup orphaned reroutes
N->>LS: layoutMutations.removeLink()
N->>G: _version++
end
loop each output slot
G->>N: disconnectOutput(i)
Note over N,R: same cascade as above
end
G->>G: scan floatingLinks for node refs
G->>G: if SubgraphNode: check refs, maybe delete subgraph def
G->>N: node.onRemoved?.()
G->>N: node.graph = null
G->>G: _version++
loop each canvas
G->>C: deselect(node)
G->>C: delete selected_nodes[id]
end
G->>G: splice from _nodes[], delete from _nodes_by_id
G->>G: onNodeRemoved?.(node)
G->>C: setDirtyCanvas(true, true)
G->>G: afterChange() [undo checkpoint]
G->>G: updateExecutionOrder()
Problems: the graph method manually disconnects every slot, cleans up reroutes, scans floating links, checks subgraph references, notifies canvases, and recomputes execution order — all in one method that knows about every entity type.
ECS Flow
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
Caller->>CS: removeNode(world, nodeId)
CS->>W: getComponent(nodeId, Connectivity)
W-->>CS: { inputSlotIds, outputSlotIds }
loop each slotId
CS->>W: getComponent(slotId, SlotConnection)
W-->>CS: { linkIds }
loop each linkId
CS->>CS: removeLink(world, linkId)
Note over CS,W: removes Link entity + updates remote slots
end
CS->>W: deleteEntity(slotId)
end
CS->>W: getComponent(nodeId, WidgetContainer)
W-->>CS: { widgetIds }
loop each widgetId
CS->>W: deleteEntity(widgetId)
end
CS->>W: deleteEntity(nodeId)
Note over W: removes Position, NodeVisual, NodeType,<br/>Connectivity, Execution, Properties,<br/>WidgetContainer — all at once
CS->>VS: markChanged()
Key Differences
| Aspect | Current | ECS |
|---|---|---|
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem knows Connectivity + SlotConnection |
| Cleanup | Manual per-slot, per-link, per-reroute | deleteEntity() removes all components atomically |
| Canvas notification | setDirtyCanvas() called explicitly |
RenderSystem sees missing entity on next frame |
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | World deletion IS the cleanup |
| Undo/redo | beforeChange()/afterChange() manually placed |
System snapshots affected components before deletion |
| Testability | Needs full LGraph + LGraphCanvas | Needs only World + ConnectivitySystem |
2. Serialization
Current Flow
LGraph.serialize() → asSerialisable() — walks every collection manually:
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant L as LLink
participant R as Reroute
participant Gr as LGraphGroup
participant SG as Subgraph
Caller->>G: serialize()
G->>G: asSerialisable()
loop each node
G->>N: node.serialize()
N->>N: snapshot inputs, outputs (with link IDs)
N->>N: snapshot properties
N->>N: collect widgets_values[]
N-->>G: ISerialisedNode
end
loop each link
G->>L: link.asSerialisable()
L-->>G: SerialisableLLink
end
loop each reroute
G->>R: reroute.asSerialisable()
R-->>G: SerialisableReroute
end
loop each group
G->>Gr: group.serialize()
Gr-->>G: ISerialisedGroup
end
G->>G: findUsedSubgraphIds()
loop each used subgraph
G->>SG: subgraph.asSerialisable()
Note over SG: recursively serializes internal nodes, links, etc.
SG-->>G: ExportedSubgraph
end
G-->>Caller: ISerialisedGraph
Problems: serialization logic lives in 6 different serialize() methods across 6 classes. Widget values are collected inline during node serialization. The graph walks its own collections — no separation of "what to serialize" from "how to serialize."
ECS Flow
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
Caller->>SS: serialize(world)
SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity)
W-->>SS: all node entities with their components
SS->>W: queryAll(LinkEndpoints)
W-->>SS: all link entities
SS->>W: queryAll(SlotIdentity, SlotConnection)
W-->>SS: all slot entities
SS->>W: queryAll(RerouteLinks, Position)
W-->>SS: all reroute entities
SS->>W: queryAll(GroupMeta, GroupChildren, Position)
W-->>SS: all group entities
SS->>W: queryAll(SubgraphStructure, SubgraphMeta)
W-->>SS: all subgraph entities
SS->>SS: assemble JSON from component data
SS-->>Caller: SerializedGraph
Key Differences
| Aspect | Current | ECS |
|---|---|---|
| Serialization logic | Spread across 6 classes (serialize() on each) |
Single SerializationSystem |
| Widget values | Collected inline during node.serialize() |
WidgetValue component queried directly |
| Subgraph recursion | asSerialisable() recursively calls itself |
Flat query — SubgraphStructure has entity refs |
| Adding a new component | Modify the entity's serialize() method |
Add component to query in SerializationSystem |
| Testing | Need full object graph to test serialization | Mock World with test components |
3. Deserialization
Current Flow
LGraph.configure(data) — ~180 lines, two-phase node creation:
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant L as LLink
participant WVS as WidgetValueStore
Caller->>G: configure(data)
G->>G: clear() [destroy all existing entities]
G->>G: _configureBase(data) [set id, extra]
loop each serialized link
G->>L: LLink.create(linkData)
G->>G: _links.set(link.id, link)
end
loop each serialized reroute
G->>G: setReroute(rerouteData)
end
opt has subgraph definitions
G->>G: deduplicateSubgraphNodeIds()
loop each subgraph (topological order)
G->>G: createSubgraph(data)
end
end
rect rgb(60, 40, 40)
Note over G,N: Phase 1: Create nodes (unlinked)
loop each serialized node
G->>N: LiteGraph.createNode(type)
G->>G: graph.add(node) [assigns ID]
end
end
rect rgb(40, 60, 40)
Note over G,N: Phase 2: Configure nodes (links now resolvable)
loop each node
G->>N: node.configure(nodeData)
N->>N: create slots, restore properties
N->>N: resolve links from graph._links
N->>N: restore widget values
N->>WVS: widget.setNodeId() → register in store
N->>N: fire onConnectionsChange per linked slot
end
end
G->>G: add floating links
G->>G: validate reroutes
G->>G: _removeDuplicateLinks()
loop each serialized group
G->>G: create + configure group
end
G->>G: updateExecutionOrder()
Problems: two-phase creation is necessary because nodes need to reference each other's links during configure. Widget value restoration happens deep inside node.configure(). Store population is a side effect of configuration. Subgraph creation requires topological sorting to handle nested subgraphs.
ECS Flow
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
participant LS as LayoutSystem
participant ES as ExecutionSystem
Caller->>SS: deserialize(world, data)
SS->>W: clear() [remove all entities]
Note over SS,W: All entities created in one pass — no two-phase needed
loop each node in data
SS->>W: createEntity(NodeEntityId)
SS->>W: setComponent(id, Position, {...})
SS->>W: setComponent(id, NodeType, {...})
SS->>W: setComponent(id, NodeVisual, {...})
SS->>W: setComponent(id, Properties, {...})
SS->>W: setComponent(id, Execution, {...})
end
loop each slot in data
SS->>W: createEntity(SlotEntityId)
SS->>W: setComponent(id, SlotIdentity, {...})
SS->>W: setComponent(id, SlotConnection, {...})
end
Note over SS,W: Slots reference links by ID — no resolution needed yet
loop each link in data
SS->>W: createEntity(LinkEntityId)
SS->>W: setComponent(id, LinkEndpoints, {...})
end
Note over SS,W: Connectivity assembled from slot/link components
loop each widget in data
SS->>W: createEntity(WidgetEntityId)
SS->>W: setComponent(id, WidgetIdentity, {...})
SS->>W: setComponent(id, WidgetValue, {...})
end
SS->>SS: create reroutes, groups, subgraphs similarly
Note over SS,W: Systems react to populated World
SS->>LS: runLayout(world)
SS->>ES: computeExecutionOrder(world)
Key Differences
| Aspect | Current | ECS |
|---|---|---|
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — components reference IDs, not instances |
| Widget restoration | Hidden inside node.configure() line ~900 |
Explicit: WidgetValue component written directly |
| Store population | Side effect of widget.setNodeId() |
World IS the store — writing component IS population |
| Callback cascade | onConnectionsChange, onInputAdded, onConfigure fire during configure |
No callbacks — systems query World after deserialization |
| Subgraph ordering | Topological sort required | Flat write — SubgraphStructure just holds entity IDs |
| Error handling | Failed node → placeholder with has_errors=true |
Failed entity → skip; components that loaded are still valid |
4. Pack Subgraph
Current Flow
LGraph.convertToSubgraph(items) — clones nodes, computes boundaries, creates SubgraphNode:
sequenceDiagram
participant Caller
participant G as LGraph
participant N as LGraphNode
participant SG as Subgraph
participant SGN as SubgraphNode
Caller->>G: convertToSubgraph(selectedItems)
G->>G: beforeChange()
G->>G: getBoundaryLinks(items)
Note over G: classify links as internal, boundary-in, boundary-out
G->>G: splitPositionables(items) → nodes, reroutes, groups
G->>N: multiClone(nodes) → cloned nodes (no links)
G->>G: serialize internal links, reroutes
G->>G: mapSubgraphInputsAndLinks(boundaryInputLinks)
G->>G: mapSubgraphOutputsAndLinks(boundaryOutputLinks)
G->>G: createSubgraph(exportedData)
G->>SG: subgraph.configure(data)
loop disconnect boundary links
G->>N: inputNode.disconnectInput()
G->>N: outputNode.disconnectOutput()
end
loop remove original items
G->>G: remove(node), remove(reroute), remove(group)
end
G->>SGN: LiteGraph.createNode(subgraph.id)
G->>G: graph.add(subgraphNode)
loop reconnect boundary inputs
G->>N: externalNode.connectSlots(output, subgraphNode, input)
end
loop reconnect boundary outputs
G->>SGN: subgraphNode.connectSlots(output, externalNode, input)
end
G->>G: afterChange()
Problems: 200+ lines in one method. Manual boundary link analysis. Clone-serialize-configure dance. Disconnect-remove-recreate-reconnect sequence with many intermediate states where the graph is inconsistent.
ECS Flow
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
Caller->>CS: packSubgraph(world, selectedEntityIds)
CS->>W: query Connectivity + SlotConnection for selected nodes
CS->>CS: classify links as internal vs boundary
CS->>W: create new GraphId scope in scopes registry
Note over CS,W: Create SubgraphNode entity in parent scope
CS->>W: createEntity(NodeEntityId) [the SubgraphNode]
CS->>W: setComponent(nodeId, Position, { center of selection })
CS->>W: setComponent(nodeId, SubgraphStructure, { graphId, interface })
CS->>W: setComponent(nodeId, SubgraphMeta, { name: 'New Subgraph' })
Note over CS,W: Re-parent selected entities into new graph scope
loop each selected entity
CS->>W: update graphScope to new graphId
end
Note over CS,W: Create boundary slots on SubgraphNode
loop each boundary input link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to target new slot
end
loop each boundary output link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to source from new slot
end
Key Differences
| Aspect | Current | ECS |
|---|---|---|
| Entity movement | Clone → serialize → configure → remove originals | Re-parent entities: update graphScope to new GraphId |
| Boundary links | Disconnect → remove → recreate → reconnect | Update LinkEndpoints to point at new SubgraphNode slots |
| Intermediate inconsistency | Graph is partially disconnected during operation | Atomic: all component writes happen together |
| Code size | 200+ lines | ~50 lines in system |
| Undo | beforeChange()/afterChange() wraps everything |
Snapshot affected components before mutation |
5. Unpack Subgraph
Current Flow
LGraph.unpackSubgraph(subgraphNode) — clones internal nodes, remaps IDs, reconnects boundary:
sequenceDiagram
participant Caller
participant G as LGraph
participant SGN as SubgraphNode
participant SG as Subgraph
participant N as LGraphNode
Caller->>G: unpackSubgraph(subgraphNode)
G->>G: beforeChange()
G->>SG: get internal nodes
G->>N: multiClone(internalNodes)
loop each cloned node
G->>G: assign new ID (++lastNodeId)
G->>G: nodeIdMap[oldId] = newId
G->>G: graph.add(node)
G->>N: node.configure(info)
G->>N: node.setPos(pos + offset)
end
G->>G: clone and add groups
Note over G,SG: Remap internal links
loop each internal link
G->>G: remap origin_id/target_id via nodeIdMap
opt origin is SUBGRAPH_INPUT_ID
G->>G: resolve to external source via subgraphNode.inputs
end
opt target is SUBGRAPH_OUTPUT_ID
G->>G: resolve to external target via subgraphNode.outputs
end
end
G->>G: remove(subgraphNode)
G->>G: deduplicate links
G->>G: create new LLink objects in parent graph
G->>G: remap reroute parentIds
G->>G: afterChange()
Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20) require special-case handling. Boundary link resolution requires looking up the SubgraphNode's slots to find external connections.
ECS Flow
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
W-->>CS: { graphId, interface }
CS->>W: query entities where graphScope = graphId
W-->>CS: all child entities (nodes, links, reroutes, etc.)
Note over CS,W: Re-parent entities to containing graph scope
loop each child entity
CS->>W: update graphScope to parent scope
end
Note over CS,W: Reconnect boundary links
loop each boundary slot in interface
CS->>W: getComponent(slotId, SlotConnection)
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
end
CS->>W: deleteEntity(subgraphNodeId)
CS->>W: remove graphId from scopes registry
Note over CS,W: Offset positions
loop each moved entity
CS->>W: update Position component
end
Key Differences
| Aspect | Current | ECS |
|---|---|---|
| ID remapping | nodeIdMap[oldId] = newId for every node and link |
No remapping — entities keep their IDs, only graphScope changes |
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as slot entities |
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Move entity references between scopes |
| Link reconnection | Remap origin_id/target_id, create new LLink objects | Update LinkEndpoints component in place |
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
6. Connect Slots
Current Flow
LGraphNode.connectSlots() — creates link, updates both endpoints, handles reroutes:
sequenceDiagram
participant Caller
participant N1 as SourceNode
participant N2 as TargetNode
participant G as LGraph
participant L as LLink
participant R as Reroute
participant LS as LayoutStore
Caller->>N1: connectSlots(output, targetNode, input)
N1->>N1: validate slot types
N1->>N2: onConnectInput?() → can reject
N1->>N1: onConnectOutput?() → can reject
opt input already connected
N1->>N2: disconnectInput(inputIndex)
end
N1->>L: new LLink(++lastLinkId, type, ...)
N1->>G: _links.set(link.id, link)
N1->>LS: layoutMutations.createLink()
N1->>N1: output.links.push(link.id)
N1->>N2: input.link = link.id
loop each reroute in path
N1->>R: reroute.linkIds.add(link.id)
end
N1->>G: _version++
N1->>N1: onConnectionsChange?(OUTPUT, ...)
N1->>N2: onConnectionsChange?(INPUT, ...)
N1->>G: setDirtyCanvas()
N1->>G: afterChange()
Problems: the source node orchestrates everything — it reaches into the graph's link map, the target node's slot, the layout store, the reroute chain, and the version counter. 19 steps in one method.
ECS Flow
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
Caller->>CS: connect(world, outputSlotId, inputSlotId)
CS->>W: getComponent(inputSlotId, SlotConnection)
opt already connected
CS->>CS: removeLink(world, existingLinkId)
end
CS->>W: createEntity(LinkEntityId)
CS->>W: setComponent(linkId, LinkEndpoints, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
CS->>W: update SlotConnection on outputSlotId (add linkId)
CS->>W: update SlotConnection on inputSlotId (set linkId)
CS->>VS: markChanged()
Key Differences
| Aspect | Current | ECS |
|---|---|---|
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (queries World) |
| Side effects | _version++, setDirtyCanvas(), afterChange(), callbacks |
markChanged() — one call |
| Reroute handling | Manual: iterate chain, add linkId to each | RerouteLinks component updated by system |
| Slot mutation | Direct: output.links.push(), input.link = id |
Component update: setComponent(slotId, SlotConnection, ...) |
| Validation | onConnectInput/onConnectOutput callbacks on nodes |
Validation system or guard function |
7. Copy / Paste
Current Flow
Copy: serialize selected items → clipboard. Paste: deserialize with new IDs.
sequenceDiagram
participant User
participant C as LGraphCanvas
participant G as LGraph
participant N as LGraphNode
participant CB as Clipboard
rect rgb(40, 40, 60)
Note over User,CB: Copy
User->>C: Ctrl+C
C->>C: _serializeItems(selectedItems)
loop each selected node
C->>N: node.clone().serialize()
C->>C: collect input links
end
C->>C: collect groups, reroutes
C->>C: recursively collect subgraph definitions
C->>CB: localStorage.setItem(JSON.stringify(data))
end
rect rgb(40, 60, 40)
Note over User,CB: Paste
User->>C: Ctrl+V
C->>CB: localStorage.getItem()
C->>C: _deserializeItems(parsed)
C->>C: remap subgraph IDs (new UUIDs)
C->>C: deduplicateSubgraphNodeIds()
C->>G: beforeChange()
loop each subgraph
C->>G: createSubgraph(data)
end
loop each node (id=-1 forces new ID)
C->>G: graph.add(node)
C->>N: node.configure(info)
end
loop each reroute
C->>G: setReroute(data)
C->>C: remap parentIds
end
loop each link
C->>N: outNode.connect(slot, inNode, slot)
end
C->>C: offset positions to cursor
C->>C: selectItems(created)
C->>G: afterChange()
end
Problems: clone-serialize-parse-remap-deserialize dance. Every entity type has its own ID remapping logic. Subgraph IDs, node IDs, reroute IDs, and link parent IDs all remapped independently. ~300 lines across multiple methods.
ECS Flow
sequenceDiagram
participant User
participant CS as ClipboardSystem
participant W as World
participant CB as Clipboard
rect rgb(40, 40, 60)
Note over User,CB: Copy
User->>CS: copy(world, selectedEntityIds)
CS->>W: snapshot all components for selected entities
CS->>W: snapshot components for child entities (slots, widgets)
CS->>W: snapshot connected links (LinkEndpoints)
CS->>CB: store component snapshot
end
rect rgb(40, 60, 40)
Note over User,CB: Paste
User->>CS: paste(world, position)
CS->>CB: retrieve snapshot
CS->>CS: generate ID remap table (old → new branded IDs)
loop each entity in snapshot
CS->>W: createEntity(newId)
loop each component
CS->>W: setComponent(newId, type, remappedData)
Note over CS,W: entity ID refs in component data<br/>are remapped via table
end
end
CS->>CS: offset all Position components to cursor
end
Key Differences
| Aspect | Current | ECS |
|---|---|---|
| Copy format | Clone → serialize → JSON (format depends on class) | Component snapshot (uniform format for all entities) |
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | Single remap table applied to all entity ID refs in all components |
| Paste reconstruction | createNode() → add() → configure() → connect() per node |
createEntity() → setComponent() per entity (flat) |
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot includes SubgraphStructure component with entity refs |
| Code complexity | ~300 lines across 4 methods | ~60 lines in one system |
Summary: Cross-Cutting Benefits
| Benefit | Scenarios Where It Applies |
|---|---|
| Atomic operations | Node Removal, Pack/Unpack — no intermediate inconsistent state |
No scattered _version++ |
All scenarios — VersionSystem handles change tracking |
| No callback cascades | Deserialization, Connect — systems query World instead of firing callbacks |
| Uniform ID handling | Copy/Paste, Unpack — one remap table instead of per-type logic |
| Entity deletion = cleanup | Node Removal — deleteEntity() removes all components |
| No two-phase creation | Deserialization — components reference IDs, not instances |
| Move instead of clone | Pack/Unpack — entities keep their IDs, just change scope |
| Testable in isolation | All scenarios — mock World, test one system |
| Undo/redo for free | All scenarios — snapshot components before mutation, restore on undo |