Compare commits

...

2 Commits

Author SHA1 Message Date
Alexander Brown
55b75e575f Merge branch 'main' into drjkl/architecture-update 2026-06-23 17:06:17 -07:00
DrJKL
df40b2c8fa docs: retarget ECS architecture docs to dedicated stores over a single World
Align the ECS architecture docs and ADR 0008 with the shipped direction
(PR 12617): entity data lives in dedicated Pinia stores keyed by string IDs
(widgetValueStore/WidgetId, layoutStore, nodeOutputStore, domWidgetStore,
subgraphNavigationStore, previewExposureStore), not one unified World
registry addressed by branded entity IDs.

- AGENTS.md + .agents/checks/adr-compliance.md: point entity-data guidance at
  dedicated stores; fix the inverted world.getComponent check
- ADR 0008: amendment note; rewrite World section, Branded ID design
  (WidgetId composite string), migration strategy, render-loop, consequences
- proto-ecs-stores: flip the "Unified World / branded IDs" gap framing to the
  store target; replace deleted PromotedWidgetViewManager with the
  input.widgetId model; fix key formats and store count
- target-architecture / lifecycle-scenarios / migration-plan: reframe all
  world.* APIs and *EntityId brands to per-store APIs and string keys; mark
  shipped phases done
- subgraph-boundaries / entity-interactions / entity-problems: scope-tagged
  store entries; swap removed PromotionStore for previewExposureStore
- appendix-critical-analysis: post-pivot status + resolution notes
- appendix-ecs-pattern-survey: supersede banner, keep the library survey
- delete obsolete ecs-world-command-api.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:13:32 -07:00
13 changed files with 726 additions and 1110 deletions

View File

@@ -33,15 +33,15 @@ Flag:
- **New circular entity dependencies** — New circular imports between `LGraph``Subgraph`, `LGraphNode``LGraphCanvas`, or similar entity classes.
- **Direct `graph._version++`** — Mutating the private version counter directly instead of through a public API. Extensions already depend on this side-channel; it must become a proper API.
### Centralized Registries and ECS-Style Access
### Dedicated Stores and Data/Behavior Separation
All entity data access should move toward centralized query patterns, not instance property access.
Entity data lives in dedicated Pinia stores keyed by string IDs (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, `previewExposureStore`), not on entity instances.
Flag:
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that should be a component in the World, queried via `world.getComponent(entityId, ComponentType)`.
- **New instance method/property patterns** — Adding `node.someProperty` or `node.someMethod()` for data that belongs in a dedicated store (e.g. widget values → `widgetValueStore` keyed by `WidgetId`).
- **OOP inheritance for entity modeling** — Extending entity classes with new subclasses instead of composing behavior through components and systems.
- **Scattered state** — New entity state stored in multiple locations (class properties, stores, local variables) instead of being consolidated in the World or in a single store.
- **Duplicated authority** — Storing the same entity state in both a class property and a store, or across two stores, so ownership becomes ambiguous. Each piece of state should have one owning store.
### Extension Ecosystem Impact

View File

@@ -246,7 +246,7 @@ All architectural decisions are documented in `docs/adr/`. Code changes must be
### Entity Architecture Constraints (ADR 0003 + ADR 0008)
1. **Command pattern for all mutations**: Every entity state change must be a serializable, idempotent, deterministic command — replayable, undoable, and transmittable over CRDT. No imperative fire-and-forget mutation APIs. Systems produce command batches, not direct side effects.
2. **Centralized registries and ECS-style access**: Entity data lives in the World (centralized registry), queried via `world.getComponent(entityId, ComponentType)`. Do not add new instance properties/methods to entity classes. Do not use OOP inheritance for entity modeling.
2. **Dedicated stores over instance state**: Entity data lives in dedicated Pinia stores keyed by string IDs — widget values in `widgetValueStore` keyed by `WidgetId` (`graphId:nodeId:name`, see `src/types/widgetId.ts`), plus `domWidgetStore`, `layoutStore`, `nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`. Prefer a focused store to a single unified registry. Do not add new instance properties/methods to entity classes for data that belongs in a store. Do not use OOP inheritance for entity modeling.
3. **No god-object growth**: Do not add methods to `LGraphNode`, `LGraphCanvas`, `LGraph`, or `Subgraph`. Extract to systems, stores, or composables.
4. **Plain data components**: ECS components are plain data objects — no methods, no back-references to parent entities. Behavior belongs in systems (pure functions).
5. **Extension ecosystem impact**: Changes to entity callbacks (`onConnectionsChange`, `onRemoved`, `onAdded`, `onConnectInput/Output`, `onConfigure`, `onWidgetChanged`), `node.widgets` access, `node.serialize`, or `graph._version++` affect 40+ custom node repos and require migration guidance.

View File

