Skip to content

Latest commit

 

History

History
559 lines (467 loc) · 15.7 KB

File metadata and controls

559 lines (467 loc) · 15.7 KB

ECS Target Architecture

This document describes the target ECS architecture for the litegraph entity system. It shows how the entities and interactions from the current system transform under ECS, and how the structural problems are resolved. For the full design rationale, see ADR 0008.

1. Store Overview

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).

graph TD
    subgraph Stores["Dedicated Stores (source of truth)"]
        direction TB
        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)"]
        direction TB
        RS["RenderSystem"]
        SS["SerializationSystem"]
        CS["ConnectivitySystem"]
        LS["LayoutSystem"]
        ES["ExecutionSystem"]
        VS["VersionSystem"]
    end

    RS -->|reads| Stores
    SS -->|reads/writes| Stores
    CS -->|reads/writes| LayoutStore
    LS -->|reads/writes| LayoutStore
    ES -->|reads| NodeOutputStore
    VS -->|reads/writes| LayoutStore

    style Stores fill:#1a1a2e,stroke:#16213e,color:#e0e0e0
    style Systems fill:#0f3460,stroke:#16213e,color:#e0e0e0
Loading

Entity Keys

Each store addresses entities by its own string-key convention.

graph LR
    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

    WID -->|widgetValueStore, domWidgetStore| W["keyed lookups"]
    NLID -->|nodeOutputStore| W
    NID -->|layoutStore| W
    LID -->|layoutStore| W
    RID -->|layoutStore| W
Loading

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. It identifies which graph an entity belongs to and forms the prefix of WidgetId. Subgraphs are nodes with a SubgraphStructure component — see Subgraph Boundaries.

Linked subgraphs and instance-varying state

Linked subgraph definitions can be shared structurally, but mutable values are 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 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

Recursive containment is represented through graph scopes rather than Subgraph extends LGraph inheritance.

  • A subgraph node points to a child graph via SubgraphStructure.childGraphId.
  • The scope registry stores childGraphId -> parentGraphId links.
  • Depth queries traverse this scope DAG, then filter entities by graphScope.

2. Component Composition

Node: Before vs After

