## 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>
10 KiB
World API and Command Layer
How the ECS World's imperative API relates to ADR 0003's command pattern requirement, and why the two are complementary rather than conflicting.
This document responds to the concern that world.setComponent() and
ConnectivitySystem.connect() are "imperative mutators" incompatible with
serializable, idempotent commands. The short answer: they are the
implementation of commands, not a replacement for them.
Architectural Layering
Caller → Command → System (handler) → World (store) → Y.js (sync)
↓
Command Log (undo, replay, sync)
- Commands describe intent. They are serializable, deterministic, and idempotent.
- Systems are command handlers. They validate, execute, and emit lifecycle events.
- The World is the store. It holds component data. It does not know about commands.
This is the same relationship Redux has between actions, reducers, and the
store. The store's dispatch() is imperative. That does not make Redux
incompatible with serializable actions.
Proposed World Mutation API
The World exposes a thin imperative surface. Every mutation goes through a system, and every system call is invoked by a command.
World Core API
interface World {
// Reads (no command needed)
getComponent<C>(id: EntityId, key: ComponentKey<C>): C | undefined
hasComponent(id: EntityId, key: ComponentKey<C>): boolean
queryAll<C extends ComponentKey[]>(...keys: C): QueryResult<C>[]
// Mutations (called only by systems, inside transactions)
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
setComponent<C>(id: EntityId, key: ComponentKey<C>, data: C): void
removeComponent(id: EntityId, key: ComponentKey<C>): void
// Transaction boundary
transaction<T>(label: string, fn: () => T): T
}
These methods are internal. External callers never call
world.setComponent() directly — they submit commands.
Command Interface
interface Command<T = void> {
readonly type: string
execute(world: World): T
}
A command is a plain object with a type discriminator and an execute
method that receives the World. The command executor wraps every
execute() call in a World transaction.
Command Executor
interface CommandExecutor {
run<T>(command: Command<T>): T
batch(label: string, commands: Command[]): void
}
function createCommandExecutor(world: World): CommandExecutor {
return {
run(command) {
return world.transaction(command.type, () => command.execute(world))
},
batch(label, commands) {
world.transaction(label, () => {
for (const cmd of commands) cmd.execute(world)
})
}
}
}
Every command execution:
- Opens a World transaction (maps to one
beforeChange/afterChangebracket for undo). - Calls the command's
execute(), which invokes system functions. - Commits the transaction. On failure, rolls back — no partial writes, no lifecycle events, no version bump.
From Imperative Calls to Commands
The lifecycle scenarios in
ecs-lifecycle-scenarios.md show system calls
like ConnectivitySystem.connect(world, outputSlotId, inputSlotId). These
are the internals of a command. Here is how each scenario maps:
Connect Slots
The lifecycle scenario shows:
// Inside ConnectivitySystem — this is the handler, not the public API
ConnectivitySystem.connect(world, outputSlotId, inputSlotId)
The public API is a command:
const connectSlots: Command = {
type: 'ConnectSlots',
outputSlotId,
inputSlotId,
execute(world) {
ConnectivitySystem.connect(world, this.outputSlotId, this.inputSlotId)
}
}
executor.run(connectSlots)
The command object is serializable ({ type, outputSlotId, inputSlotId }).
It can be sent over a wire, stored in a log, or replayed.
Move Node
const moveNode: Command = {
type: 'MoveNode',
nodeId,
pos: [150, 250],
execute(world) {
LayoutSystem.moveNode(world, this.nodeId, this.pos)
}
}
Remove Node
const removeNode: Command = {
type: 'RemoveNode',
nodeId,
execute(world) {
ConnectivitySystem.removeNode(world, this.nodeId)
}
}
Set Widget Value
const setWidgetValue: Command = {
type: 'SetWidgetValue',
widgetId,
value,
execute(world) {
world.setComponent(this.widgetId, WidgetValue, {
...world.getComponent(this.widgetId, WidgetValue)!,
value: this.value
})
}
}
Batch: Paste
Paste is a compound operation — many entities created in one undo step:
const paste: Command = {
type: 'Paste',
snapshot,
offset,
execute(world) {
const remap = new Map<EntityId, EntityId>()
for (const entity of this.snapshot.entities) {
const newId = world.createEntity(entity.kind)
remap.set(entity.id, newId)
for (const [key, data] of entity.components) {
world.setComponent(newId, key, remapEntityRefs(data, remap))
}
}
// Offset positions
for (const [, newId] of remap) {
const pos = world.getComponent(newId, Position)
if (pos) {
world.setComponent(newId, Position, {
...pos,
pos: [pos.pos[0] + this.offset[0], pos.pos[1] + this.offset[1]]
})
}
}
}
}
executor.run(paste) // one transaction, one undo step
Addressing the Six Concerns
The PR review raised six "critical conflicts." Here is how the command layer resolves each:
1. "The World API is imperative, not command-based"
Correct — by design. The World is the store. Commands are the public
mutation API above it. world.setComponent() is to commands what
state[key] = value is to Redux reducers.
2. "Systems are orchestrators, not command producers"
Systems are command handlers. A command's execute() calls system
functions. Systems do not spontaneously mutate the World — they are invoked
by commands.
3. "Auto-incrementing IDs are non-stable in concurrent environments"
For local-only operations, auto-increment is fine. For CRDT sync, entity
creation goes through a CRDT-aware ID generator (Y.js provides this via
doc.clientID + logical clock). The command layer can select the ID
strategy:
// Local-only command
world.createEntity(kind) // auto-increment
// CRDT-aware command (future)
world.createEntityWithId(kind, crdtGeneratedId)
This is an ID generation concern, not an ECS architecture concern.
4. "No transaction primitive exists"
world.transaction(label, fn) is the primitive. It maps to one
beforeChange/afterChange bracket. The command executor wraps every
execute() call in a transaction. See the migration plan's Phase 3→4
gate for the acceptance
criteria.
5. "No idempotency guarantees"
Idempotency is a property of the command, not the store. Two strategies:
- Content-addressed IDs: The command specifies the entity ID rather than auto-generating. Replaying the command with the same ID is a no-op if the entity already exists.
- Command deduplication: The command log tracks applied command IDs. Replaying an already-applied command is skipped.
Both are standard CRDT patterns and belong in the command executor, not the World.
6. "No error semantics"
Commands return results. The executor can wrap execution:
type CommandResult<T> =
| { status: 'applied'; value: T }
| { status: 'rejected'; reason: string }
| { status: 'no-op' }
function run<T>(command: Command<T>): CommandResult<T> {
try {
const value = world.transaction(command.type, () => command.execute(world))
return { status: 'applied', value }
} catch (e) {
if (e instanceof RejectionError) {
return { status: 'rejected', reason: e.message }
}
throw e
}
}
Rejection semantics (e.g., onConnectInput returning false) throw a
RejectionError inside the system, which the transaction rolls back.
Why Two ADRs
ADR 0003 defines the command pattern and CRDT sync layer. ADR 0008 defines the entity data model.
They are complementary architectural layers, not competing proposals:
| Concern | Owns It |
|---|---|
| Entity taxonomy and IDs | ADR 0008 |
| Component decomposition | ADR 0008 |
| World (store) | ADR 0008 |
| Command interface | ADR 0003 |
| Undo/redo via command log | ADR 0003 |
| CRDT sync | ADR 0003 |
| Serialization format | ADR 0008 |
| Replay and idempotency | ADR 0003 |
Merging them into a single mega-ADR would conflate the data model with the mutation strategy. Keeping them separate allows each to evolve independently — the World can change its internal representation without affecting the command API, and the command layer can adopt new sync strategies without restructuring the entity model.
Relationship to Lifecycle Scenarios
The lifecycle scenarios show system-level
calls (ConnectivitySystem.connect(), ClipboardSystem.paste(), etc.).
These are the inside of a command — what the command handler does when
the command is executed.
The scenarios deliberately omit the command layer to focus on how systems
interact with the World. Adding command wrappers is mechanical: every
system call shown in the scenarios becomes the body of a command's
execute() method.
When This Gets Built
The command layer is not part of the initial ECS migration phases (0–3). During Phases 0–3, the bridge layer provides mutation entry points that will later become command handlers. The command layer is introduced in Phase 4 when write paths migrate from legacy to ECS:
- Phase 4a: Position write commands replace direct
node.pos =assignment - Phase 4b: Connectivity commands replace
node.connect()/node.disconnect() - Phase 4c: Widget value commands replace direct store writes
Each Phase 4 step introduces commands for one concern, with the system function as the handler and the World transaction as the atomicity boundary.