mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-06-25 01:04:58 +00:00
Compare commits
2 Commits
codex/fix-
...
drjkl/arch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55b75e575f | ||
|
|
df40b2c8fa |
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<NodeEntityId, NodeComponents>"]
|
||||
LinkStore["Links
|
||||
Map<LinkEntityId, LinkComponents>"]
|
||||
ScopeRegistry["Graph Scopes
|
||||
Map<GraphId, ParentGraphId | null>"]
|
||||
WidgetStore["Widgets
|
||||
Map<WidgetEntityId, WidgetComponents>"]
|
||||
SlotStore["Slots
|
||||
Map<SlotEntityId, SlotComponents>"]
|
||||
RerouteStore["Reroutes
|
||||
Map<RerouteEntityId, RerouteComponents>"]
|
||||
GroupStore["Groups
|
||||
Map<GroupEntityId, GroupComponents>"]
|
||||
WidgetValueStore["widgetValueStore
|
||||
Map<WidgetId, WidgetValue>"]
|
||||
DomWidgetStore["domWidgetStore
|
||||
Map<WidgetId, DomWidgetState>"]
|
||||
LayoutStore["layoutStore (Y.js CRDT)
|
||||
nodeId / linkId / rerouteId → layout"]
|
||||
NodeOutputStore["nodeOutputStore
|
||||
Map<nodeLocatorId, outputs>"]
|
||||
SubgraphNavStore["subgraphNavigationStore
|
||||
active subgraph path"]
|
||||
PreviewExposureStore["previewExposureStore
|
||||
preview exposure state"]
|
||||
end
|
||||
|
||||
subgraph Systems["Systems (Behavior)"]
|
||||
@@ -38,53 +37,50 @@ Map<GroupEntityId, GroupComponents>"]
|
||||
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
|
||||
|
||||
|
||||
@@ -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 (0–3).
|
||||
During Phases 0–3, the bridge layer provides mutation entry points that
|
||||
will later become command handlers. The command layer is introduced in
|
||||
Phase 4 when write paths migrate from legacy to ECS:
|
||||
|
||||
- **Phase 4a**: Position write commands replace direct `node.pos =` assignment
|
||||
- **Phase 4b**: Connectivity commands replace `node.connect()` /
|
||||
`node.disconnect()`
|
||||
- **Phase 4c**: Widget value commands replace direct store writes
|
||||
|
||||
Each Phase 4 step introduces commands for one concern, with the system
|
||||
function as the handler and the World transaction as the atomicity
|
||||
boundary.
|
||||
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user