Files
ComfyUI_frontend/docs/architecture/ecs-world-command-api.md
Alexander Brown 3e197b5c57 docs: ADR 0008 — Entity Component System (#10420)
## 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>
2026-03-26 16:14:44 -07:00

350 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```ts
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
```ts
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
```ts
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:
1. Opens a World transaction (maps to one `beforeChange`/`afterChange`
bracket for undo).
2. Calls the command's `execute()`, which invokes system functions.
3. 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](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:
```ts
// Inside ConnectivitySystem — this is the handler, not the public API
ConnectivitySystem.connect(world, outputSlotId, inputSlotId)
```
The public API is a command:
```ts
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
```ts
const moveNode: Command = {
type: 'MoveNode',
nodeId,
pos: [150, 250],
execute(world) {
LayoutSystem.moveNode(world, this.nodeId, this.pos)
}
}
```
### Remove Node
```ts
const removeNode: Command = {
type: 'RemoveNode',
nodeId,
execute(world) {
ConnectivitySystem.removeNode(world, this.nodeId)
}
}
```
### Set Widget Value
```ts
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:
```ts
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:
```ts
// 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](ecs-migration-plan.md#phase-3---4-gate-required) 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:
```ts
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](ecs-lifecycle-scenarios.md) 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 (03).
During Phases 03, 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.