@@ -6,6 +6,21 @@ Date: 2026-03-23
Proposed
### Amendment (2026-06-19, PR 12617)
The single central registry this ADR calls the "World" was superseded during
implementation. Runtime entity data is held in dedicated Pinia stores keyed by
string IDs — `widgetValueStore`, `domWidgetStore`, `layoutStore`,
`nodeOutputStore`, `subgraphNavigationStore`, and `previewExposureStore`.
Widget values are keyed by `WidgetId` (`graphId:nodeId:name`, see
`src/types/widgetId.ts`); the `world/*` layer (`widgetValueIO`, `entityIds`,
`brand`, `WidgetEntityId`) was deleted. The ECS principles below still hold —
plain-data components, separation of data from behavior, command-driven
mutation, and no god-object growth — realized across those stores. Where the
text below says "the World," read "the set of dedicated stores"; where it shows
`world.getComponent(id, Component)`, read the matching store getter (for
example `widgetValueStore.getWidget(widgetId)`).
## Context
The litegraph layer is built on deeply coupled OOP classes (`LGraphNode`, `LLink`, `Subgraph`, `BaseWidget`, `Reroute`, `LGraphGroup`, `SlotBase`). Each entity directly references its container and children — nodes hold widget arrays, widgets back-reference their node, links reference origin/target node IDs, subgraphs extend the graph class, and so on.
@@ -40,7 +55,7 @@ Six entity kinds, each with a branded ID type:
| ----------- | ------------------------------------------------- | --------------------------- | ----------------- |
| Node | `LGraphNode` | `NodeId = number \| string` | `NodeEntityId` |
| Link | `LLink` | `LinkId = number` | `LinkEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetEntityId` |
| Widget | `BaseWidget` subclasses (25+) | name + parent node | `WidgetId` |
| Slot | `SlotBase` / `INodeInputSlot` / `INodeOutputSlot` | index on parent node | `SlotEntityId` |
| Reroute | `Reroute` | `RerouteId = number` | `RerouteEntityId` |
| Group | `LGraphGroup` | `number` | `GroupEntityId` |
@@ -54,7 +69,6 @@ Each entity kind gets a nominal/branded type wrapping its underlying primitive.
```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' }
@@ -63,7 +77,12 @@ type GroupEntityId = number & { readonly __brand: 'GroupEntityId' }
type GraphId = string & { readonly __brand: 'GraphId' }
```
Widgets and Slots currently lack independent IDs. The ECS will assign synthetic IDs at entity creation time via an auto-incrementing counter (matching the pattern used by `lastNodeId`, `lastLinkId`, etc. in `LGraphState`).
> **Amended (PR 12617):** Widgets are keyed by a branded composite **string**,
> `WidgetId = graphId:nodeId:name` (`src/types/widgetId.ts`), rather than a
> synthetic numeric `WidgetEntityId`. The composite stays self-documenting and
> survives renames at the store layer. The numeric per-kind brands above for
> Node/Link/Reroute/Group remain aspirational and unshipped; treat them as
> design intent. Slots have no independent ID yet.
### Component Decomposition
@@ -139,18 +158,24 @@ A node carrying a subgraph gains these additional components. Subgraphs are not
| `GroupVisual` | `color` |
| `GroupChildren` | child entity refs (nodes, reroutes) |
### World
### Dedicated stores
A central registry (the "World") maps entity IDs to their component sets. One
World exists per workflow instance, containing all entities across all nesting
levels. Each entity carries a `graphScope` identifier linking it to its
containing graph. The World also maintains a scope registry mapping each
`graphId` to its parent (or null for the root graph).
Component data lives in a set of dedicated Pinia stores, each owning one
concern and keyed by a string ID that embeds its graph scope (for example
`widgetValueStore` keyed by `WidgetId = graphId:nodeId:name`, `layoutStore`
keyed by `nodeId`/`linkId`/`rerouteId`, `nodeOutputStore` keyed by
`subgraphId:nodeId`). Each store provides a clear-by-graph lifecycle hook
(`clearGraph(graphId)`) and query helpers. A scope registry maps each `graphId`
to its parent (or null for the root graph).
> The original design centralized this in one "World" registry per workflow
> instance; PR 12617 replaced that with the dedicated stores above. The
> remainder of this section describes scoping, which applies per store.
The "single source of truth" claim in this ADR is scoped to one workflow
instance. In a future linked-subgraph model, shared definitions can be loaded
into multiple workflow instances, but mutable runtime components
(`WidgetValue`, execution state, selection, transient layout caches) remain
instance, per concern. In a future linked-subgraph model, shared definitions
can be loaded into multiple workflow instances, but mutable runtime state
(widget values, execution state, selection, transient layout caches) remains
instance-scoped unless explicitly declared shareable.
### Subgraph recursion model
@@ -166,7 +191,7 @@ queries by `graphScope`.
### Systems (future work)
Systems are pure functions that query the World for entities with specific component combinations. Initial candidates:
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Initial candidates:
- **RenderSystem** — queries `Position` + `Dimensions` (where present) + `*Visual` components
- **SerializationSystem** — queries all components to produce/consume workflow JSON
@@ -178,25 +203,23 @@ System design is deferred to a future ADR. For detailed before/after walkthrough
### Migration Strategy
1. **Define types**branded IDs, component interfaces, World type in a new `src/ecs/` directory
2. **Bridge layer** — adapter functions that read ECS components from existing class instances (zero-copy where possible)
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on ECS components rather than class properties
4. **Incremental extraction** — migrate one component at a time from classes to the World, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the World, mark class properties as deprecated
1. **Define types**string-key ID types (for example `WidgetId`) and plain-data component interfaces, owned by the store for each concern
2. **Bridge layer** — adapter functions that read component data from existing class instances (zero-copy where possible)
3. **New features first** — any new cross-cutting feature (e.g., CRDT sync) builds on store-backed components rather than class properties
4. **Incremental extraction** — migrate one component at a time from classes into its dedicated store, using the bridge layer for backward compatibility
5. **Deprecate class properties** — once all consumers read from the store, mark class properties as deprecated
For the phased migration roadmap with shipping milestones, see [ECS Migration Plan](../architecture/ecs-migration-plan.md). For the full target architecture, see [ECS Target Architecture](../architecture/ecs-target-architecture.md). For an inventory of existing stores that already partially implement ECS patterns, see [Proto-ECS Stores](../architecture/proto-ecs-stores.md).
### Relationship to ADR 0003 (Command Pattern / CRDT)
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the World store. They are complementary architectural layers:
[ADR 0003](0003-crdt-based-layout-system.md) establishes that all mutations flow through serializable, idempotent commands. This ADR (0008) defines the entity data model and the dedicated stores that hold it. They are complementary architectural layers:
- **Commands** (ADR 0003) describe mutation intent — serializable objects that can be logged, replayed, sent over a wire, or undone.
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the World.
- **The World** (ADR 0008) is the store — it holds component data. It does not know about commands.
- **Systems** (ADR 0008) are command handlers — they validate and execute mutations against the relevant stores.
- **The dedicated stores** (ADR 0008) hold component data and expose mutation APIs (for example `useLayoutMutations()`, `widgetValueStore.setValue`); each owns its own transaction boundary.
The World's imperative API (`setComponent`, `deleteEntity`, etc.) is internal. External callers submit commands; the command executor wraps each in a World transaction. This is analogous to Redux: the store's internal mutation is imperative, but the public API is action-based.
For the full design showing how each lifecycle scenario maps to a command, see [World API and Command Layer](../architecture/ecs-world-command-api.md).
A store's imperative mutators are internal implementation. External callers submit commands; each mutating store wraps its writes in a transaction (the Y.js-backed `layoutStore` already does this). This follows Redux: internal mutation is imperative, while the public API is action-based.
### Alternatives Considered
@@ -210,26 +233,26 @@ For the full design showing how each lifecycle scenario maps to a command, see [
- Cross-cutting concerns (undo/redo, CRDT sync, serialization) can be implemented as systems without modifying entity classes
- Components are independently testable — no need to construct an entire `LGraphNode` to test position logic
- Branded IDs prevent a class of bugs where IDs are accidentally used across entity kinds
- The World provides a single source of truth for runtime entity state inside a workflow instance, simplifying debugging and state inspection
- Branded IDs (including the composite `WidgetId` string) prevent a class of bugs where IDs are accidentally used across entity kinds
- Each dedicated store provides a single source of truth for its concern inside a workflow instance, simplifying debugging and state inspection
- Aligns with the CRDT layout system direction from ADR 0003
### Negative
- Additional indirection: reading a node's position requires a World lookup instead of `node.pos`
- Additional indirection: reading a node's position requires a store lookup instead of `node.pos`
- Learning curve for contributors unfamiliar with ECS patterns
- Migration period where both OOP and ECS patterns coexist, increasing cognitive load
- Widgets and Slots need synthetic IDs, adding ID management complexity
### Render-Loop Performance Implications and Mitigations
Replacing direct property reads (`node.pos`) with component lookups (`world.getComponent(nodeId, Position)`) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
Replacing direct property reads (`node.pos`) with store lookups (for example `layoutStore` position reads) does add per-read overhead in the hot render path. In modern JS engines, hot `Map.get()` paths are heavily optimized and are often within a low constant factor of object property reads, but this ADR treats render-loop cost as a first-class risk rather than assuming it is free.
Planned mitigations for the ECS render path:
1. Pre-collect render queries into frame-stable caches (`visibleNodeIds`, `visibleLinkIds`, and resolved component references) and rebuild only on topology/layout dirty signals, not on every draw call.
2. Keep archetype-style buckets for common render signatures (for example: `Node = Position+Dimensions+NodeVisual`, `Reroute = Position+RerouteVisual`) so systems iterate arrays instead of probing unrelated entities.
3. Allow a hot-path storage upgrade behind the World API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
3. Allow a hot-path storage upgrade behind a store's API (for example, SoA-style typed arrays for `Position` and `Dimensions`) if profiling shows `Map.get()` dominates frame time.
4. Gate migration of each render concern with profiling parity checks against the legacy path (same workflow, same viewport, same frame budget).
5. Treat parity as a release gate: ECS render path must stay within agreed frame-time budgets (for example, no statistically significant regression in p95 frame time on representative 200-node and 500-node workflows).
@@ -247,7 +270,6 @@ Companion architecture documents that expand on the design in this ADR:
| [ECS Target Architecture](../architecture/ecs-target-architecture.md) | Full target architecture showing how entities and interactions transform under ECS |
| [ECS Migration Plan](../architecture/ecs-migration-plan.md) | Phased migration roadmap with shipping milestones and go/no-go criteria |
| [ECS Lifecycle Scenarios](../architecture/ecs-lifecycle-scenarios.md) | Before/after walkthroughs of lifecycle operations (node removal, link creation, etc.) |
| [World API and Command Layer](../architecture/ecs-world-command-api.md) | How each lifecycle scenario maps to a command in the World API |
| [Subgraph Boundaries and Widget Promotion](../architecture/subgraph-boundaries-and-promotion.md) | Design rationale for modeling subgraphs as node components, not separate entities |
| [ADR 0009: Subgraph promoted widgets](0009-subgraph-promoted-widgets-use-linked-inputs.md) | Follow-up decision for promoted widget identity and value ownership at subgraph boundaries |
| [Appendix: Critical Analysis](../architecture/appendix-critical-analysis.md) | Independent verification of the accuracy of the architecture documents |
@@ -258,5 +280,5 @@ Companion architecture documents that expand on the design in this ADR:
- The 25+ widget types (`BooleanWidget`, `NumberWidget`, `ComboWidget`, etc.) will share the same ECS component schema. Widget-type-specific behavior lives in systems, not in component data.
- Subgraphs are not a separate entity kind. A `GraphId` scope identifier (branded `string`) tracks which graph an entity belongs to. The scope DAG must be acyclic — see [Subgraph Boundaries](../architecture/subgraph-boundaries-and-promotion.md).
- The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters extend naturally to `lastWidgetId` and `lastSlotId`.
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the flat World and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.
- Widgets are addressed by the composite `WidgetId` string, so they need no synthetic counter. The existing `LGraphState.lastNodeId` / `lastLinkId` / `lastRerouteId` counters cover the kinds that have numeric IDs.
- The internal ECS model and the serialization format are deliberately separate concerns. The `SerializationSystem` translates between the store-backed component data and the nested serialization format. Backward-compatible loading of all prior workflow formats is a hard, indefinite constraint.

View File

@@ -2,6 +2,13 @@
_In which we examine the shadow material of a codebase in individuation, verify its self-reported symptoms, and note where the ego's aspirations outpace the psyche's readiness for transformation._
> **Post-pivot status (PR 12617).** This analysis was written against the
> single-World ECS target. The project has since chosen dedicated Pinia stores
> over one unified World, which acts on several of the concerns raised below.
> Resolution notes are inlined where the pivot answers a critique; the still-open
> gaps (extension-callback continuity, atomicity/undo, Y.js ↔ ECS coexistence)
> remain live. Verification snapshots predate PR 12617.
---
## I. On the Accuracy of Self-Diagnosis
@@ -15,7 +22,7 @@ The god-objects are as large as claimed. `LGraphCanvas` contains 9,094 lines —
Some thirty specific line references were verified against the living code. The `renderingColor` getter sits precisely at line 328. The `drawNode()` method begins exactly at line 5554, and within it, at lines 5562 and 5564, the render pass mutates state — `_setConcreteSlots()` and `arrange()` — just as the documents confess. The scattered `_version++` increments appear at every claimed location across all three files. The module-scope store invocations in `LLink.ts:24` and `Reroute.ts:23` are exactly where indicated.
The stores — all six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects. The `PromotionStore` does maintain its ref-counted maps. The `LayoutStore` does wrap Y.js CRDTs.
The stores — six of them — exist at their stated paths with their described APIs. The `WidgetValueStore` does indeed hold plain `WidgetState` objects, keyed by `WidgetId`. The `LayoutStore` does wrap Y.js CRDTs. (The `PromotionStore` named in the original snapshot was removed by PR 12617; promoted value state now lives in `WidgetValueStore`, and `PreviewExposureStore` holds host-scoped preview exposures.)
This level of factual accuracy — 28 out of 30 sampled citation checks
(93.3%) — is, one might say, the work of a consciousness that has genuinely
@@ -47,6 +54,12 @@ This is the individuation dream: the fragmented psyche imagines itself unified,
It is a beautiful vision. It is also, in several respects, a fantasy that has not yet been tested against reality.
> **Resolved (PR 12617).** The single World was set aside. The project keeps the
> fragments deliberately apart — dedicated stores, each holding one concern and
> keyed by its own string identity. The integration the dream sought lives in the
> shared discipline (plain-data components, command-driven mutation), with the
> stores standing on their own.
### The Line-Count Comparisons
The lifecycle scenarios compare current implementations against projected ECS equivalents:
@@ -93,6 +106,12 @@ But one must be careful not to mistake diversity for disorder. Some of these com
The documents present branded IDs as an unqualified improvement. They are an improvement in _type safety_. Whether they are an improvement in _comprehensibility_ depends on whether the system provides good lookup APIs. The analysis would benefit from acknowledging this tradeoff rather than presenting it as a pure gain.
> **Resolved (PR 12617).** The pivot honored this concern. `WidgetValueStore`
> keeps the self-documenting composite as its key, branded as a string
> (`WidgetId = graphId:nodeId:name`, `src/types/widgetId.ts`), gaining cross-kind
> safety while preserving the structural meaning the synthetic integer would have
> shed.
## V. On the Subgraph: The Child Who Contains the Parent
The documents describe the `Subgraph extends LGraph` relationship as a circular dependency. This is technically accurate and architecturally concerning. But it is also, symbolically, the most interesting structure in the entire system.
@@ -115,13 +134,13 @@ This is sound. The documents would benefit from being equally realistic about th
### Factual Corrections Required
| Document | Error | Correction |
| --------------------- | ---------------------------------- | ---------------------------------- |
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | `reconcile()` / `getOrCreate()` |
| Document | Error | Correction |
| --------------------- | ---------------------------------- | ----------------------------------------------------- |
| `entity-problems.md` | `toJSON() (line 1033)` | `toString() (line 1033)` |
| `entity-problems.md` | `execute() (line 1418)` | `doExecute() (line 1411)` |
| `entity-problems.md` | `~539 method/property definitions` | ~848; methodology should be stated |
| `entity-problems.md` | `configure()` ~180 lines | ~247 lines |
| `proto-ecs-stores.md` | `resolveDeepest()` in diagram | moot — `PromotedWidgetViewManager` removed (PR 12617) |
### Analytical Gaps
@@ -129,7 +148,7 @@ This is sound. The documents would benefit from being equally realistic about th
2. **Atomicity guarantees** are claimed but not mechanically specified.
3. **Y.js / ECS coexistence** is an open architectural question the documents do not engage.
4. **ECS line-count projections** are aspirational and should be marked as estimates.
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything."
5. **Composite key tradeoffs** deserve more nuance than "branded IDs fix everything." _(Resolved by PR 12617: `WidgetId` keeps the composite as a branded string.)_
### What the Documents Do Well

View File

@@ -1,20 +1,24 @@
# Appendix: ECS Pattern Survey
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, Thyseus, and Bevy — captured during the world-consolidation
analysis that shipped slice 1 of
[ADR 0008](../adr/0008-entity-component-system.md). This appendix records
which structural patterns our `src/world/` substrate adopts, which it
deliberately departs from, and where the trade-offs are load-bearing rather
than incidental. Thyseus is called out specifically because it is the most
Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is the
closest external analog to the command layer ADR 0003 / ADR 0008 are
converging on, so it gets dedicated treatment in §2.5 and §3.5._
> **Superseded (PR 12617).** The single `src/world/` substrate this appendix
> analyzes was removed; the project adopted dedicated Pinia stores
> (`widgetValueStore`, `domWidgetStore`, `layoutStore`, `nodeOutputStore`,
> `subgraphNavigationStore`, `previewExposureStore`) keyed by string IDs. §1
> (the external library survey) remains valid reference material and supports
> the dedicated-store direction — its first unanimous finding, that components
> live with the code that owns them, is exactly what per-domain stores do. §2§4
> describe the deleted `src/world/` substrate (`world.ts`, `entityIds.ts`,
> `widgetComponents.ts`, `WidgetEntityId`) and are retained for historical
> rationale only; read their references to "the World" as "the relevant
> dedicated store."
The in-code anchors for the load-bearing constraints discussed below are the
doc-comments in [src/world/world.ts](../../src/world/world.ts) (storage
strategy) and [src/world/entityIds.ts](../../src/world/entityIds.ts) (identity
contract) — see §3 below.
_A survey of mainstream Entity Component System libraries — bitECS, miniplex,
koota, ECSY, Thyseus, and Bevy. This appendix records which structural patterns
the surveyed libraries share, which the project departs from, and where the
trade-offs carry weight. Thyseus is called out specifically because it is the
most Bevy-shaped of the TypeScript ECSs surveyed — its `Commands` parameter is
the closest external analog to the command layer ADR 0003 / ADR 0008 converge
on, so it gets dedicated treatment in §2.5 and §3.5._
---
@@ -49,9 +53,9 @@ Two structural patterns are unanimous across the surveyed libraries:
because it commits to a full system-execution runtime, not just
storage.
Our slice-1 end state — five source files under
[src/world/](../../src/world/), ~14 exported names total — sits squarely in
this band.
The dedicated-store end state — each store a small, focused module keyed by a
string ID — sits squarely in this band: a small surface per store, with
component shapes defined next to the store that owns them.
---
@@ -141,12 +145,11 @@ export function spawnEntities(commands: Commands) {
```
`commands.spawn()`, `.add(component)`, and `.remove(component)` enqueue
deferred mutations against a command buffer; the World applies them at
deferred mutations against a command buffer; the substrate applies them at
defined sync points in the schedule. This is the same shape Bevy uses
and is the closest direct external analog to the mutation layer
[ADR 0003](../adr/0003-crdt-based-layout-system.md) and the
[World API and Command Layer](./ecs-world-command-api.md) describe for
this codebase.
and is the closest direct external analog to the per-store mutation layer
[ADR 0003](../adr/0003-crdt-based-layout-system.md) describes for this
codebase (realized as store mutation APIs such as `useLayoutMutations()`).
We deliberately match the **shape** of this pattern: external callers
submit commands; only the executor calls the World's imperative
@@ -172,8 +175,8 @@ yet:
The point of calling Thyseus out separately is that when ADR 0008 lands
its command executor slice, "what does this look like in Thyseus?" is a
load-bearing comparison point — not a curiosity. Diverging from the
Bevy/Thyseus shape there should require an explicit justification, not
comparison point worth taking seriously. Diverging from the
Bevy/Thyseus shape there should require an explicit justification rather than
silent drift.
---
@@ -181,7 +184,7 @@ silent drift.
## 3. Patterns We Explicitly Do NOT Adopt
Each of the following is a real industry idiom we considered and rejected
on load-bearing grounds. None of these are pure performance trade-offs.
on structural grounds. None of these are pure performance trade-offs.
### 3.1 Replace-on-write usage idioms
@@ -215,8 +218,8 @@ SoA storage spreads each component's fields across parallel typed arrays,
so the per-entity "row object" is reconstructed on read. **A future
migration to SoA would lose the proxy on the row object** — and with it
the shared-reactive-identity contract that `BaseWidget._state` and the
`widgetValueStore` facade rely on. This is a load-bearing constraint, not
just a perf optimization decision.
`widgetValueStore` facade rely on. This constraint carries real weight
beyond a perf optimization decision.
The contract is pinned in the doc-comment at the top of
[src/world/world.ts](../../src/world/world.ts) — copied here for
@@ -261,7 +264,7 @@ The contract is pinned in the doc-comment at the top of
* Entity IDs are deterministic, content-addressed, and string-prefix
* encoded — NOT opaque numeric IDs (cf. bitECS, koota, miniplex).
*
* `widgetEntityId(rootGraphId, nodeId, name)` is load-bearing:
* `widgetEntityId(rootGraphId, nodeId, name)` carries real weight:
* consumers consistently pass `rootGraph.id` so widgets viewed at
* different subgraph depths share identity. Migrating to numeric IDs
* would break cross-subgraph value sharing. See ADR 0008 and

View File

@@ -2,7 +2,9 @@
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](../adr/0008-entity-component-system.md).
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.
ECS principles are realized across a set of dedicated Pinia stores keyed by string IDs (shipped in PR 12617): `widgetValueStore` (keyed by `WidgetId` = `graphId:nodeId:name`, see `src/types/widgetId.ts`), `layoutStore` (mutated via `useLayoutMutations()`), `nodeOutputStore`, `domWidgetStore`, `subgraphNavigationStore`, and `previewExposureStore`. Components live as plain-data entries in these stores; systems read and mutate them through store getters and command-style mutations.
Each scenario follows the same structure: **Current Flow** (what happens today), **ECS Flow** (the store-backed target), and a **Key Differences** table.
## 1. Node Removal
@@ -63,47 +65,43 @@ Problems: the graph method manually disconnects every slot, cleans up reroutes,
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
participant LM as useLayoutMutations()
participant LS as layoutStore
participant WVS as widgetValueStore
participant NOS as nodeOutputStore
participant DWS as domWidgetStore
Caller->>CS: removeNode(world, nodeId)
Caller->>CS: removeNode(nodeId)
CS->>W: getComponent(nodeId, Connectivity)
W-->>CS: { inputSlotIds, outputSlotIds }
CS->>LS: read node links (incoming + outgoing)
LS-->>CS: linkIds
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)
loop each linkId
CS->>LM: deleteLink(linkId)
Note over LM,LS: removes link entry +<br/>updates both slot endpoints
end
CS->>W: getComponent(nodeId, WidgetContainer)
W-->>CS: { widgetIds }
loop each widgetId
CS->>W: deleteEntity(widgetId)
loop each widget on node
CS->>WVS: deleteWidget(widgetId)
CS->>DWS: unregisterWidget(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()
CS->>NOS: removeNodeOutputs(nodeId)
CS->>LM: deleteNode(nodeId)
Note over CS,LS: coordinated cleanup across stores —<br/>each store drops its entry for the node
```
### 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 |
| Aspect | Current | ECS |
| ------------------- | ------------------------------------------------ | ----------------------------------------------------------------------------------------------- |
| Lines of code | ~107 in one method | ~30 in system function |
| Entity types known | Graph knows about all 6+ types | ConnectivitySystem coordinates layoutStore + widget/output stores |
| Cleanup | Manual per-slot, per-link, per-reroute | `deleteLink()`/`deleteNode()` mutations per layout entry |
| Canvas notification | `setDirtyCanvas()` called explicitly | Vue reactivity: components re-render when store entries change |
| Store cleanup | WidgetValueStore/LayoutStore NOT cleaned up | Coordinated: `deleteWidget`, `deleteLink`/`deleteNode`, `removeNodeOutputs`, `unregisterWidget` |
| Undo/redo | `beforeChange()`/`afterChange()` manually placed | Layout mutations are command records, replayable and undoable |
| Testability | Needs full LGraph + LGraphCanvas | Needs only the relevant stores + ConnectivitySystem |
## 2. Serialization
@@ -165,41 +163,37 @@ Problems: serialization logic lives in 6 different `serialize()` methods across
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
participant LS as layoutStore
participant WVS as widgetValueStore
participant CLS as node class state
Caller->>SS: serialize(world)
Caller->>SS: serialize(graphId)
SS->>W: queryAll(NodeType, Position, Properties, WidgetContainer, Connectivity)
W-->>SS: all node entities with their components
SS->>LS: read node layouts (position, size, z-index)
LS-->>SS: layout entries for graphId
SS->>W: queryAll(LinkEndpoints)
W-->>SS: all link entities
SS->>LS: read links + reroutes for graphId
LS-->>SS: link / reroute entries
SS->>W: queryAll(SlotIdentity, SlotConnection)
W-->>SS: all slot entities
SS->>WVS: getWidget(widgetId) per node widget
WVS-->>SS: WidgetState values
SS->>W: queryAll(RerouteLinks, Position)
W-->>SS: all reroute entities
SS->>CLS: read type / properties / flags
CLS-->>SS: per-node class data
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->>SS: assemble JSON from store entries + class state
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 |
| Aspect | Current | ECS |
| ---------------------- | ----------------------------------------------- | --------------------------------------------------------- |
| Serialization logic | Spread across 6 classes (`serialize()` on each) | Single SerializationSystem reading the stores |
| Widget values | Collected inline during `node.serialize()` | `widgetValueStore.getWidget(widgetId)` read directly |
| Subgraph recursion | `asSerialisable()` recursively calls itself | Flat read — layout entries carry scope tags, no recursion |
| Adding a new component | Modify the entity's `serialize()` method | Read one more store in SerializationSystem |
| Testing | Need full object graph to test serialization | Seed the stores with test entries |
## 3. Deserialization
@@ -274,64 +268,48 @@ Problems: two-phase creation is necessary because nodes need to reference each o
sequenceDiagram
participant Caller
participant SS as SerializationSystem
participant W as World
participant LS as LayoutSystem
participant LM as useLayoutMutations()
participant WVS as widgetValueStore
participant ES as ExecutionSystem
Caller->>SS: deserialize(world, data)
Caller->>SS: deserialize(graphId, data)
SS->>W: clear() [remove all entities]
SS->>WVS: clearGraph(graphId)
Note over SS,WVS: drop stale widget entries for this graph
Note over SS,W: All entities created in one pass — no two-phase needed
Note over SS,LM: All entries 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, {...})
SS->>LM: createNode(nodeId, { position, size, ... })
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, {...})
SS->>LM: createLink(linkId, source, target)
end
Note over SS,W: Connectivity assembled from slot/link components
Note over SS,LM: links reference node + slot IDs directly,<br/>no instance resolution needed
loop each widget in data
SS->>W: createEntity(WidgetEntityId)
SS->>W: setComponent(id, WidgetIdentity, {...})
SS->>W: setComponent(id, WidgetValue, {...})
SS->>WVS: registerWidget(widgetId, { value, ... })
end
SS->>SS: create reroutes, groups, subgraphs similarly
SS->>SS: create reroutes, groups via layout mutations;<br/>subgraph scopes tagged on entries
Note over SS,W: Systems react to populated World
Note over SS,ES: Systems read the populated stores
SS->>LS: runLayout(world)
SS->>ES: computeExecutionOrder(world)
SS->>ES: computeExecutionOrder(graphId)
```
### 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 |
| Two-phase creation | Required (nodes must exist before link resolution) | Not needed — links reference string IDs, not instances |
| Widget restoration | Hidden inside `node.configure()` line ~900 | Explicit: `widgetValueStore.registerWidget(widgetId, state)` |
| Store population | Side effect of `widget.setNodeId()` | Direct: writing the store entry is the population |
| Callback cascade | `onConnectionsChange`, `onInputAdded`, `onConfigure` fire during configure | No callbacks — systems read the stores after deserialization |
| Subgraph ordering | Topological sort required | Flat write — scope tags on entries, no instance ordering |
| Error handling | Failed node → placeholder with `has_errors=true` | Failed entry → skip; entries that loaded are still valid |
## 4. Pack Subgraph
@@ -394,50 +372,50 @@ Problems: 200+ lines in one method. Manual boundary link analysis. Clone-seriali
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant LS as layoutStore
participant LM as useLayoutMutations()
participant SNS as subgraphNavigationStore
Caller->>CS: packSubgraph(world, selectedEntityIds)
Caller->>CS: packSubgraph(selectedNodeIds)
CS->>W: query Connectivity + SlotConnection for selected nodes
CS->>LS: read links for selected nodes
CS->>CS: classify links as internal vs boundary
CS->>W: create new GraphId scope in scopes registry
CS->>SNS: register new subgraph graphId
Note over CS,W: Create SubgraphNode entity in parent scope
Note over CS,LM: Create SubgraphNode layout entry in parent graph
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' })
CS->>LM: createNode(subgraphNodeId, { position: center of selection })
CS->>CS: record SubgraphNode interface (boundary slots)
Note over CS,W: Re-parent selected entities into new graph scope
Note over CS,LS: Re-tag selected entries into new graph scope
loop each selected entity
CS->>W: update graphScope to new graphId
loop each selected node + link
CS->>LS: set graphId scope tag to new subgraph graphId
end
Note over CS,W: Create boundary slots on SubgraphNode
Note over CS,LM: Reconnect boundary links to SubgraphNode slots
loop each boundary input link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to target new slot
CS->>LM: deleteLink(oldLinkId)
CS->>LM: createLink(newLinkId, source, subgraphNode input slot)
end
loop each boundary output link
CS->>W: create SlotEntity on SubgraphNode
CS->>W: update LinkEndpoints to source from new slot
CS->>LM: deleteLink(oldLinkId)
CS->>LM: createLink(newLinkId, subgraphNode output slot, target)
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 |
| Aspect | Current | ECS |
| -------------------------- | ------------------------------------------------- | ------------------------------------------------------------ |
| Entity movement | Clone → serialize → configure → remove originals | Re-tag entries: change graphId scope tag on store entries |
| Boundary links | Disconnect → remove → recreate → reconnect | `deleteLink`/`createLink` against the new SubgraphNode slots |
| Intermediate inconsistency | Graph is partially disconnected during operation | Mutations batch together as one command sequence |
| Code size | 200+ lines | ~50 lines in system |
| Undo | `beforeChange()`/`afterChange()` wraps everything | Layout mutation commands replay and undo as a batch |
## 5. Unpack Subgraph
@@ -496,48 +474,49 @@ Problems: ID remapping is complex and error-prone. Magic IDs (SUBGRAPH_INPUT_ID
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant LS as layoutStore
participant LM as useLayoutMutations()
participant SNS as subgraphNavigationStore
Caller->>CS: unpackSubgraph(world, subgraphNodeId)
Caller->>CS: unpackSubgraph(subgraphNodeId)
CS->>W: getComponent(subgraphNodeId, SubgraphStructure)
W-->>CS: { graphId, interface }
CS->>CS: read SubgraphNode interface (boundary slots)
CS->>W: query entities where graphScope = graphId
W-->>CS: all child entities (nodes, links, reroutes, etc.)
CS->>LS: query entries where graphId scope = subgraph graphId
LS-->>CS: child entries (nodes, links, reroutes)
Note over CS,W: Re-parent entities to containing graph scope
Note over CS,LS: Re-tag entries to containing graph scope
loop each child entity
CS->>W: update graphScope to parent scope
loop each child entry
CS->>LS: set graphId scope tag to parent scope
end
Note over CS,W: Reconnect boundary links
Note over CS,LM: Reconnect boundary links
loop each boundary slot in interface
CS->>W: getComponent(slotId, SlotConnection)
CS->>W: update LinkEndpoints: SubgraphNode slot → internal node slot
CS->>LM: deleteLink(boundaryLinkId)
CS->>LM: createLink(newLinkId, external slot → internal node slot)
end
CS->>W: deleteEntity(subgraphNodeId)
CS->>W: remove graphId from scopes registry
CS->>LM: deleteNode(subgraphNodeId)
CS->>SNS: drop subgraph graphId
Note over CS,W: Offset positions
Note over CS,LM: Offset positions
loop each moved entity
CS->>W: update Position component
loop each moved node
CS->>LM: moveNode(nodeId, position + offset)
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 |
| Aspect | Current | ECS |
| ----------------- | --------------------------------------------------- | ----------------------------------------------------------------- |
| ID remapping | `nodeIdMap[oldId] = newId` for every node and link | No remapping — entries keep their IDs, only the scope tag changes |
| Magic IDs | SUBGRAPH_INPUT_ID = -10, SUBGRAPH_OUTPUT_ID = -20 | No magic IDs — boundary modeled as SubgraphNode interface slots |
| Clone vs move | Clone nodes, assign new IDs, configure from scratch | Re-tag store entries between scopes |
| Link reconnection | Remap origin_id/target_id, create new LLink objects | `deleteLink`/`createLink` against the resolved endpoints |
| Complexity | ~200 lines with deduplication and reroute remapping | ~40 lines, no remapping needed |
## 6. Connect Slots
@@ -591,33 +570,28 @@ Problems: the source node orchestrates everything — it reaches into the graph'
sequenceDiagram
participant Caller
participant CS as ConnectivitySystem
participant W as World
participant VS as VersionSystem
participant LS as layoutStore
participant LM as useLayoutMutations()
Caller->>CS: connect(world, outputSlotId, inputSlotId)
Caller->>CS: connect(outputSlot, inputSlot)
CS->>W: getComponent(inputSlotId, SlotConnection)
CS->>LS: read input slot link
opt already connected
CS->>CS: removeLink(world, existingLinkId)
CS->>LM: deleteLink(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()
CS->>LM: createLink(linkId, {<br/> originNodeId, originSlotIndex,<br/> targetNodeId, targetSlotIndex, type<br/>})
Note over LM,LS: createLink updates both slot endpoints<br/>and emits a command record
```
### 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, ...)` |
| Orchestrator | Source node (reaches into graph, target, reroutes) | ConnectivitySystem (reads layoutStore) |
| Side effects | `_version++`, `setDirtyCanvas()`, `afterChange()`, callbacks | `createLink()` command — endpoints + change tracking included |
| Reroute handling | Manual: iterate chain, add linkId to each | Reroute entries updated via layout mutations |
| Slot mutation | Direct: `output.links.push()`, `input.link = id` | `createLink(linkId, ...)` updates both endpoints |
| Validation | `onConnectInput`/`onConnectOutput` callbacks on nodes | Validation system or guard function |
## 7. Copy / Paste
@@ -688,57 +662,61 @@ parent IDs all remapped independently. ~300 lines across multiple methods.
sequenceDiagram
participant User
participant CS as ClipboardSystem
participant W as World
participant LS as layoutStore
participant WVS as widgetValueStore
participant LM as useLayoutMutations()
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
User->>CS: copy(selectedNodeIds)
CS->>LS: snapshot layout entries (nodes, links, reroutes)
CS->>WVS: snapshot WidgetState for each widgetId
CS->>CB: store cross-store snapshot
end
rect rgb(40, 60, 40)
Note over User,CB: Paste
User->>CS: paste(world, position)
User->>CS: paste(position)
CS->>CB: retrieve snapshot
CS->>CS: generate ID remap table (old → new branded IDs)
CS->>CS: build ID remap table (old → new nodeId / WidgetId)
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
loop each node in snapshot
CS->>LM: createNode(newNodeId, remapped layout)
end
loop each link in snapshot
CS->>LM: createLink(newLinkId, remapped endpoints)
Note over CS,LM: node + slot refs remapped via table
end
loop each widget in snapshot
CS->>WVS: registerWidget(newWidgetId, WidgetState)
end
CS->>CS: offset all Position components to cursor
CS->>LM: batchMoveNodes(offset all 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 |
| Aspect | Current | ECS |
| -------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------- |
| Copy format | Clone → serialize → JSON (format depends on class) | Store-entry snapshot (uniform shape across stores) |
| ID remapping | Separate logic per entity type (nodes, reroutes, subgraphs, links) | One remap table applied to string keys (`nodeId`, `WidgetId`) |
| Paste reconstruction | `createNode()``add()``configure()``connect()` per node | `createNode`/`createLink`/`registerWidget` per entry (flat) |
| Subgraph handling | Recursive clone + UUID remap + deduplication | Snapshot carries scope tags; remap rewrites graphId keys |
| 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 |
| Benefit | Scenarios Where It Applies |
| ----------------------------- | ---------------------------------------------------------------------------------------------------- |
| **Batched operations** | Node Removal, Pack/Unpack — mutations apply together as one command sequence |
| **No scattered `_version++`** | All scenarios — layout mutation commands carry change tracking |
| **No callback cascades** | Deserialization, Connect — systems read the stores instead of firing callbacks |
| **Uniform ID handling** | Copy/Paste, Unpack — one remap table over string keys instead of per-type logic |
| **Coordinated cleanup** | Node Removal — `deleteWidget` + `deleteLink`/`deleteNode` + `removeNodeOutputs` + `unregisterWidget` |
| **No two-phase creation** | Deserialization — store entries reference string IDs, not instances |
| **Move instead of clone** | Pack/Unpack — entries keep their IDs, only the scope tag changes |
| **Testable in isolation** | All scenarios — seed the relevant stores, test one system |
| **Undo/redo for free** | All scenarios — layout mutation commands replay and undo |

View File

@@ -10,6 +10,14 @@ target architecture, see [ECS Target Architecture](ecs-target-architecture.md).
For verified accuracy of these documents, see
[Appendix: Critical Analysis](appendix-critical-analysis.md).
> **Target end-state (revised):** N dedicated Pinia stores keyed by composite
> string IDs, one store per concern (widget values, DOM widgets, layout, node
> outputs, subgraph navigation, preview exposure). The earlier "single unified
> World with branded numeric entity IDs and `getComponent`/`setComponent`" model
> was rejected. PR 12617 shipped the first stores against composite
> `graphId:nodeId:name` string keys (`WidgetId`). Phases below are reframed
> around dedicated stores; shipped work is marked ✅.
## Planning assumptions
- The bridge period is expected to span 2-3 release cycles.
@@ -23,36 +31,16 @@ For verified accuracy of these documents, see
Zero behavioral risk. Prepares the codebase for extraction without changing
runtime semantics. All items are independently shippable.
### 0a. Centralize version counter
### 0a. Centralize version counter ✅ Shipped
`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.
`LGraph.incrementVersion()` exists and is used everywhere. The counter is only
read for debug display in `LGraphCanvas.renderInfo()`; it is not used for
dirty-checking, caching, or reactivity.
**Change:** Add `LGraph.incrementVersion()` and replace all 19 direct
increments.
**Remaining cleanup:** One stray direct `_version++` at `LGraph.ts:831` should
be replaced with `incrementVersion()`.
```
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.
**Risk:** None. Mechanical one-line change; existing null guards preserved.
### 0b. Add missing ID type aliases
@@ -79,246 +67,198 @@ Five factual errors verified during code review (see
- `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
## Phase 1: Types and Dedicated Stores
Introduces the ECS type vocabulary and an empty World. No migration of existing
code — new types coexist with old ones.
Introduces the ID type vocabulary and the dedicated stores. Phase 1 end-state is
N dedicated Pinia stores, each keyed by a composite string ID, coexisting with
legacy class instances.
### 1a. Branded entity ID types
### 1a. Branded string ID types ✅ Shipped (PR 12617)
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:
`src/types/widgetId.ts` ships the branded string `WidgetId`:
```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
}
type WidgetId = string & { readonly __brand: 'WidgetId' }
```
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.
Format: `graphId:nodeId:name`. A `parseWidgetId()` helper splits a `WidgetId`
back into its `{ graphId, nodeId, name }` parts at store boundaries.
World scope is per workflow instance. Linked subgraph definitions can be reused
The composite string key carries the structural relationship (graph -> node ->
widget) directly in the key. There is no synthetic opaque number and no reverse
lookup index.
**Consideration:** `NodeId = number | string`. The `string` branch exists for
subgraph-related nodes (GroupNode hack). The `WidgetId` format stringifies the
`nodeId` segment, so both numeric and string node IDs flow through unchanged.
### 1b. Plain-data store state shapes
Each dedicated store holds plain-data records for its concern — no methods on the
records, behavior lives in store actions and composables. State shapes mirror the
decomposition in ADR 0008 Section "Component Decomposition" (position, node type,
node visual, connectivity, execution, properties, widget container, link
endpoints).
**Risk:** None. Type-only definitions.
### 1c. Dedicated stores
Phase 1 end-state is a set of dedicated Pinia stores, one per concern, each
keyed by its own composite string ID. Each store owns its data and exposes a
narrow accessor surface. There is no single container that fronts all entities.
Shipped stores:
| Store | File |
| ------------------------- | ----------------------------------------------- |
| `widgetValueStore` | `src/stores/widgetValueStore.ts` |
| `domWidgetStore` | `src/stores/domWidgetStore.ts` |
| `layoutStore` | `src/renderer/core/layout/store/layoutStore.ts` |
| `nodeOutputStore` | `src/stores/nodeOutputStore.ts` |
| `subgraphNavigationStore` | `src/stores/subgraphNavigationStore.ts` |
| `previewExposureStore` | `src/stores/previewExposureStore.ts` |
`widgetValueStore` exposes `registerWidget`, `getWidget`, `setValue`,
`deleteWidget`, `getNodeWidgets`, and `clearGraph`, all `WidgetId`-native. There
is no shared `lastWidgetId` counter; identity comes from the composite key.
Store 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`.
selection/transient view state) stays instance-scoped through `graphId` embedded
in each composite key.
Initial implementation: plain `Map`-backed. No reactivity, no CRDT, no
persistence. The World exists but nothing populates it yet.
Subgraphs are not a separate store. Subgraph nesting is tracked in
`subgraphNavigationStore`. See
[Subgraph Boundaries](subgraph-boundaries-and-promotion.md) for the full model.
**Risk:** Low. New code, no integration points.
**Risk:** Low. Stores are additive; integration happens in Phase 2.
---
## Phase 2: Bridge Layer
## Phase 2: Store Integration
Connects the legacy class instances to the World. Both old and new code can
read entity state; writes still go through legacy classes.
Connects the legacy class instances to the dedicated stores. Both old and new
code can read entity state; writes for not-yet-migrated concerns still go through
legacy classes.
### 2a. Read-only bridge for Position
### 2a. Position reads through layoutStore
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.
`layoutStore` (`src/renderer/core/layout/store/layoutStore.ts`) already extracts
position data for nodes, links, and reroutes into Y.js CRDTs and is the source of
truth for layout.
**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.
**Approach:** New code reads position via `layoutStore` queries (and
`useLayoutMutations()` for writes); legacy code continues to read `node.pos`
directly during the transition. No second copy of position data is introduced —
`layoutStore` stays authoritative.
**Open question:** Should the World wrap the Y.js maps or maintain its own
plain-data copy? Options:
**Risk:** Medium. The legacy `node.pos` read path must stay consistent with
`layoutStore` during the transition. Watch for stale reads during render.
| 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 |
### 2b. Consolidate widget callers onto widgetValueStore ✅ Largely shipped (PR 12617)
**Recommendation:** Start with "World copies from Y.js" for simplicity. The
copy is cheap (position is small data). Revisit if sync overhead becomes
measurable.
`widgetValueStore` (`src/stores/widgetValueStore.ts`) holds widget state in
plain records keyed by `WidgetId` (`graphId:nodeId:name`) and is the source of
truth for widget values. PR 12617 reverted the earlier synthetic-numeric-ID
bridge approach.
**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).
**Remaining work:** Consolidate the remaining widget callers onto
`widgetValueStore`. Reads use `getWidget(widgetId)` / `getNodeWidgets(graphId,
nodeId)`; writes use `setValue(widgetId, value)`; `parseWidgetId()` recovers the
`{ graphId, nodeId, name }` parts at boundaries.
### 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.
**Risk:** Low. The store is well-structured and `WidgetId`-native; identity comes
from the composite key with no separate lookup index.
**Promoted-widget caveat:** ADR 0009 assigns promoted value widgets a
host-boundary identity (`host node locator + SubgraphInput.name`). Interior
source node/widget identity is preserved only as migration and diagnostic
metadata.
### 2c. Read-only bridge for Node metadata
### 2c. Node metadata stores
Populate `NodeType`, `NodeVisual`, `Properties`, `Execution` components by
reading from `LGraphNode` instances. These are simple property copies.
Populate node-metadata records (node type, visual, properties, execution) by
reading from `LGraphNode` instances. These are simple property copies into the
relevant store.
**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.)
**Approach:** When a node is added to the graph (`LGraph.add()`), the store
records its metadata. When a node is removed, the store drops it. The
`incrementVersion()` seam from Phase 0a is a candidate hook point for re-sync
when changed.
**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.
without breaking existing behavior. Stores mirror the classes during the
transition, which limits blast radius.
### Bridge sunset criteria (applies to every Phase 2 bridge)
### Store sunset criteria (applies to every Phase 2 concern)
A bridge can move from "transitional" to "removal candidate" only when:
A legacy path 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.
- All production reads for that concern flow through store accessors.
- All production writes for that concern flow through store actions.
- Serialization parity tests show no diff between legacy and store-driven paths.
- Extension compatibility tests pass without legacy-only fallback paths.
These criteria prevent the bridge from becoming permanent by default.
These criteria prevent the dual path from becoming permanent by default.
### Bridge duration and maintenance controls
### Dual-path 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
- Every concern has a named owner and target sunset release.
- Every PR touching store-covered data paths must include parity tests for both
legacy and store-driven execution.
- Legacy fallback usage is instrumented in integration/e2e and reviewed every
milestone; upward trends block new dual-path expansion.
- Any concern 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
Introduce system functions that operate on store 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.
A function `serializeFromStores(): SerializedGraph` that produces workflow JSON
by querying the dedicated stores. 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.
(output must match existing serialization). It exercises every store and proves
the stores contain 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.
Move change tracking behind a system that observes store mutations and
auto-increments the version counter, replacing scattered explicit increment
calls.
**Dependency:** Requires Phase 2 bridges to be in place (otherwise the World
doesn't see changes).
**Dependency:** Requires Phase 2 store integration (otherwise the system 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.
historically caught.
### 3c. ConnectivitySystem (queries only)
A system that can answer connectivity queries by reading `Connectivity`,
`SlotConnection`, and `LinkEndpoints` components from the World:
A system that answers connectivity queries by reading connectivity, slot, and
link-endpoint records from the relevant stores:
- "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.
Does not perform mutations yet — just queries. Validates that store connectivity
data is complete and consistent with the class-based graph.
**Risk:** Low. Read-only system with equivalence tests.
@@ -326,27 +266,27 @@ connectivity data is complete and consistent with the class-based graph.
## Phase 4: Write Path Migration
Systems begin owning mutations. Legacy class methods delegate to systems.
This is the highest-risk phase.
Systems begin owning mutations. Legacy class methods delegate to stores and
systems. This is the highest-risk phase.
### 4a. Position writes through World
### 4a. Position writes through layoutStore
New code writes position via `world.setComponent(nodeId, Position, ...)`.
The bridge propagates changes back to LayoutStore and `LGraphNode.pos`.
New code writes position via `useLayoutMutations()` against `layoutStore`. A
compatibility shim propagates changes back to `LGraphNode.pos` for legacy
readers.
**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.
**This inverts the data flow:** Phase 2 had legacy -> store (read path). Phase 4
has store -> legacy (write path). Both 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).
**Risk:** High. Two-way sync between `layoutStore` and legacy state. Must handle
re-entrant updates (store write triggers the shim, which writes to legacy, which
must NOT trigger another store write).
### 4b. ConnectivitySystem mutations
`connect()`, `disconnect()`, `removeNode()` operations implemented as system
functions on the World. Legacy `LGraphNode.connect()` etc. delegate to the
system.
functions over the connectivity stores. Legacy `LGraphNode.connect()` etc.
delegate to the system.
**Extension API concern:** The current system fires callbacks at each step:
@@ -363,8 +303,8 @@ 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,
- `onConnectOutput()` and `onConnectInput()` run before any store mutation.
- If either callback rejects, abort with no store writes, no version bump,
and no lifecycle events.
- `onConnectionsChange()` fires synchronously after commit, preserving current
source-then-target ordering.
@@ -374,14 +314,14 @@ the system knowing about the callback API.
**Risk:** High. Extensions depend on callback ordering and timing. Must be
validated against real-world extensions.
### 4c. Widget write path
### 4c. Widget write path ✅ Largely shipped (PR 12617)
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.
`widgetValueStore.setValue()` is already the widget write path and the source of
truth for widget values. Remaining work routes the last legacy widget writers
through `setValue()` rather than mutating widget instances directly.
**Risk:** Medium. WidgetValueStore is already well-abstracted. The main
change is routing writes through the World instead of the store.
**Risk:** Medium. The store is well-abstracted and `WidgetId`-native. The main
change is migrating the remaining direct-mutation call sites onto `setValue()`.
### 4d. Layout write path and render decoupling
@@ -407,25 +347,25 @@ Before enabling ECS render reads as default for any migrated family:
- 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.
- Capture profiler traces proving the dominant cost is not repeated store
accessor 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.
- A store/command-executor transaction wrapper exists 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.
checkpoint in both legacy and store-driven 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
- Write-path re-entrancy tests prove there is no store <-> 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
- Render hot-path benchmark gate passes for every family moving to store-first
reads.
---
@@ -435,10 +375,11 @@ Phase 4 starts only when all of the following are true:
Remove bridge layers and deprecated class properties. This phase happens
per-component, not all at once.
### 5a. Remove Position bridge
### 5a. Remove Position compatibility shim
Once all position reads and writes go through the World, remove the bridge
and the `pos`/`size` properties from `LGraphNode`, `Reroute`, `LGraphGroup`.
Once all position reads and writes go through `layoutStore`, remove the
compatibility shim and the `pos`/`size` properties from `LGraphNode`, `Reroute`,
`LGraphGroup`.
### 5b. Remove widget class hierarchy
@@ -448,9 +389,9 @@ replaced with component data + system functions. `BaseWidget`, `NumberWidget`,
### 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.
`LGraphNode`, `LLink`, `LGraph` become thin shells — their only role is holding
the composite ID and delegating to the stores. Eventually, they can be removed
entirely, replaced by composite IDs + store queries.
**Risk:** Very High. This is the irreversible step. Must be done only after
thorough validation that all consumers (including extensions) work with the
@@ -460,8 +401,8 @@ ECS path.
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.
- The concern being removed has no remaining direct reads or writes outside
store/system APIs.
- Serialization equivalence tests pass continuously for one release cycle.
- A representative extension compatibility matrix is green, including
`rgthree-comfy`.
@@ -489,20 +430,20 @@ The team prepares a single go/no-go packet containing:
### 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.
`layoutStore` uses Y.js CRDTs for collaboration-ready position data
(per [ADR 0003](../adr/0003-crdt-based-layout-system.md)). The other dedicated
stores hold plain reactive data. 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.
`layoutStore` stays authoritative for layout (Phase 2a), so position data has a
single CRDT-backed home. Whether other stores need CRDT backing is open and
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?
- Should non-position stores also be CRDT-backed for collaboration?
- Do the stores 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 record?
### Extension API preservation
@@ -529,7 +470,7 @@ event listeners instead of callbacks.
**Phase 4 decisions:**
- Rejection callbacks act as pre-commit guards (reject before World mutation).
- Rejection callbacks act as pre-commit guards (reject before store mutation).
- Callback dispatch remains synchronous during the bridge period.
- Callback order remains: output validation -> input validation -> commit ->
output change notification -> input change notification.
@@ -546,16 +487,12 @@ incrementally to ECS-native patterns.
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')
// Store pattern (composite WidgetId, no reverse-lookup index needed)
const seedWidgetId = widgetValueStore
.getNodeWidgets(graphId, nodeId)
.find((id) => parseWidgetId(id).name === 'seed')
if (seedWidgetId) {
const widgetValue = world.getComponent(seedWidgetId, WidgetValue)
if (widgetValue) {
world.setComponent(seedWidgetId, WidgetValue, {
...widgetValue,
value: 42
})
}
widgetValueStore.setValue(seedWidgetId, 42)
}
```
@@ -606,17 +543,15 @@ lifecycleEvents.on('entity.removed', (event) => {
// Legacy pattern (do not add new usages)
graph._version++
// Bridge-safe transitional pattern (Phase 0a)
// Transitional pattern (Phase 0a)
graph.incrementVersion()
// ECS-native pattern: mutate through command/system API.
// Store-native pattern: mutate through the 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 })
execute() {
widgetValueStore.setValue(widgetId, 42)
}
})
```
@@ -628,9 +563,11 @@ executor.run({
### 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.
The lifecycle scenarios claim operations are "atomic." This requires a
store/command-executor transaction — the ability to batch multiple store writes
and commit or rollback as a unit. `layoutStore` already wraps its mutations in
Y.js transactions; the command executor extends the same discipline across
stores.
**Current state:** `beforeChange()` / `afterChange()` provide undo/redo
checkpoints but not true transactions. The graph can be in an inconsistent
@@ -638,10 +575,10 @@ 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()` /
- Mutating systems run inside a single command-executor transaction.
- The bridge maps one executor transaction to one `beforeChange()` /
`afterChange()` bracket.
- Operations with multiple component writes (for example `connect()` touching
- Operations with multiple store 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
@@ -649,65 +586,64 @@ state between these calls.
**Questions to resolve:**
- How should `world.transaction()` interact with Y.js transactions when a
component is CRDT-backed?
- How should the command-executor transaction interact with the Y.js
transactions that `layoutStore` already runs?
- 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:
The dedicated stores use per-concern 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'` |
| Store | Key Format |
| ------------------------- | ---------------------------------- |
| `widgetValueStore` | `WidgetId` (`graphId:nodeId:name`) |
| `domWidgetStore` | Widget UUID |
| `layoutStore` | Raw nodeId/linkId/rerouteId |
| `nodeOutputStore` | `"${subgraphId}:${nodeId}"` |
| `subgraphNavigationStore` | subgraphId or `'root'` |
ADR 0009 refines the promoted-widget target: promoted value widgets should use
host boundary identity (`host node locator + SubgraphInput.name`), not interior
source node/widget identity.
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.
Composite string keys won over synthetic numeric IDs. A widget is identified by
its relationship to a graph and node, and the `graphId:nodeId:name` key carries
that relationship directly. PR 12617 kept the composite string instead of an
opaque number, so no reverse lookup index is required — `parseWidgetId()`
recovers the parts on demand.
**Trade-off:** Type safety and uniformity vs. self-documenting keys. The
World should maintain a lookup index (`(nodeId, widgetName) -> WidgetEntityId`)
for the transition period.
**Resolution:** Self-documenting composite keys, parsed at boundaries. Each store
keeps the key format that matches its concern; there is no forced unification
under a single ID space.
---
## Dependency Graph
```
Phase 0a (incrementVersion) ──
Phase 0b (ID type aliases) ───
Phase 0a (incrementVersion) ──── ✅ shipped (one stray cleanup remaining)
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 1a (branded WidgetId) ── ✅ shipped (PR 12617)
Phase 1b (store state shapes) ─┐── depends on 1a
Phase 1c (dedicated stores) ──┘── widgetValueStore + 5 others shipped (PR 12617)
Phase 2a (Position via layoutStore) ─┐── depends on 1c
Phase 2b (Widget consolidation) ────┤── ✅ largely shipped; depends on 1a, 1c
Phase 2c (Node metadata stores) ────┘── depends on 1c
Phase 3a (SerializationSystem) ─── depends on 2a, 2b, 2c
Phase 3b (VersionSystem) ──────── depends on 0a, 2c
Phase 3b (VersionSystem) ──────── depends on 2c (store-level change tracking)
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 4c (Widget writes) ─────── ✅ largely shipped; depends on 2b
Phase 4d (Layout decoupling) ─── depends on 2a, 3->4 gate
Phase 4->5 exit criteria ──────── depends on all of Phase 4
@@ -715,16 +651,19 @@ Phase 4->5 exit criteria ──────── depends on all of Phase 4
Phase 5 (legacy removal) ─────── depends on 4->5 exit criteria
```
The dedicated stores (1c) are the hub: Phase 2 routes legacy data into them,
Phase 3 systems read from them, Phase 4 routes writes through them.
## 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 |
| Phase | Risk | Reversibility | Extension Impact |
| --------------------- | ---------- | ----------------------- | --------------------------- |
| 0 (Foundation) | None | Fully reversible | None |
| 1 (Types/Stores) | Low | New files, deletable | None |
| 2 (Store integration) | Low-Medium | Additive store reads | 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,

View File

@@ -2,30 +2,29 @@
This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the [current system](entity-interactions.md) transform under ECS, and how the [structural problems](entity-problems.md) are resolved. For the full design rationale, see [ADR 0008](../adr/0008-entity-component-system.md).
## 1. World Overview
## 1. Store Overview
The World is the single source of truth for runtime entity state in one
workflow instance. Entities are just branded IDs. Components are plain data
objects. Systems are functions that query the World.
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).
```mermaid
graph TD
subgraph World["World (Central Registry)"]
subgraph Stores["Dedicated Stores (source of truth)"]
direction TB
NodeStore["Nodes
Map&lt;NodeEntityId, NodeComponents&gt;"]
LinkStore["Links
Map&lt;LinkEntityId, LinkComponents&gt;"]
ScopeRegistry["Graph Scopes
Map&lt;GraphId, ParentGraphId | null&gt;"]
WidgetStore["Widgets
Map&lt;WidgetEntityId, WidgetComponents&gt;"]
SlotStore["Slots
Map&lt;SlotEntityId, SlotComponents&gt;"]
RerouteStore["Reroutes
Map&lt;RerouteEntityId, RerouteComponents&gt;"]
GroupStore["Groups
Map&lt;GroupEntityId, GroupComponents&gt;"]
WidgetValueStore["widgetValueStore
Map&lt;WidgetId, WidgetValue&gt;"]
DomWidgetStore["domWidgetStore
Map&lt;WidgetId, DomWidgetState&gt;"]
LayoutStore["layoutStore (Y.js CRDT)
nodeId / linkId / rerouteId → layout"]
NodeOutputStore["nodeOutputStore
Map&lt;nodeLocatorId, outputs&gt;"]
SubgraphNavStore["subgraphNavigationStore
active subgraph path"]
PreviewExposureStore["previewExposureStore
preview exposure state"]
end
subgraph Systems["Systems (Behavior)"]
@@ -38,53 +37,50 @@ Map&lt;GroupEntityId, GroupComponents&gt;"]
VS["VersionSystem"]
end
RS -->|reads| World
SS -->|reads/writes| World
CS -->|reads/writes| World
LS -->|reads/writes| World
ES -->|reads| World
VS -->|reads/writes| World
RS -->|reads| Stores
SS -->|reads/writes| Stores
CS -->|reads/writes| LayoutStore
LS -->|reads/writes| LayoutStore
ES -->|reads| NodeOutputStore
VS -->|reads/writes| LayoutStore
style World fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Stores fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
```
### Entity IDs
### Entity Keys
Each store addresses entities by its own string-key convention.
```mermaid
graph LR
subgraph "Branded IDs (compile-time distinct)"
NID["NodeEntityId
number & { __brand: 'NodeEntityId' }"]
LID["LinkEntityId
number & { __brand: 'LinkEntityId' }"]
WID["WidgetEntityId
number & { __brand: 'WidgetEntityId' }"]
SLID["SlotEntityId
number & { __brand: 'SlotEntityId' }"]
RID["RerouteEntityId
number & { __brand: 'RerouteEntityId' }"]
GID["GroupEntityId
number & { __brand: 'GroupEntityId' }"]
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
GRID["GraphId
string & { __brand: 'GraphId' }"]:::scopeId
NID -.-x LID
LID -.-x WID
WID -.-x SLID
classDef scopeId fill:#2a2a4a,stroke:#4a4a6a,color:#e0e0e0,stroke-dasharray:5
linkStyle 0 stroke:red,stroke-dasharray:5
linkStyle 1 stroke:red,stroke-dasharray:5
linkStyle 2 stroke:red,stroke-dasharray:5
WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
NLID -->|nodeOutputStore| W
NID -->|layoutStore| W
LID -->|layoutStore| W
RID -->|layoutStore| W
```
Red dashed lines = compile-time errors if mixed. No more accidentally passing a `LinkId` where a `NodeId` is expected.
`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, not an entity ID. It identifies which graph an entity belongs to. Subgraphs are nodes with a `SubgraphStructure` component — see [Subgraph Boundaries](subgraph-boundaries-and-promotion.md).
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](subgraph-boundaries-and-promotion.md).
### Linked subgraphs and instance-varying state
@@ -94,9 +90,10 @@ 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 containing `graphId` chain inside one World instance.
- "Single source of truth" therefore means one source per workflow instance,
not one global source across all linked instances.
to the containing `graphId` chain 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 subgraphs without inheritance
@@ -130,7 +127,7 @@ graph LR
B12["connect(), disconnect()"]
end
subgraph After["NodeEntityId + Components"]
subgraph After["nodeId-keyed components (across stores)"]
direction TB
A1["Position
{ pos, size, bounding }"]
@@ -180,7 +177,7 @@ target_id, target_slot, type"]
B5["resolve()"]
end
subgraph After["LinkEntityId + Components"]
subgraph After["linkId-keyed components (layoutStore)"]
direction TB
A1["LinkEndpoints
{ originId, originSlot,
@@ -214,7 +211,7 @@ graph LR
B5["useWidgetValueStore()"]
end
subgraph After["WidgetEntityId + Components"]
subgraph After["WidgetId + components"]
direction TB
A1["WidgetIdentity
{ name, widgetType, parentNodeId }"]
@@ -228,8 +225,7 @@ graph LR
B2 -.-> A2
B3 -.-> A3
B4 -.->|"moves to"| SYS1["RenderSystem"]
B5 -.->|"absorbed by"| SYS2["World (is the store)"]
B6 -.->|"moves to"| SYS3["PromotionSystem"]
B5 -.->|"absorbed by"| SYS2["widgetValueStore"]
style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
@@ -237,7 +233,7 @@ graph LR
## 3. System Architecture
Systems are pure functions that query the World for entities with specific component combinations. Each system owns exactly one concern.
Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Each system owns exactly one concern.
```mermaid
graph TD
@@ -389,30 +385,25 @@ graph TD
VS["VersionSystem"]
end
World["World
(instance-scoped source of truth)"]
subgraph Components["Component Stores"]
Pos["Position"]
Vis["*Visual"]
Con["Connectivity"]
Val["*Value"]
subgraph Stores["Dedicated Stores (instance-scoped source of truth)"]
LayoutStore["layoutStore"]
WidgetValueStore["widgetValueStore"]
DomWidgetStore["domWidgetStore"]
NodeOutputStore["nodeOutputStore"]
end
Systems -->|"query/mutate"| World
World -->|"contains"| Components
Systems -->|"query/mutate"| Stores
style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
style World fill:#1a1a4a,stroke:#2a2a6a,color:#e0e0e0
style Components fill:#1a3a3a,stroke:#2a4a4a,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 the World directly, never reach through entities
- **No scattered store access**: the World _is_ the store; systems are the only writers
- **Unidirectional**: Input → Systems → World → Render (no back-edges)
- **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
@@ -447,9 +438,10 @@ No inheritance hierarchy.
Subgraph = node + component."]
S3["One system per concern.
Systems don't overlap."]
S4["Branded per-kind IDs.
Compile-time type errors."]
S5["Systems query World.
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."]
@@ -479,30 +471,30 @@ sequenceDiagram
participant Legacy as Legacy Code
participant Class as LGraphNode (class)
participant Bridge as Bridge Adapter
participant World as World (ECS)
participant Store as layoutStore (ECS)
participant New as New Code / Systems
Note over Legacy,New: Phase 1: Bridge reads from class, writes to World
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->>World: world.setComponent(nodeId, Position, { pos: [100, 200] })
Bridge->>Store: useLayoutMutations().moveNode(nodeId, { pos: [100, 200] })
New->>World: world.getComponent(nodeId, Position)
World-->>New: { pos: [100, 200], size: [...] }
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->>World: world.setComponent(nodeId, Position, { pos: [150, 250] })
World->>Bridge: change detected
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->>World: world.getComponent(nodeId, Position)
World-->>New: { pos: [150, 250] }
New->>Store: layoutStore read for nodeId
Store-->>New: { pos: [150, 250] }
```
### Incremental layout/render separation
@@ -524,16 +516,17 @@ incremental rollout safety.
```mermaid
graph LR
subgraph Phase1["Phase 1: Types Only"]
T1["Define branded IDs"]
T1["Define string-key types
(WidgetId, nodeLocatorId)"]
T2["Define component interfaces"]
T3["Define World type"]
T3["Define store shapes"]
end
subgraph Phase2["Phase 2: Bridge"]
B1["Bridge adapters
class ↔ World sync"]
class ↔ store sync"]
B2["New features use
World as source"]
stores as source"]
B3["Old code unchanged"]
end

View File

@@ -1,349 +0,0 @@
# 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.

View File

@@ -1,6 +1,6 @@
# Entity Interactions (Current System)
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md).
This document maps the relationships and interaction patterns between all entity types in the litegraph layer as it exists today. It serves as a baseline for the ECS migration planned in [ADR 0008](../adr/0008-entity-component-system.md), whose target is realized as a set of dedicated Pinia stores (see [Proto-ECS Stores](proto-ecs-stores.md)).
## Entities
@@ -361,7 +361,7 @@ graph TD
subgraph Stores
WVS["WidgetValueStore
(Pinia)"]
PS["PromotionStore
PES["PreviewExposureStore
(Pinia)"]
LM["LayoutMutations
(composable)"]
@@ -379,9 +379,9 @@ lastRerouteId, lastGroupId)"]
Widget <-->|"value, label, disabled"| WVS
WVS -.->|"keyed by graphId:nodeId:name"| Widget
%% PromotionStore
SGNode -->|"tracks promoted widgets"| PS
Widget -.->|"isPromotedByAny() query"| PS
%% PreviewExposureStore
SGNode -->|"host-scoped preview exposures"| PES
PES -.->|"keyed by host node locator"| SGNode
%% LayoutMutations
Node -->|"pos/size setter"| LM

View File

@@ -115,7 +115,7 @@ If slots are reordered (e.g., by an extension adding a slot), all links referenc
### No Cross-Kind ID Safety
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. This is the core motivation for the branded ID types proposed in ADR 0008.
Nothing prevents passing a `LinkId` where a `NodeId` is expected — they're both `number`. The dedicated-store direction addresses this with branded string keys where cross-kind safety pays off (for example `WidgetId` in `widgetValueStore`, `src/types/widgetId.ts`).
## 5. Law of Demeter Violations
@@ -201,12 +201,12 @@ This means:
## How ECS Addresses These Problems
| Problem | ECS Solution |
| ---------------------- | ----------------------------------------------------------------------------- |
| God objects | Data split into small, focused components; behavior lives in systems |
| Circular dependencies | Entities are just IDs; components have no inheritance hierarchy |
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
| Inconsistent IDs | Branded per-kind IDs with compile-time safety |
| Demeter violations | Systems query the World directly; no entity-to-entity references |
| Scattered side effects | Version tracking becomes a system responsibility; stores become systems |
| Render-time mutations | Render system reads components without writing; layout system runs separately |
| Problem | ECS Solution |
| ---------------------- | ------------------------------------------------------------------------------------------- |
| God objects | Data split into small, focused components in dedicated stores; behavior lives in systems |
| Circular dependencies | Entities are addressed by string keys; components have no inheritance hierarchy |
| Mixed concerns | Each system handles exactly one concern (render, serialize, execute) |
| Inconsistent IDs | Branded string keys per store (for example `WidgetId`) for cross-kind safety |
| Demeter violations | Systems query the relevant store directly; no entity-to-entity references |
| Scattered side effects | Version tracking becomes a system responsibility; mutations flow through store command APIs |
| Render-time mutations | Render system reads components without writing; layout system runs separately |

View File

@@ -6,17 +6,20 @@ For the full problem analysis, see [Entity Problems](entity-problems.md). For th
## 1. What's Already Extracted
Five stores extract entity state out of class instances into centralized,
queryable registries. Promoted value-widget topology is no longer a store; ADR
0009 represents it as ordinary linked `SubgraphInput` state.
Six dedicated stores extract entity state out of class instances into focused,
queryable registries, each owning one concern. Promoted value-widget topology is
no longer a store; ADR 0009 represents it as ordinary linked `SubgraphInput`
state, and promoted value data lives in `WidgetValueStore` keyed by the input's
`WidgetId`.
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------------- | ------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId → nodeId:name` | `"${nodeId}:${widgetName}"` | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| Store | Extracts From | Scoping | Key Format | Data Shape |
| ----------------------- | ------------------- | ----------------- | ---------------------------------- | ----------------------------- |
| WidgetValueStore | `BaseWidget` | `graphId` | `WidgetId` (`graphId:nodeId:name`) | Plain `WidgetState` object |
| DomWidgetStore | `BaseDOMWidget` | Global | `widgetId` (UUID) | Position, visibility, z-index |
| LayoutStore | Node, Link, Reroute | Workflow-level | `nodeId`, `linkId`, `rerouteId` | Y.js CRDT maps (pos, size) |
| NodeOutputStore | Execution results | `nodeLocatorId` | `"${subgraphId}:${nodeId}"` | Output data, preview URLs |
| SubgraphNavigationStore | Canvas viewport | `subgraphId` | `subgraphId` or `'root'` | LRU viewport cache |
| PreviewExposureStore | Subgraph host node | host node locator | host locator + exposure name | Display-only preview state |
ADR 0009 refines promoted-widget identity: promoted value widgets are keyed by
the host boundary (`host node locator + SubgraphInput.name`), while interior
@@ -31,9 +34,9 @@ The closest thing to a true ECS component store in the codebase today.
### State Shape
```
Map<UUID, Map<WidgetKey, WidgetState>>
Map<UUID, Map<WidgetId, WidgetState>>
│ │ │
graphId "nodeId:name" pure data object
graphId "graphId:nodeId:name" pure data object
```
`WidgetState` is a plain data object with no methods:
@@ -56,7 +59,7 @@ Map<UUID, Map<WidgetKey, WidgetState>>
**Phase 2 — `setNodeId()`:** Widget replaces its `_state` with a reference to the store's object:
```
widget._state = useWidgetValueStore().registerWidget(graphId, { ...this._state, nodeId })
widget._state = useWidgetValueStore().registerWidget(widgetId, { ...this._state, nodeId })
```
After registration, the widget's getters/setters (`value`, `label`, `disabled`) are pass-throughs to the store. Mutations to the widget automatically sync to the store via shared object reference.
@@ -119,20 +122,22 @@ Legacy `properties.proxyWidgets` is load-time migration input only.
╰────────────────╯ ╰──────────────────────╯
```
`PromotedWidgetViewManager`
(`src/lib/litegraph/src/subgraph/PromotedWidgetViewManager.ts`) now reconciles
synthetic widget views derived from linked subgraph inputs. It does not sit on
top of a promotion registry.
A promoted host widget is ordinary `WidgetState` in `WidgetValueStore`, keyed by
the `WidgetId` carried on the `SubgraphInput` (`input.widgetId`). `SubgraphNode.widgets`
is a read-only projection over the node's inputs that resolves each value via
`useWidgetValueStore().getWidget(input.widgetId)`. There is no synthetic widget
view object and no view cache to reconcile (PR 12617 deleted `PromotedWidgetView`
and `PromotedWidgetViewManager`).
### ECS Alignment
| Aspect | ECS-like | Why |
| ----------------------------- | --------- | ------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| View reconciliation | Partially | ViewManager preserves synthetic widget object identity |
| Entity class drives view sync | **No** | SubgraphNode still owns synthetic view cache invalidation |
| Aspect | ECS-like | Why |
| ---------------------------- | -------- | -------------------------------------------------------------- |
| Canonical topology | Yes | Value exposure is ordinary subgraph input/link state |
| Host-scoped preview state | Yes | Preview exposure data is keyed by host locator |
| Legacy migration boundary | Yes | `proxyWidgets` is consumed into canonical state or quarantine |
| Promoted value is plain data | Yes | Host widget is `WidgetState` in the store, keyed by `WidgetId` |
| Projection over data | Yes | `SubgraphNode.widgets` derives from inputs; no view cache |
## 4. LayoutStore (CRDT)
@@ -171,14 +176,14 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
### ECS Alignment
| Aspect | ECS-like | Why |
| ---------------------------- | --------- | --------------------------------------------------- |
| Position data extracted | Yes | Closest to the ECS `Position` component |
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
| Module-scope access | **No** | Domain objects import store at module level |
| No entity ID branding | **No** | Plain numbers, no type safety across kinds |
| Aspect | ECS-like | Why |
| ---------------------------- | --------- | ------------------------------------------------------- |
| Position data extracted | Yes | Closest to the ECS `Position` component |
| CRDT-ready | Yes | Enables collaboration (ADR 0003) |
| Covers multiple entity kinds | Yes | Nodes, links, reroutes in one store |
| Mutation API (composable) | Partially | System-like, but called from entities, not a system |
| Module-scope access | **No** | Domain objects import store at module level |
| Per-store keying | Yes | Owns `nodeId`/`linkId`/`rerouteId` keys for its concern |
## 5. Pattern Analysis
@@ -190,30 +195,32 @@ These module-scope calls create implicit dependencies on the Vue runtime and mak
4. **Query APIs**: `getWidget()`, preview exposure queries, `getNodeWidgets()` — system-like queries
5. **Separation of data from behavior**: The stores hold data; classes retain behavior
### What's Missing vs Full ECS
### Target Design and Remaining Gaps
Dedicated per-domain stores with their own string keys are the target, not a way
station toward one unified registry. The remaining gaps are about behavior and
data flow, not about collapsing the stores together.
```mermaid
graph TD
subgraph Have["What We Have"]
subgraph Have["What We Have (and Want)"]
style Have fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
H1["Centralized data stores"]
H1["Dedicated per-domain stores"]
H2["Plain data components
(WidgetState, LayoutMap)"]
H3["Query APIs
(getWidget, preview exposures)"]
H4["Graph-scoped lifecycle"]
H5["Partial position extraction
H5["Per-store string keys
(WidgetId, nodeLocatorId)"]
H6["Position extraction
(LayoutStore)"]
end
subgraph Missing["What's Missing"]
style Missing fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
M1["Unified World
(6 stores, 6 keying strategies)"]
M2["Branded entity IDs
(keys are string concatenations)"]
M3["System layer
(mutations from anywhere)"]
M3["System / command layer
(sanctioned mutation path)"]
M4["Complete extraction
(behavior still on classes)"]
M5["No entity-to-entity refs
@@ -225,19 +232,21 @@ graph TD
### Keying Strategy Comparison
Each store invents its own identity scheme:
Each store owns the identity scheme that fits its concern:
| Store | Key Format | Entity ID Used | Type-Safe? |
| ---------------- | --------------------------- | ----------------------- | ---------- |
| WidgetValueStore | `"${nodeId}:${widgetName}"` | NodeId (number\|string) | No |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
| Store | Key Format | Key Type | Type-Safe? |
| ---------------- | ---------------------------------- | ------------------ | ---------------- |
| WidgetValueStore | `WidgetId` (`graphId:nodeId:name`) | branded string | Yes (`WidgetId`) |
| DomWidgetStore | Widget UUID | UUID (string) | No |
| LayoutStore | Raw nodeId/linkId/rerouteId | Mixed number types | No |
| NodeOutputStore | `"${subgraphId}:${nodeId}"` | Composite string | No |
In the ECS target, all of these would use branded entity IDs (`WidgetEntityId`, `NodeEntityId`, etc.) with compile-time cross-kind protection.
For promoted value widgets, ADR 0009 narrows the target key to host boundary
identity (`host node locator + SubgraphInput.name`) instead of interior source
identity.
`WidgetValueStore` already keys on a branded `WidgetId` string (`src/types/widgetId.ts`),
which carries its scope and survives renames at the store layer. The remaining
stores can adopt their own branded string keys where cross-kind safety pays off,
without a shared entity-ID space. For promoted value widgets, ADR 0009 keys on
the host boundary: the input's `WidgetId` (host node locator + `SubgraphInput.name`),
not interior source identity.
## 6. Extraction Map

View File

@@ -74,7 +74,7 @@ graph TD
subgraph Unified["Unified: Composition"]
direction TB
W["World (flat)"]
W["Dedicated stores (flat)"]
N1["Node A
graphScope: root"]
N2["Node B (subgraph carrier)
@@ -95,30 +95,29 @@ graph TD
style Unified fill:#1a2a1a,stroke:#2a4a2a,color:#e0e0e0
```
In the ECS World:
Across the dedicated stores:
- **Every graph is a graph.** The "root" graph is simply the one whose
`graphScope` has no parent.
- **Nesting is a component, not a type.** A node can carry a
`SubgraphStructure` component, which references another graph scope. That
scope contains its own entities — nodes, links, widgets, reroutes, groups —
all living in the same flat World.
- **One World per workflow.** All entities across all nesting levels coexist in
a single World, each tagged with a `graphScope` identifier. There are no
sub-worlds, no recursive containers. The fractal structure is encoded in the
data, not in the container hierarchy.
- **Entity taxonomy: six kinds, not seven.** ADR 0008 defines seven entity kinds
including `SubgraphEntityId`. Under unification, "subgraph" is not an entity
kind — it is a node with a component. The taxonomy becomes: Node, Link,
Widget, Slot, Reroute, Group.
- **ID counters remain global.** All entity IDs are allocated from a single
counter space, shared across all nesting levels. This preserves the current
`rootGraph.state` behavior and guarantees ID uniqueness across the entire
World.
- **Graph scope parentage is tracked.** The World maintains a scope registry:
each `graphId` maps to its parent `graphId` (or null for the root). This
enables the ancestor walk required by the acyclicity invariant and supports
queries like "all entities transitively contained by this graph."
- **Nesting is a component.** A node can carry a `SubgraphStructure` component,
which references another graph scope. That scope contains its own entities —
nodes, links, widgets, reroutes, groups — and each store entry tags the scope
it belongs to.
- **Scope tagging over container nesting.** Entries across all nesting levels
live in the same stores, each tagged with a `graphScope` identifier. The
stores stay flat; the fractal structure is encoded in the keys and scope tags,
with no recursive containers.
- **Entity taxonomy: six kinds.** A subgraph is a node carrying a
`SubgraphStructure` component. The taxonomy is: Node, Link, Widget, Slot,
Reroute, Group. `SubgraphEntityId` is replaced by a `GraphId` scope identifier.
- **Numeric ID counters stay shared.** Node/link/reroute IDs are allocated from
a single counter space across nesting levels, preserving the current
`rootGraph.state` behavior and guaranteeing uniqueness. Widget keys embed
their scope directly (`WidgetId = graphId:nodeId:name`).
- **Graph scope parentage is tracked.** A scope registry maps each `graphId` to
its parent `graphId` (or null for the root). This enables the ancestor walk
required by the acyclicity invariant and supports queries like "all entities
transitively contained by this graph."
### The acyclicity invariant
@@ -367,15 +366,17 @@ sequenceDiagram
Exec->>IW: reads input value (42)
```
### Candidate B: Simplified component promotion
### Candidate B: Simplified component promotion (rejected)
ADR 0009 chose Candidate A. Candidate B is retained here as the rejected
alternative; it relied on a source-widget lookup model that no longer exists.
Promotion remains a first-class concept, simplified from three layers to one:
- A `WidgetPromotion` component on a widget entity:
`{ promotedTo: NodeEntityId, sourceWidget: WidgetEntityId }`
- The SubgraphNode's widget list includes promoted widget entity IDs directly
- Value reads/writes delegate to the source widget's `WidgetValue` component via
World lookup
- A `WidgetPromotion` component on a widget entity referencing the host node and
source widget
- The SubgraphNode's widget list includes promoted widget references directly
- Value reads/writes delegate to the source widget's value via a store lookup
- Serialized as `properties.proxyWidgets` (unchanged)
This removes the ViewManager and proxy widget reconciliation but preserves the
@@ -399,8 +400,9 @@ concept of promotion as distinct from connection.
Whichever candidate is chosen:
- **`WidgetEntityId` is internal.** Serialization uses widget name + parent node
reference. This is settled (see Section 4).
- **Internal identity is the `WidgetId` string.** Serialization uses widget name
- parent node reference, while runtime state keys on `WidgetId`
(`graphId:nodeId:name`). This is settled (see Section 4).
- **The type → widget mapping is authoritative.** The widget registry
(`widgetStore.widgets`) is the single source of truth for which types produce
widgets. No parallel mechanism should duplicate this.
@@ -473,10 +475,10 @@ and produces the recursive `ExportedSubgraph` structure, matching the current
format exactly. Existing workflows, the ComfyUI backend, and third-party tools
see no change.
| Direction | Format | Notes |
| --------------- | ------------------------------- | ------------------------------------------ |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Migration: normalize to flat World on load |
| Direction | Format | Notes |
| --------------- | ------------------------------- | -------------------------------------------------- |
| **Save/export** | Nested (current shape) | SerializationSystem walks scope tree |
| **Load/import** | Nested (current) or future flat | Migration: normalize to store-backed state on load |
The migration pattern: load any supported format and normalize to the internal
model. The system accepts old formats indefinitely but produces the current
@@ -486,7 +488,7 @@ format on save.
| Context | Identity | Example |
| -------------------- | ---------------------------------------------------------- | ---------------------------------- |
| **Internal (World)** | `WidgetEntityId` (opaque branded number) | `42 as WidgetEntityId` |
| **Internal (store)** | `WidgetId` composite string | `'graphId:42:seed' as WidgetId` |
| **Serialized** | Position in `widgets_values[]` + name from node definition | `widgets_values[2]` → third widget |
On save: the `SerializationSystem` queries `WidgetIdentity.name` and
@@ -494,7 +496,7 @@ On save: the `SerializationSystem` queries `WidgetIdentity.name` and
order.
On load: widget values are matched by name against the node definition's input
specs, then assigned `WidgetEntityId`s from the global counter.
specs, then registered in `WidgetValueStore` under their `WidgetId`.
This is the existing contract, preserved exactly.
@@ -553,7 +555,7 @@ This document proposes or surfaces the following changes to
| Entity taxonomy | 7 kinds including `SubgraphEntityId` | 6 kinds — subgraph is a node with `SubgraphStructure` component |
| `SubgraphEntityId` | `string & { __brand: 'SubgraphEntityId' }` | Eliminated; replaced by `GraphId` scope identifier |
| Subgraph components | `SubgraphStructure`, `SubgraphMeta` listed as separate-entity components | Become node components on SubgraphNode entities |
| World structure | Implied per-graph containment | Flat World with `graphScope` tags; one World per workflow |
| Storage structure | Implied per-graph containment | Dedicated stores with `graphScope`-tagged entries; no single registry |
| Acyclicity | Not addressed | DAG invariant on `SubgraphStructure.graphId` references, enforced on mutation |
| Boundary model | Deferred | Typed interface contracts on `SubgraphStructure`; no virtual nodes or magic IDs |
| Widget promotion | Treated as a given feature to migrate | ADR 0009 chooses Candidate A: promoted value widgets are linked inputs |