mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-04 13:12:10 +00:00
## 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>
723 lines
28 KiB
Markdown
723 lines
28 KiB
Markdown
# ECS Migration Plan
|
|
|
|
A phased roadmap for migrating the litegraph entity system to the ECS
|
|
architecture described in [ADR 0008](../adr/0008-entity-component-system.md).
|
|
Each phase is independently shippable. Later phases depend on earlier ones
|
|
unless noted otherwise.
|
|
|
|
For the problem analysis, see [Entity Problems](entity-problems.md). For the
|
|
target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
|
|
For verified accuracy of these documents, see
|
|
[Appendix: Critical Analysis](appendix-critical-analysis.md).
|
|
|
|
## Planning assumptions
|
|
|
|
- The bridge period is expected to span 2-3 release cycles.
|
|
- Bridge work is treated as transitional debt with explicit owners and sunset
|
|
checkpoints, not as a permanent architecture layer.
|
|
- Phase 5 is entered only by explicit go/no-go review against the criteria in
|
|
this document.
|
|
|
|
## Phase 0: Foundation
|
|
|
|
Zero behavioral risk. Prepares the codebase for extraction without changing
|
|
runtime semantics. All items are independently shippable.
|
|
|
|
### 0a. Centralize version counter
|
|
|
|
`graph._version++` appears in 19 locations across 7 files. The counter is only
|
|
read once — for debug display in `LGraphCanvas.renderInfo()` (line 5389). It
|
|
is not used for dirty-checking, caching, or reactivity.
|
|
|
|
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
|
|
increments.
|
|
|
|
```
|
|
incrementVersion(): void {
|
|
this._version++
|
|
}
|
|
```
|
|
|
|
| File | Sites |
|
|
| ---------------------- | ------------------------------------------------------- |
|
|
| `LGraph.ts` | 5 (lines 956, 989, 1042, 1109, 2643) |
|
|
| `LGraphNode.ts` | 8 (lines 833, 2989, 3138, 3176, 3304, 3539, 3550, 3567) |
|
|
| `LGraphCanvas.ts` | 2 (lines 3084, 7880) |
|
|
| `BaseWidget.ts` | 1 (line 439) |
|
|
| `SubgraphInput.ts` | 1 (line 137) |
|
|
| `SubgraphInputNode.ts` | 1 (line 190) |
|
|
| `SubgraphOutput.ts` | 1 (line 102) |
|
|
|
|
**Why first:** Creates the seam where a VersionSystem can later intercept,
|
|
batch, or replace the mechanism. Mechanical find-and-replace with zero
|
|
behavioral change.
|
|
|
|
**Risk:** None. Existing null guards at call sites are preserved.
|
|
|
|
### 0b. Add missing ID type aliases
|
|
|
|
`NodeId`, `LinkId`, and `RerouteId` exist as type aliases. Two are missing:
|
|
|
|
| Type | Definition | Location |
|
|
| ----------- | ---------- | ---------------------------------------------------------------- |
|
|
| `GroupId` | `number` | `LGraphGroup.ts` (currently implicit on `id: number` at line 39) |
|
|
| `SlotIndex` | `number` | `interfaces.ts` (slot positions are untyped `number` everywhere) |
|
|
|
|
**Change:** Add the type aliases, update property declarations, re-export from
|
|
barrel (`litegraph.ts`).
|
|
|
|
**Why:** Foundation for branded IDs. Type aliases are erased at compile time —
|
|
zero runtime impact.
|
|
|
|
**Risk:** None. Type-only change.
|
|
|
|
### 0c. Fix architecture doc errors
|
|
|
|
Five factual errors verified during code review (see
|
|
[Appendix](appendix-critical-analysis.md#vii-summary-of-findings)):
|
|
|
|
- `entity-problems.md`: `toJSON()` should be `toString()`, `execute()` should
|
|
be `doExecute()`, method count ~539 should be ~848, `configure()` is ~240
|
|
lines not ~180
|
|
- `proto-ecs-stores.md`: `resolveDeepest()` does not exist on
|
|
PromotedWidgetViewManager; actual methods are `reconcile()` / `getOrCreate()`
|
|
|
|
---
|
|
|
|
## Phase 1: Types and World Shell
|
|
|
|
Introduces the ECS type vocabulary and an empty World. No migration of existing
|
|
code — new types coexist with old ones.
|
|
|
|
### 1a. Branded entity ID types
|
|
|
|
Define branded types in a new `src/ecs/entityId.ts`:
|
|
|
|
```
|
|
type NodeEntityId = number & { readonly __brand: 'NodeEntityId' }
|
|
type LinkEntityId = number & { readonly __brand: 'LinkEntityId' }
|
|
type WidgetEntityId = number & { readonly __brand: 'WidgetEntityId' }
|
|
type SlotEntityId = number & { readonly __brand: 'SlotEntityId' }
|
|
type RerouteEntityId = number & { readonly __brand: 'RerouteEntityId' }
|
|
type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
|
|
type GraphId = string & { readonly __brand: 'GraphId' } // scope, not entity
|
|
```
|
|
|
|
Add cast helpers (`asNodeEntityId(id: number): NodeEntityId`) for use at
|
|
system boundaries (deserialization, legacy bridge).
|
|
|
|
**Does NOT change existing code.** The branded types are new exports consumed
|
|
only by new ECS code.
|
|
|
|
**Risk:** Low. New files, no modifications to existing code.
|
|
|
|
**Consideration:** `NodeId = number | string` is the current type. The branded
|
|
`NodeEntityId` narrows to `number`. The `string` branch exists solely for
|
|
subgraph-related nodes (GroupNode hack). The migration must decide whether to:
|
|
|
|
- Keep `NodeEntityId = number` and handle the string case at the bridge layer
|
|
- Or define `NodeEntityId = number | string` with branding (less safe)
|
|
|
|
Recommend the former: the bridge layer coerces string IDs to a numeric
|
|
mapping, and only branded numeric IDs enter the World.
|
|
|
|
### 1b. Component interfaces
|
|
|
|
Define component interfaces in `src/ecs/components/`:
|
|
|
|
```
|
|
src/ecs/
|
|
entityId.ts # Branded ID types
|
|
components/
|
|
position.ts # Position (shared by Node, Reroute, Group)
|
|
nodeType.ts # NodeType
|
|
nodeVisual.ts # NodeVisual
|
|
connectivity.ts # Connectivity
|
|
execution.ts # Execution
|
|
properties.ts # Properties
|
|
widgetContainer.ts # WidgetContainer
|
|
linkEndpoints.ts # LinkEndpoints
|
|
...
|
|
world.ts # World type and factory
|
|
```
|
|
|
|
Components are TypeScript interfaces only — no runtime code. They mirror
|
|
the decomposition in ADR 0008 Section "Component Decomposition."
|
|
|
|
**Risk:** None. Interface-only files.
|
|
|
|
### 1c. World type
|
|
|
|
Define the World as a typed container:
|
|
|
|
```ts
|
|
interface World {
|
|
nodes: Map<NodeEntityId, NodeComponents>
|
|
links: Map<LinkEntityId, LinkComponents>
|
|
widgets: Map<WidgetEntityId, WidgetComponents>
|
|
slots: Map<SlotEntityId, SlotComponents>
|
|
reroutes: Map<RerouteEntityId, RerouteComponents>
|
|
groups: Map<GroupEntityId, GroupComponents>
|
|
scopes: Map<GraphId, GraphId | null> // graph scope DAG (parent or null for root)
|
|
|
|
createEntity<K extends EntityKind>(kind: K): EntityIdFor<K>
|
|
deleteEntity<K extends EntityKind>(kind: K, id: EntityIdFor<K>): void
|
|
getComponent<C>(id: EntityId, component: ComponentKey<C>): C | undefined
|
|
setComponent<C>(id: EntityId, component: ComponentKey<C>, data: C): void
|
|
}
|
|
```
|
|
|
|
Subgraphs are not a separate entity kind. A node with a `SubgraphStructure`
|
|
component represents a subgraph. The `scopes` map tracks the graph nesting DAG.
|
|
See [Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full
|
|
model.
|
|
|
|
World scope is per workflow instance. Linked subgraph definitions can be reused
|
|
across instances, but mutable runtime state (widget values, execution state,
|
|
selection/transient view state) remains instance-scoped through `graphId`.
|
|
|
|
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
|
|
persistence. The World exists but nothing populates it yet.
|
|
|
|
**Risk:** Low. New code, no integration points.
|
|
|
|
---
|
|
|
|
## Phase 2: Bridge Layer
|
|
|
|
Connects the legacy class instances to the World. Both old and new code can
|
|
read entity state; writes still go through legacy classes.
|
|
|
|
### 2a. Read-only bridge for Position
|
|
|
|
The LayoutStore (`src/renderer/core/layout/store/layoutStore.ts`) already
|
|
extracts position data for nodes, links, and reroutes into Y.js CRDTs. The
|
|
bridge reads from LayoutStore and populates the World's `Position` component.
|
|
|
|
**Approach:** A `PositionBridge` that observes LayoutStore changes and mirrors
|
|
them into the World. New code reads `world.getComponent(nodeId, Position)`;
|
|
legacy code continues to read `node.pos` / LayoutStore directly.
|
|
|
|
**Open question:** Should the World wrap the Y.js maps or maintain its own
|
|
plain-data copy? Options:
|
|
|
|
| Approach | Pros | Cons |
|
|
| ---------------------- | ------------------------------------- | ----------------------------------------------- |
|
|
| World wraps Y.js | Single source of truth; no sync lag | World API becomes CRDT-aware; harder to test |
|
|
| World copies from Y.js | Clean World API; easy to test | Two copies of position data; sync overhead |
|
|
| World replaces Y.js | Pure ECS; no CRDT dependency in World | Breaks collaboration (ADR 0003); massive change |
|
|
|
|
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
|
|
copy is cheap (position is small data). Revisit if sync overhead becomes
|
|
measurable.
|
|
|
|
**Risk:** Medium. Introduces a sync point between two state systems. Must
|
|
ensure the bridge doesn't create subtle ordering bugs (e.g., World reads stale
|
|
position during render).
|
|
|
|
### 2b. Read-only bridge for WidgetValue
|
|
|
|
WidgetValueStore (`src/stores/widgetValueStore.ts`) already extracts widget
|
|
state into plain `WidgetState` objects keyed by `graphId:nodeId:name`. This is
|
|
the closest proto-ECS store.
|
|
|
|
**Approach:** A `WidgetBridge` that maps `WidgetValueStore` entries into
|
|
`WidgetValue` components in the World, keyed by `WidgetEntityId`. Requires
|
|
assigning synthetic widget IDs (via `lastWidgetId` counter on LGraphState).
|
|
|
|
**Dependency:** Requires 1a (branded IDs) for `WidgetEntityId`.
|
|
|
|
**Risk:** Low-Medium. WidgetValueStore is well-structured. Main complexity is
|
|
the ID mapping — widgets currently lack independent IDs, so the bridge must
|
|
maintain a `(nodeId, widgetName) -> WidgetEntityId` lookup.
|
|
|
|
### 2c. Read-only bridge for Node metadata
|
|
|
|
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
|
|
reading from `LGraphNode` instances. These are simple property copies.
|
|
|
|
**Approach:** When a node is added to the graph (`LGraph.add()`), the bridge
|
|
creates the corresponding entity in the World and populates its components.
|
|
When a node is removed, the bridge deletes the entity.
|
|
|
|
The `incrementVersion()` method from Phase 0a becomes the hook point — when
|
|
version increments, the bridge can re-sync changed components. (This is why
|
|
centralizing version first matters.)
|
|
|
|
**Risk:** Medium. Must handle the full node lifecycle (add, configure, remove)
|
|
without breaking existing behavior. The bridge is read-only (World mirrors
|
|
classes, not the reverse), which limits blast radius.
|
|
|
|
### Bridge sunset criteria (applies to every Phase 2 bridge)
|
|
|
|
A bridge can move from "transitional" to "removal candidate" only when:
|
|
|
|
- All production reads for that concern flow through World component queries.
|
|
- All production writes for that concern flow through system APIs.
|
|
- Serialization parity tests show no diff between legacy and World paths.
|
|
- Extension compatibility tests pass without bridge-only fallback paths.
|
|
|
|
These criteria prevent the bridge from becoming permanent by default.
|
|
|
|
### Bridge duration and maintenance controls
|
|
|
|
To contain dual-path maintenance cost during Phases 2-4:
|
|
|
|
- Every bridge concern has a named owner and target sunset release.
|
|
- Every PR touching bridge-covered data paths must include parity tests for both
|
|
legacy and World-driven execution.
|
|
- Bridge fallback usage is instrumented in integration/e2e and reviewed every
|
|
milestone; upward trends block new bridge expansion.
|
|
- Any bridge that misses its target sunset release requires an explicit risk
|
|
review and revised removal plan.
|
|
|
|
---
|
|
|
|
## Phase 3: Systems
|
|
|
|
Introduce system functions that operate on World data. Systems coexist with
|
|
legacy methods — they don't replace them yet.
|
|
|
|
### 3a. SerializationSystem (read-only)
|
|
|
|
A function `serializeFromWorld(world: World): SerializedGraph` that produces
|
|
workflow JSON by querying World components. Run alongside the existing
|
|
`LGraph.serialize()` in tests to verify equivalence.
|
|
|
|
**Why first:** Serialization is read-only and has a clear correctness check
|
|
(output must match existing serialization). It exercises every component type
|
|
and proves the World contains sufficient data.
|
|
|
|
**Risk:** Low. Runs in parallel with existing code; does not replace it.
|
|
|
|
### 3b. VersionSystem
|
|
|
|
Replace the `incrementVersion()` method with a system that owns all change
|
|
tracking. The system observes component mutations on the World and
|
|
auto-increments the version counter.
|
|
|
|
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
|
|
doesn't see changes).
|
|
|
|
**Risk:** Medium. Must not miss any change that the scattered `_version++`
|
|
currently catches. The 19-site inventory from Phase 0a serves as the test
|
|
matrix.
|
|
|
|
### 3c. ConnectivitySystem (queries only)
|
|
|
|
A system that can answer connectivity queries by reading `Connectivity`,
|
|
`SlotConnection`, and `LinkEndpoints` components from the World:
|
|
|
|
- "What nodes are connected to this node's inputs?"
|
|
- "What links pass through this reroute?"
|
|
- "What is the execution order?"
|
|
|
|
Does not perform mutations yet — just queries. Validates that the World's
|
|
connectivity data is complete and consistent with the class-based graph.
|
|
|
|
**Risk:** Low. Read-only system with equivalence tests.
|
|
|
|
---
|
|
|
|
## Phase 4: Write Path Migration
|
|
|
|
Systems begin owning mutations. Legacy class methods delegate to systems.
|
|
This is the highest-risk phase.
|
|
|
|
### 4a. Position writes through World
|
|
|
|
New code writes position via `world.setComponent(nodeId, Position, ...)`.
|
|
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
|
|
|
|
**This inverts the data flow:** Phase 2 had legacy -> World (read bridge).
|
|
Phase 4 has World -> legacy (write bridge). Both paths must work during the
|
|
transition.
|
|
|
|
**Risk:** High. Two-way sync between World and legacy state. Must handle
|
|
re-entrant updates (World write triggers bridge, which writes to legacy,
|
|
which must NOT trigger another World write).
|
|
|
|
### 4b. ConnectivitySystem mutations
|
|
|
|
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
|
|
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
|
|
system.
|
|
|
|
**Extension API concern:** The current system fires callbacks at each step:
|
|
|
|
- `onConnectInput()` / `onConnectOutput()` — can reject connections
|
|
- `onConnectionsChange()` — notifies after connection change
|
|
- `onRemoved()` — notifies after node removal
|
|
|
|
These callbacks are the **extension API contract**. The ConnectivitySystem
|
|
must fire them at the same points in the operation, or extensions break.
|
|
|
|
**Recommended approach:** The system emits lifecycle events that the bridge
|
|
layer translates into legacy callbacks. This preserves the contract without
|
|
the system knowing about the callback API.
|
|
|
|
**Phase 4 callback contract (locked):**
|
|
|
|
- `onConnectOutput()` and `onConnectInput()` run before any World mutation.
|
|
- If either callback rejects, abort with no component writes, no version bump,
|
|
and no lifecycle events.
|
|
- `onConnectionsChange()` fires synchronously after commit, preserving current
|
|
source-then-target ordering.
|
|
- Bridge lifecycle events remain internal. Legacy callbacks stay the public
|
|
compatibility API during Phase 4.
|
|
|
|
**Risk:** High. Extensions depend on callback ordering and timing. Must be
|
|
validated against real-world extensions.
|
|
|
|
### 4c. Widget write path
|
|
|
|
Widget value changes go through the World instead of directly through
|
|
WidgetValueStore. The World's `WidgetValue` component becomes the single
|
|
source of truth; WidgetValueStore becomes a read-through cache or is removed.
|
|
|
|
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
|
|
change is routing writes through the World instead of the store.
|
|
|
|
### 4d. Layout write path and render decoupling
|
|
|
|
Remove layout side effects from render incrementally by node family.
|
|
|
|
**Approach:**
|
|
|
|
1. Inventory `drawNode()` call paths that still trigger `arrange()`.
|
|
2. For one node family at a time, run `LayoutSystem` in update phase and mark
|
|
entities as layout-clean before render.
|
|
3. Keep a temporary compatibility fallback that runs legacy layout only for
|
|
non-migrated families.
|
|
4. Delete fallback once parity tests and frame-time budgets are met.
|
|
|
|
**Risk:** High. Mixed-mode operation must avoid stale layout reads. Requires
|
|
family-level rollout and targeted regression tests.
|
|
|
|
### Render hot-path performance gate
|
|
|
|
Before enabling ECS render reads as default for any migrated family:
|
|
|
|
- Benchmark representative workflows (200-node and 500-node minimum).
|
|
- Compare legacy vs ECS p95 frame time and mean draw cost.
|
|
- Block rollout on statistically significant regression beyond agreed budget
|
|
(default budget: 5% p95 frame-time regression ceiling).
|
|
- Capture profiler traces proving the dominant cost is not repeated
|
|
`world.getComponent()` lookups.
|
|
|
|
### Phase 3 -> 4 gate (required)
|
|
|
|
Phase 4 starts only when all of the following are true:
|
|
|
|
- A transaction wrapper API exists on the World and is used by connectivity and
|
|
widget write paths in integration tests.
|
|
- Undo batching parity is proven: one logical user action yields one undo
|
|
checkpoint in both legacy and ECS paths.
|
|
- Callback timing and rejection semantics from Phase 4b are covered by
|
|
integration tests.
|
|
- A representative extension suite passes, including `rgthree-comfy`.
|
|
- Write bridge re-entrancy tests prove there is no World <-> legacy feedback
|
|
loop.
|
|
- Layout migration for any enabled node family passes read-only render checks
|
|
(no `arrange()` writes during draw).
|
|
- Render hot-path benchmark gate passes for every family moving to ECS-first
|
|
reads.
|
|
|
|
---
|
|
|
|
## Phase 5: Legacy Removal
|
|
|
|
Remove bridge layers and deprecated class properties. This phase happens
|
|
per-component, not all at once.
|
|
|
|
### 5a. Remove Position bridge
|
|
|
|
Once all position reads and writes go through the World, remove the bridge
|
|
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
|
|
|
|
### 5b. Remove widget class hierarchy
|
|
|
|
Once all widget behavior is in systems, the 23+ widget subclasses can be
|
|
replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
|
|
`ComboWidget`, etc. become configuration data rather than class instances.
|
|
|
|
### 5c. Dissolve god objects
|
|
|
|
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is
|
|
holding the entity ID and delegating to the World. Eventually, they can be
|
|
removed entirely, replaced by entity ID + component queries.
|
|
|
|
**Risk:** Very High. This is the irreversible step. Must be done only after
|
|
thorough validation that all consumers (including extensions) work with the
|
|
ECS path.
|
|
|
|
### Phase 4 -> 5 exit criteria (required)
|
|
|
|
Legacy removal starts only when all of the following are true:
|
|
|
|
- The component being removed has no remaining direct reads or writes outside
|
|
World/system APIs.
|
|
- Serialization equivalence tests pass continuously for one release cycle.
|
|
- A representative extension compatibility matrix is green, including
|
|
`rgthree-comfy`.
|
|
- Bridge instrumentation shows zero fallback-path usage in integration and e2e
|
|
suites.
|
|
- A rollback plan exists for each removal PR until the release is cut.
|
|
- ECS write path has run as default behind a kill switch for at least one full
|
|
release cycle.
|
|
- No unresolved P0/P1 extension regressions are attributed to ECS migration in
|
|
that cycle.
|
|
|
|
### Phase 5 trigger packet (required before first legacy-removal PR)
|
|
|
|
The team prepares a single go/no-go packet containing:
|
|
|
|
- Phase 4 -> 5 criteria checklist with links to evidence.
|
|
- Extension compatibility matrix results.
|
|
- Bridge fallback usage report (must be zero for the target concern).
|
|
- Performance gate report for ECS render/read paths.
|
|
- Rollback owner, rollback steps, and release coordination sign-off.
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
### CRDT / ECS coexistence
|
|
|
|
The LayoutStore uses Y.js CRDTs for collaboration-ready position data
|
|
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The ECS World
|
|
uses plain `Map`s. These must coexist.
|
|
|
|
**Options explored in Phase 2a.** The recommended path (World copies from Y.js)
|
|
defers the hard question. Eventually, the World may need to be CRDT-native —
|
|
but this requires a separate ADR.
|
|
|
|
**Questions to resolve:**
|
|
|
|
- Should non-position components also be CRDT-backed for collaboration?
|
|
- Does the World need an operation log for undo/redo, or can that remain
|
|
external (Y.js undo manager)?
|
|
- How does conflict resolution work when two users modify the same component?
|
|
|
|
### Extension API preservation
|
|
|
|
The current system exposes lifecycle callbacks on entity classes:
|
|
|
|
| Callback | Class | Purpose |
|
|
| --------------------- | ------------ | ----------------------------------- |
|
|
| `onConnectInput` | `LGraphNode` | Validate/reject incoming connection |
|
|
| `onConnectOutput` | `LGraphNode` | Validate/reject outgoing connection |
|
|
| `onConnectionsChange` | `LGraphNode` | React to topology change |
|
|
| `onRemoved` | `LGraphNode` | Cleanup on deletion |
|
|
| `onAdded` | `LGraphNode` | Setup on graph insertion |
|
|
| `onConfigure` | `LGraphNode` | Post-deserialization hook |
|
|
| `onWidgetChanged` | `LGraphNode` | React to widget value change |
|
|
|
|
Extensions register these callbacks to customize node behavior. The ECS
|
|
migration must preserve this contract or provide a documented migration path
|
|
for extension authors.
|
|
|
|
**Recommended approach:** Define an `EntityLifecycleEvent` system that emits
|
|
typed events at the same points where callbacks currently fire. The bridge
|
|
layer translates events into legacy callbacks. Extensions can gradually adopt
|
|
event listeners instead of callbacks.
|
|
|
|
**Phase 4 decisions:**
|
|
|
|
- Rejection callbacks act as pre-commit guards (reject before World mutation).
|
|
- Callback dispatch remains synchronous during the bridge period.
|
|
- Callback order remains: output validation -> input validation -> commit ->
|
|
output change notification -> input change notification.
|
|
|
|
### Extension Migration Examples (old -> new)
|
|
|
|
The bridge keeps legacy callbacks working, but extension authors can migrate
|
|
incrementally to ECS-native patterns.
|
|
|
|
#### 1) Widget lookup by name
|
|
|
|
```ts
|
|
// Legacy pattern
|
|
const seedWidget = node.widgets?.find((w) => w.name === 'seed')
|
|
seedWidget?.setValue(42)
|
|
|
|
// ECS pattern (using the bridge/world widget lookup index)
|
|
const seedWidgetId = world.widgetIndex.getByNodeAndName(nodeId, 'seed')
|
|
if (seedWidgetId) {
|
|
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
|
|
if (widgetValue) {
|
|
world.setComponent(seedWidgetId, WidgetValue, {
|
|
...widgetValue,
|
|
value: 42
|
|
})
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 2) `onConnectionsChange` callback
|
|
|
|
```ts
|
|
// Legacy pattern
|
|
nodeType.prototype.onConnectionsChange = function (
|
|
side,
|
|
slot,
|
|
connected,
|
|
linkInfo
|
|
) {
|
|
updateExtensionState(this.id, side, slot, connected, linkInfo)
|
|
}
|
|
|
|
// ECS pattern
|
|
lifecycleEvents.on('connection.changed', (event) => {
|
|
if (event.nodeId !== nodeId) return
|
|
updateExtensionState(
|
|
event.nodeId,
|
|
event.side,
|
|
event.slotIndex,
|
|
event.connected,
|
|
event.linkInfo
|
|
)
|
|
})
|
|
```
|
|
|
|
#### 3) `onRemoved` callback
|
|
|
|
```ts
|
|
// Legacy pattern
|
|
nodeType.prototype.onRemoved = function () {
|
|
cleanupExtensionResources(this.id)
|
|
}
|
|
|
|
// ECS pattern
|
|
lifecycleEvents.on('entity.removed', (event) => {
|
|
if (event.kind !== 'node' || event.entityId !== nodeId) return
|
|
cleanupExtensionResources(event.entityId)
|
|
})
|
|
```
|
|
|
|
#### 4) `graph._version++`
|
|
|
|
```ts
|
|
// Legacy pattern (do not add new usages)
|
|
graph._version++
|
|
|
|
// Bridge-safe transitional pattern (Phase 0a)
|
|
graph.incrementVersion()
|
|
|
|
// ECS-native pattern: mutate through command/system API.
|
|
// VersionSystem bumps once at transaction commit.
|
|
executor.run({
|
|
type: 'SetWidgetValue',
|
|
execute(world) {
|
|
const value = world.getComponent(widgetId, WidgetValue)
|
|
if (!value) return
|
|
world.setComponent(widgetId, WidgetValue, { ...value, value: 42 })
|
|
}
|
|
})
|
|
```
|
|
|
|
**Question to resolve after compatibility parity:**
|
|
|
|
- Should ECS-native lifecycle events stay synchronous after bridge removal, or
|
|
can they become asynchronous once legacy callback compatibility is dropped?
|
|
|
|
### Atomicity and transactions
|
|
|
|
The ECS lifecycle scenarios claim operations are "atomic." This requires
|
|
the World to support transactions — the ability to batch multiple component
|
|
writes and commit or rollback as a unit.
|
|
|
|
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
|
|
checkpoints but not true transactions. The graph can be in an inconsistent
|
|
state between these calls.
|
|
|
|
**Phase 4 baseline semantics:**
|
|
|
|
- Mutating systems run inside `world.transaction(label, fn)`.
|
|
- The bridge maps one World transaction to one `beforeChange()` /
|
|
`afterChange()` bracket.
|
|
- Operations with multiple component writes (for example `connect()` touching
|
|
slots, links, and node metadata) still commit as one transaction and therefore
|
|
one undo entry.
|
|
- Failed transactions do not publish partial writes, lifecycle events, or
|
|
version increments.
|
|
|
|
**Questions to resolve:**
|
|
|
|
- How should `world.transaction()` interact with Y.js transactions when a
|
|
component is CRDT-backed?
|
|
- Is eventual consistency acceptable for derived data updates between
|
|
transactions, or must post-transaction state always be immediately
|
|
consistent?
|
|
|
|
### Keying strategy unification
|
|
|
|
The 6 proto-ECS stores use 6 different keying strategies:
|
|
|
|
| Store | Key Format |
|
|
| ----------------------- | --------------------------------- |
|
|
| WidgetValueStore | `"${nodeId}:${widgetName}"` |
|
|
| PromotionStore | `"${sourceNodeId}:${widgetName}"` |
|
|
| DomWidgetStore | Widget UUID |
|
|
| LayoutStore | Raw nodeId/linkId/rerouteId |
|
|
| NodeOutputStore | `"${subgraphId}:${nodeId}"` |
|
|
| SubgraphNavigationStore | subgraphId or `'root'` |
|
|
|
|
The World unifies these under branded entity IDs. But stores that use
|
|
composite keys (e.g., `nodeId:widgetName`) reflect a genuine structural
|
|
reality — a widget is identified by its relationship to a node. Synthetic
|
|
`WidgetEntityId`s replace this with an opaque number, requiring a reverse
|
|
lookup index.
|
|
|
|
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
|
|
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
|
|
for the transition period.
|
|
|
|
---
|
|
|
|
## Dependency Graph
|
|
|
|
```
|
|
Phase 0a (incrementVersion) ──┐
|
|
Phase 0b (ID type aliases) ───┤
|
|
Phase 0c (doc fixes) ─────────┤── no dependencies between these
|
|
│
|
|
Phase 1a (branded IDs) ────────┤
|
|
Phase 1b (component interfaces) ┤── 1b depends on 1a
|
|
Phase 1c (World type) ─────────┘── 1c depends on 1a, 1b
|
|
|
|
Phase 2a (Position bridge) ────┐── depends on 1c
|
|
Phase 2b (Widget bridge) ──────┤── depends on 1a, 1c
|
|
Phase 2c (Node metadata bridge) ┘── depends on 0a, 1c
|
|
|
|
Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
|
|
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
|
|
Phase 3c (ConnectivitySystem) ──── depends on 2c
|
|
|
|
Phase 3->4 gate checklist ──────── depends on 3a, 3b, 3c
|
|
|
|
Phase 4a (Position writes) ────── depends on 2a, 3b
|
|
Phase 4b (Connectivity mutations) ─ depends on 3c, 3->4 gate
|
|
Phase 4c (Widget writes) ─────── depends on 2b
|
|
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
|
|
|
|
Phase 4->5 exit criteria ──────── depends on all of Phase 4
|
|
|
|
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
|
|
```
|
|
|
|
## Risk Summary
|
|
|
|
| Phase | Risk | Reversibility | Extension Impact |
|
|
| ------------------ | ---------- | ----------------------- | --------------------------- |
|
|
| 0 (Foundation) | None | Fully reversible | None |
|
|
| 1 (Types/World) | Low | New files, deletable | None |
|
|
| 2 (Bridge) | Low-Medium | Bridge is additive | None |
|
|
| 3 (Systems) | Low-Medium | Systems run in parallel | None |
|
|
| 4 (Write path) | High | Two-way sync is fragile | Callbacks must be preserved |
|
|
| 5 (Legacy removal) | Very High | Irreversible | Extensions must migrate |
|
|
|
|
The plan is designed so that Phases 0-3 can ship without any risk to
|
|
extensions or existing behavior. Phase 4 is where the real migration begins,
|
|
and Phase 5 is the point of no return.
|