graph LR
    subgraph Before["LGraphNode (monolith)"]
        direction TB
        B1["pos, size, bounding"]
        B2["color, bgcolor, title"]
        B3["type, category, nodeData"]
        B4["inputs[], outputs[]"]
        B5["order, mode, flags"]
        B6["properties, properties_info"]
        B7["widgets[]"]
        B8["serialize(), configure()"]
        B9["drawSlots(), drawWidgets()"]
        B10["execute(), triggerSlot()"]
        B11["graph._version++"]
        B12["connect(), disconnect()"]
    end

    subgraph After["nodeId-keyed components (across stores)"]
        direction TB
        A1["Position
{ pos, size, bounding }"]
        A2["NodeVisual
{ color, bgcolor, boxcolor, title }"]
        A3["NodeType
{ type, category, nodeData }"]
        A4["Connectivity
{ inputSlotIds[], outputSlotIds[] }"]
        A5["Execution
{ order, mode, flags }"]
        A6["Properties
{ properties, propertiesInfo }"]
        A7["WidgetContainer
{ widgetIds[] }"]
    end

    B1 -.-> A1
    B2 -.-> A2
    B3 -.-> A3
    B4 -.-> A4
    B5 -.-> A5
    B6 -.-> A6
    B7 -.-> A7

    B8 -.->|"moves to"| SYS1["SerializationSystem"]
    B9 -.->|"moves to"| SYS2["RenderSystem"]
    B10 -.->|"moves to"| SYS3["ExecutionSystem"]
    B11 -.->|"moves to"| SYS4["VersionSystem"]
    B12 -.->|"moves to"| SYS5["ConnectivitySystem"]

    style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
    style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Loading

Link: Before vs After

graph LR
    subgraph Before["LLink (class)"]
        direction TB
        B1["origin_id, origin_slot
target_id, target_slot, type"]
        B2["color, path, _pos"]
        B3["_dragging, data"]
        B4["disconnect()"]
        B5["resolve()"]
    end

    subgraph After["linkId-keyed components (layoutStore)"]
        direction TB
        A1["LinkEndpoints
{ originId, originSlot,
targetId, targetSlot, type }"]
        A2["LinkVisual
{ color, path, centerPos }"]
        A3["LinkState
{ dragging, data }"]
    end

    B1 -.-> A1
    B2 -.-> A2
    B3 -.-> A3
    B4 -.->|"moves to"| SYS1["ConnectivitySystem"]
    B5 -.->|"moves to"| SYS2["ConnectivitySystem"]

    style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
    style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Loading

Widget: Before vs After

graph LR
    subgraph Before["BaseWidget (class)"]
        direction TB
        B1["name, type, _node"]
        B2["value, options, serialize"]
        B3["computedHeight, margin"]
        B4["drawWidget(), onClick()"]
        B5["useWidgetValueStore()"]
    end

    subgraph After["WidgetId + components"]
        direction TB
        A1["WidgetIdentity
{ name, widgetType, parentNodeId }"]
        A2["WidgetValue
{ value, options, serialize }"]
        A3["WidgetLayout
{ computedHeight, constraints }"]
    end

    B1 -.-> A1
    B2 -.-> A2
    B3 -.-> A3
    B4 -.->|"moves to"| SYS1["RenderSystem"]
    B5 -.->|"absorbed by"| SYS2["widgetValueStore"]

    style Before fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
    style After fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Loading

3. System Architecture

Systems are pure functions that query the relevant store(s) for entities with specific component combinations. Each system owns exactly one concern.

graph TD
    subgraph InputPhase["Input Phase"]
        UserInput["User Input
(pointer, keyboard)"]
        APIInput["API Input
(backend execution results)"]
    end

    subgraph UpdatePhase["Update Phase (ordered)"]
        direction TB
        CS["ConnectivitySystem
Manages link/slot mutations.
Writes: LinkEndpoints, SlotConnection,
Connectivity"]
        VS["VersionSystem
Centralizes change tracking.
Replaces 15+ scattered _version++.
Writes: version counter"]
        LS["LayoutSystem
Computes positions and sizes.
Runs BEFORE render, not during.
Reads: Connectivity, WidgetContainer
Writes: Position, SlotVisual, WidgetLayout"]
        ES["ExecutionSystem
Determines run order.
Reads: Connectivity, Execution
Writes: Execution.order"]
    end

    subgraph RenderPhase["Render Phase (read-only)"]
        RS["RenderSystem
Pure read of components.
No state mutation.
Reads: Position, *Visual, *Layout"]
    end

    subgraph PersistPhase["Persist Phase"]
        SS["SerializationSystem
Reads/writes all components.
Handles workflow JSON."]
    end

    UserInput --> CS
    APIInput --> ES
    CS --> VS
    VS --> LS
    LS --> RS
    CS --> SS

    style InputPhase fill:#2a2a4a,stroke:#3a3a5a,color:#e0e0e0
    style UpdatePhase fill:#1a3a2a,stroke:#2a4a3a,color:#e0e0e0
    style RenderPhase fill:#3a2a1a,stroke:#4a3a2a,color:#e0e0e0
    style PersistPhase fill:#2a2a3a,stroke:#3a3a4a,color:#e0e0e0
Loading

System-Component Access Matrix

graph LR
    subgraph Systems
        RS["Render"]
        SS["Serialization"]
        CS["Connectivity"]
        LS["Layout"]
        ES["Execution"]
        VS["Version"]
    end

    subgraph Components
        Pos["Position"]
        NV["NodeVisual"]
        NT["NodeType"]
        Con["Connectivity"]
        Exe["Execution"]
        Props["Properties"]
        WC["WidgetContainer"]
        LE["LinkEndpoints"]
        LV["LinkVisual"]
        SC["SlotConnection"]
        SV["SlotVisual"]
        WVal["WidgetValue"]
        WL["WidgetLayout"]
    end

    RS -.->|read| Pos
    RS -.->|read| NV
    RS -.->|read| LV
    RS -.->|read| SV
    RS -.->|read| WL

    LS -->|write| Pos
    LS -->|write| SV
    LS -->|write| WL
    LS -.->|read| Con
    LS -.->|read| WC

    CS -->|write| LE
    CS -->|write| SC
    CS -->|write| Con

    ES -.->|read| Con
    ES -->|write| Exe

    SS -.->|read/write| Pos
    SS -.->|read/write| NT
    SS -.->|read/write| Props
    SS -.->|read/write| WVal
    SS -.->|read/write| LE

    VS -.->|read| Pos
    VS -.->|read| Con
Loading

4. Dependency Flow

Before: Tangled References

graph TD
    Node["LGraphNode"] <-->|"circular"| Graph["LGraph"]
    Graph <-->|"circular"| Subgraph["Subgraph"]
    Node -->|"this.graph._links"| Links["LLink Map"]
    Node -->|"this.graph.getNodeById"| Node
    Canvas["LGraphCanvas"] -->|"node.graph._version++"| Graph
    Canvas -->|"node.graph.remove(node)"| Graph
    Widget["BaseWidget"] -->|"useWidgetValueStore()"| Store1["Pinia Store"]
    Node -->|"useLayoutMutations()"| Store3["Layout Store"]
    Graph -->|"useLayoutMutations()"| Store3
    LLink["LLink"] -->|"useLayoutMutations()"| Store3

    style Node fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
    style Graph fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
    style Canvas fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
    style Widget fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
Loading

After: Unidirectional Data Flow

graph TD
    subgraph Systems["Systems"]
        RS["RenderSystem"]
        CS["ConnectivitySystem"]
        LS["LayoutSystem"]
        ES["ExecutionSystem"]
        SS["SerializationSystem"]
        VS["VersionSystem"]
    end

    subgraph Stores["Dedicated Stores (instance-scoped source of truth)"]
        LayoutStore["layoutStore"]
        WidgetValueStore["widgetValueStore"]
        DomWidgetStore["domWidgetStore"]
        NodeOutputStore["nodeOutputStore"]
    end

    Systems -->|"query/mutate"| Stores

    style Systems fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
    style Stores fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
Loading

Key differences:

  • No circular dependencies: entities are IDs, not class instances
  • 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

5. Problem Resolution Map

How each problem from entity-problems.md is resolved:

graph LR
    subgraph Problems["Current Problems"]
        P1["God Objects
(9k+ line classes)"]
        P2["Circular Deps
(LGraph ↔ Subgraph)"]
        P3["Mixed Concerns
(render + domain + state)"]
        P4["Inconsistent IDs
(number|string, no safety)"]
        P5["Demeter Violations
(graph._links, graph._version++)"]
        P6["Scattered Side Effects
(15+ _version++ sites)"]
        P7["Render-Time Mutations
(arrange() during draw)"]
    end

    subgraph Solutions["ECS Solutions"]
        S1["Components: small, focused
data objects (5-10 fields each)"]
        S2["Entities are just IDs.
No inheritance hierarchy.
Subgraph = node + component."]
        S3["One system per concern.
Systems don't overlap."]
        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."]
        S7["LayoutSystem runs in
update phase, before render.
RenderSystem is read-only."]
    end

    P1 --> S1
    P2 --> S2
    P3 --> S3
    P4 --> S4
    P5 --> S5
    P6 --> S6
    P7 --> S7

    style Problems fill:#4a1a1a,stroke:#6a2a2a,color:#e0e0e0
    style Solutions fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Loading

6. Migration Bridge

The migration is incremental. During the transition, a bridge layer keeps legacy class properties and ECS components in sync.

sequenceDiagram
    participant Legacy as Legacy Code
    participant Class as LGraphNode (class)
    participant Bridge as Bridge Adapter
    participant Store as layoutStore (ECS)
    participant New as New Code / Systems

    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->>Store: useLayoutMutations().moveNode(nodeId, { pos: [100, 200] })

    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->>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->>Store: layoutStore read for nodeId
    Store-->>New: { pos: [150, 250] }
Loading

Incremental layout/render separation

Layout extraction is staged by node family, not all-at-once:

  1. Mark arrange() as deprecated in render paths and collect call-site telemetry.
  2. Run LayoutSystem during update for a selected node family behind a feature gate.
  3. Keep a temporary compatibility fallback for un-migrated node families only.
  4. Remove the fallback once parity tests and frame-time budgets pass.

This keeps RenderSystem read-only for migrated families while preserving incremental rollout safety.

Migration Phases

graph LR
    subgraph Phase1["Phase 1: Types Only"]
        T1["Define string-key types
(WidgetId, nodeLocatorId)"]
        T2["Define component interfaces"]
        T3["Define store shapes"]
    end

    subgraph Phase2["Phase 2: Bridge"]
        B1["Bridge adapters
class ↔ store sync"]
        B2["New features use
stores as source"]
        B3["Old code unchanged"]
    end

    subgraph Phase3["Phase 3: Extract"]
        E1["Migrate one component
at a time"]
        E2["Deprecate class
properties"]
        E3["Systems replace
methods"]
    end

    subgraph Phase4["Phase 4: Clean"]
        C1["Remove bridge"]
        C2["Remove legacy classes"]
        C3["Systems are sole
behavior layer"]
    end

    Phase1 --> Phase2 --> Phase3 --> Phase4

    style Phase1 fill:#1a2a4a,stroke:#2a3a5a,color:#e0e0e0
    style Phase2 fill:#1a3a3a,stroke:#2a4a4a,color:#e0e0e0
    style Phase3 fill:#2a3a1a,stroke:#3a4a2a,color:#e0e0e0
    style Phase4 fill:#1a4a1a,stroke:#2a6a2a,color:#e0e0e0
Loading

This diagram is intentionally high level. The operational Phase 4 -> 5 entry criteria (compatibility matrix, bridge fallback usage, rollback requirements) are defined in ecs-migration-plan.md.