Compare commits

...

2 Commits

Author SHA1 Message Date
DrJKL
ec472d3e54 refactor(world): collapse substrate, colocate domain components
Phase 1 of temp/plans/world-consolidation.md.

Substrate-internal cleanup (src/world/world.ts):
- F2: inline InternalStore wrapper as closure-captured Map.
- F3: delete World.hasComponent (zero non-test consumers; tests collapse
  to expect(getComponent(...)).toBeUndefined()).
- F4: entitiesWith returns TEntity[] (snapshot) instead of generator.
  Makes Phase 2's clearGraph mutate-while-iterate inherently safe.
- D.1: add load-bearing SoA/AoS contract doc-comment to world.ts.
- D.2: add load-bearing deterministic-ID doc-comment to entityIds.ts.

Domain-component relocation (src/stores/widgetComponents.ts):
- S1: delete speculative WidgetIdentity, WidgetDisplayState, WidgetSchema.
- S2: drop <T = unknown> generic on WidgetValue (already discarded at
  component-key boundary).
- F1: move WidgetValueComponent, WidgetContainerComponent, widgetParent
  reverse-lookup into src/stores/widgetComponents.ts. Delete the entire
  src/world/components/ directory and src/world/worldIndex.ts.
- B1: delete unregisterWidgetInWorld (zero non-test consumers; Phase 2
  facade does not reintroduce one).

Industry ECS convention (bitECS, miniplex, koota, Bevy plugins) and
AGENTS.md DDD guidance both place components with the domain code that
owns them, not in the substrate.

End state: 6 substrate files, no components/ folder. Net non-test
diff -58 LOC (well under the 120 LOC Phase 1 budget).

Verification:
- pnpm typecheck, format:check clean.
- 54 tests pass across src/world, src/stores/widgetComponents,
  src/stores/widgetValueStore, src/composables/useUpstreamValue,
  src/lib/litegraph/src/widgets/BaseWidget.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019dd146-a3ad-734d-9825-0ab356454dd5
2026-04-27 16:52:34 -07:00
DrJKL
b35d1f3b58 feat(world): slice 1 - World substrate + WidgetValue bridge
Adds minimal ECS substrate (src/world/) per ADR 0008 and bridges into
useWidgetValueStore via widgetWorldBridge. setNodeId now writes the same
reactive _state into both the Pinia store and World, preserving shared
reactive identity for the 40+ extension ecosystem.

Subsystems added:
- src/world/{world,worldInstance,componentKey,brand,entityIds,worldIndex}
- src/world/components/{WidgetValue,WidgetContainer,WidgetIdentity,WidgetDisplayState,WidgetSchema}
- src/world/widgetWorldBridge

Consumers updated:
- BaseWidget.setNodeId: registers in World after store registration
- useUpstreamValue: reads via getNodeWidgetsThroughWorld

Cross-subgraph identity: widgetEntityId(rootGraphId, nodeId, name).

Amp-Thread-ID: https://ampcode.com/threads/T-019dd146-a3ad-734d-9825-0ab356454dd5
Co-authored-by: Amp <amp@ampcode.com>
2026-04-27 16:31:59 -07:00
13 changed files with 540 additions and 5 deletions

View File

@@ -1,9 +1,25 @@
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { reactive } from 'vue'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import { asGraphId } from '@/world/entityIds'
import { registerWidgetInWorld } from '@/world/widgetWorldBridge'
import { getWorld, resetWorldInstance } from '@/world/worldInstance'
import { boundsExtractor, singleValueExtractor } from './useUpstreamValue'
import {
boundsExtractor,
singleValueExtractor,
useUpstreamValue
} from './useUpstreamValue'
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: () => ({
canvas: {
graph: { rootGraph: { id: '00000000-0000-0000-0000-000000000001' } }
}
})
}))
function widget(name: string, value: unknown): WidgetState {
return { name, type: 'INPUT', value, nodeId: '1' as NodeId, options: {} }
@@ -116,3 +132,36 @@ describe('boundsExtractor', () => {
expect(extract(widgets, undefined)).toEqual(bounds)
})
})
describe('useUpstreamValue (World-backed read path)', () => {
it('reads upstream node widgets via the World, not the Pinia store', () => {
resetWorldInstance()
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
const state = reactive<WidgetState>({
nodeId: 'upstream-1' as NodeId,
name: 'value',
type: 'number',
value: 7,
options: {}
})
registerWidgetInWorld(getWorld(), graphId, state)
const upstreamValue = useUpstreamValue<number>(
() => ({ nodeId: 'upstream-1', outputName: 'value' }),
singleValueExtractor((v): v is number => typeof v === 'number')
)
expect(upstreamValue.value).toBe(7)
state.value = 11
expect(upstreamValue.value).toBe(11)
})
it('returns undefined when no upstream linkage is provided', () => {
resetWorldInstance()
const upstreamValue = useUpstreamValue(
() => undefined,
singleValueExtractor((v): v is number => typeof v === 'number')
)
expect(upstreamValue.value).toBeUndefined()
})
})

View File

@@ -1,10 +1,12 @@
import { computed } from 'vue'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { Bounds } from '@/renderer/core/layout/types'
import type { LinkedUpstreamInfo } from '@/types/simplifiedWidget'
import { asGraphId } from '@/world/entityIds'
import { getNodeWidgetsThroughWorld } from '@/world/widgetWorldBridge'
import { getWorld } from '@/world/worldInstance'
type ValueExtractor<T = unknown> = (
widgets: WidgetState[],
@@ -16,14 +18,17 @@ export function useUpstreamValue<T>(
extractValue: ValueExtractor<T>
) {
const canvasStore = useCanvasStore()
const widgetValueStore = useWidgetValueStore()
return computed(() => {
const upstream = getLinkedUpstream()
if (!upstream) return undefined
const graphId = canvasStore.canvas?.graph?.rootGraph.id
if (!graphId) return undefined
const widgets = widgetValueStore.getNodeWidgets(graphId, upstream.nodeId)
const widgets = getNodeWidgetsThroughWorld(
getWorld(),
asGraphId(graphId),
upstream.nodeId
)
return extractValue(widgets, upstream.outputName)
})
}

View File

@@ -20,6 +20,9 @@ import type {
import { usePromotionStore } from '@/stores/promotionStore'
import type { WidgetState } from '@/stores/widgetValueStore'
import { useWidgetValueStore } from '@/stores/widgetValueStore'
import { asGraphId } from '@/world/entityIds'
import { registerWidgetInWorld } from '@/world/widgetWorldBridge'
import { getWorld } from '@/world/worldInstance'
export interface DrawWidgetOptions {
/** The width of the node where this widget will be displayed. */
@@ -149,6 +152,11 @@ export abstract class BaseWidget<TWidget extends IBaseWidget = IBaseWidget>
value: this.value,
nodeId
})
registerWidgetInWorld(
getWorld(),
asGraphId(graphId),
this._state as WidgetState
)
}
constructor(widget: TWidget & { node: LGraphNode })

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import { asGraphId, nodeEntityId, widgetEntityId } from '@/world/entityIds'
import { registerWidgetInWorld } from '@/world/widgetWorldBridge'
import { createWorld } from '@/world/world'
import { widgetParent } from './widgetComponents'
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
function makeState(nodeId: string, name: string, value: unknown): WidgetState {
return { nodeId, name, type: 'number', value, options: {} }
}
describe('widgetParent', () => {
it('returns the owning node entity for a widget', () => {
const world = createWorld()
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
const widgetId = widgetEntityId(graphId, 'node-1', 'seed')
expect(widgetParent(world, widgetId)).toBe(nodeEntityId(graphId, 'node-1'))
})
it('returns undefined when no container references the widget', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 'orphan', 'seed')
expect(widgetParent(world, widgetId)).toBeUndefined()
})
})

View File

@@ -0,0 +1,47 @@
import { defineComponentKey } from '@/world/componentKey'
import type { NodeEntityId, WidgetEntityId } from '@/world/entityIds'
import type { World } from '@/world/world'
/**
* Per-widget value. The bridge to `useWidgetValueStore` shares the same
* reactive object reference so Vue tracking is preserved across both
* read paths. The wider WidgetState shape collapses to `WidgetValue` at
* the component-key boundary.
*/
export interface WidgetValue {
value: unknown
}
export const WidgetValueComponent = defineComponentKey<
WidgetValue,
WidgetEntityId
>('WidgetValue')
/**
* Node-side list of widget entity ids. Reverse lookup
* (`widget → node`) goes through `widgetParent()`.
*/
interface WidgetContainer {
widgetIds: WidgetEntityId[]
}
export const WidgetContainerComponent = defineComponentKey<
WidgetContainer,
NodeEntityId
>('WidgetContainer')
/**
* Reverse-lookup: which node owns this widget?
* Walks `WidgetContainer` components; O(nodes) — denormalised cache
* deferred until profiling demands it.
*/
export function widgetParent(
world: World,
widgetId: WidgetEntityId
): NodeEntityId | undefined {
for (const nodeId of world.entitiesWith(WidgetContainerComponent)) {
const container = world.getComponent(nodeId, WidgetContainerComponent)
if (container?.widgetIds.includes(widgetId)) return nodeId
}
return undefined
}

6
src/world/brand.ts Normal file
View File

@@ -0,0 +1,6 @@
/**
* Nominal-typed brand helper. Used by entity ID and component-key types so
* mixing kinds is a compile-time error.
*/
declare const brand: unique symbol
export type Brand<T, Tag extends string> = T & { readonly [brand]: Tag }

21
src/world/componentKey.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { EntityId } from './entityIds'
declare const componentKeyData: unique symbol
declare const componentKeyEntity: unique symbol
/**
* Nominal handle for a component type. The phantom params drive
* `world.getComponent` return-type inference and forbid cross-kind misuse
* (e.g. reading a `WidgetValue` off a `NodeEntityId` is a type error).
*/
export interface ComponentKey<TData, TEntity extends EntityId> {
readonly name: string
readonly [componentKeyData]?: TData
readonly [componentKeyEntity]?: TEntity
}
export function defineComponentKey<TData, TEntity extends EntityId>(
name: string
): ComponentKey<TData, TEntity> {
return { name } as ComponentKey<TData, TEntity>
}

50
src/world/entityIds.ts Normal file
View File

@@ -0,0 +1,50 @@
/**
* 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:
* 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
* widgetValueStore for the canonical keying contract.
*/
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
import type { Brand } from './brand'
export type GraphId = Brand<UUID, 'GraphId'>
export type NodeEntityId = Brand<string, 'NodeEntityId'>
export type WidgetEntityId = Brand<string, 'WidgetEntityId'>
export type EntityId = NodeEntityId | WidgetEntityId
/**
* Cast a UUID into a `GraphId`. Pinned at the seam so the rest of the world
* code does not have to know about UUID-vs-GraphId.
*/
export function asGraphId(id: UUID): GraphId {
return id as GraphId
}
/**
* Deterministic widget-entity id derived from `(rootGraphId, nodeId, name)`.
*
* Matches the existing `WidgetValueStore` keying contract: nodes viewed at
* different subgraph depths share the same widget state because consumers
* pass `rootGraph.id`. See [widgetValueStore.ts](../stores/widgetValueStore.ts).
*/
export function widgetEntityId(
graphId: GraphId,
nodeId: NodeId,
name: string
): WidgetEntityId {
return `widget:${graphId}:${nodeId}:${name}` as WidgetEntityId
}
/**
* Deterministic node-entity id derived from `(rootGraphId, nodeId)`.
*/
export function nodeEntityId(graphId: GraphId, nodeId: NodeId): NodeEntityId {
return `node:${graphId}:${nodeId}` as NodeEntityId
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
WidgetContainerComponent,
WidgetValueComponent
} from '@/stores/widgetComponents'
import type { WidgetState } from '@/stores/widgetValueStore'
import { asGraphId, nodeEntityId, widgetEntityId } from './entityIds'
import {
getNodeWidgetsThroughWorld,
registerWidgetInWorld
} from './widgetWorldBridge'
import { createWorld } from './world'
function makeState(nodeId: string, name: string, value: unknown): WidgetState {
return { nodeId, name, type: 'number', value, options: {} }
}
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
describe('registerWidgetInWorld', () => {
it('writes WidgetValue and updates WidgetContainer on the node', () => {
const world = createWorld()
const state = makeState('node-1', 'seed', 100)
registerWidgetInWorld(world, graphId, state)
const widgetId = widgetEntityId(graphId, 'node-1', 'seed')
const nodeId = nodeEntityId(graphId, 'node-1')
expect(world.getComponent(widgetId, WidgetValueComponent)?.value).toBe(100)
expect(
world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
).toEqual([widgetId])
})
it('shares object identity with the registered state (reactive bridge)', () => {
const world = createWorld()
const state = makeState('node-1', 'seed', 100)
registerWidgetInWorld(world, graphId, state)
state.value = 200
const widgetId = widgetEntityId(graphId, 'node-1', 'seed')
expect(world.getComponent(widgetId, WidgetValueComponent)?.value).toBe(200)
})
it('appends additional widgets to the same node container', () => {
const world = createWorld()
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
registerWidgetInWorld(world, graphId, makeState('node-1', 'cfg', 7))
const nodeId = nodeEntityId(graphId, 'node-1')
const ids = world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
expect(ids).toEqual([
widgetEntityId(graphId, 'node-1', 'seed'),
widgetEntityId(graphId, 'node-1', 'cfg')
])
})
it('does not duplicate widgetIds when the same widget re-registers', () => {
const world = createWorld()
const state = makeState('node-1', 'seed', 1)
registerWidgetInWorld(world, graphId, state)
registerWidgetInWorld(world, graphId, state)
const nodeId = nodeEntityId(graphId, 'node-1')
expect(
world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
).toHaveLength(1)
})
})
describe('getNodeWidgetsThroughWorld', () => {
it('returns all widget states attached to a node', () => {
const world = createWorld()
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
registerWidgetInWorld(world, graphId, makeState('node-1', 'cfg', 7))
registerWidgetInWorld(world, graphId, makeState('node-2', 'seed', 99))
const widgets = getNodeWidgetsThroughWorld(world, graphId, 'node-1')
expect(widgets.map((w) => w.name).sort()).toEqual(['cfg', 'seed'])
})
it('returns an empty array for unknown nodes', () => {
const world = createWorld()
expect(getNodeWidgetsThroughWorld(world, graphId, 'missing')).toEqual([])
})
})

View File

@@ -0,0 +1,62 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { WidgetValue } from '@/stores/widgetComponents'
import {
WidgetContainerComponent,
WidgetValueComponent
} from '@/stores/widgetComponents'
import type { WidgetState } from '@/stores/widgetValueStore'
import type { GraphId } from './entityIds'
import { nodeEntityId, widgetEntityId } from './entityIds'
import type { World } from './world'
/**
* Slice 1 bridge: writes widget entities into the World whenever
* `WidgetValueStore.registerWidget` runs. The `state` argument is the
* SAME reactive object the store holds — sharing identity preserves Vue
* tracking across both read paths.
*/
export function registerWidgetInWorld(
world: World,
graphId: GraphId,
state: WidgetState
): void {
const widgetId = widgetEntityId(graphId, state.nodeId, state.name)
// `state` IS the reactive object owned by `WidgetValueStore`; sharing the
// reference is intentional — Vue tracking flows through both read paths
// during the slice-1 bridge window. The wider WidgetState shape collapses
// to `WidgetValue` at the component-key boundary.
world.setComponent(widgetId, WidgetValueComponent, state as WidgetValue)
const nodeId = nodeEntityId(graphId, state.nodeId)
const container = world.getComponent(nodeId, WidgetContainerComponent)
if (!container) {
world.setComponent(nodeId, WidgetContainerComponent, {
widgetIds: [widgetId]
})
return
}
if (!container.widgetIds.includes(widgetId)) {
container.widgetIds.push(widgetId)
}
}
/**
* Look up all widget value states attached to a node, going through the
* World rather than the Pinia store. Used by `useUpstreamValue`.
*/
export function getNodeWidgetsThroughWorld(
world: World,
graphId: GraphId,
nodeId: NodeId
): WidgetState[] {
const owner = nodeEntityId(graphId, nodeId)
const container = world.getComponent(owner, WidgetContainerComponent)
if (!container) return []
const widgets: WidgetState[] = []
for (const widgetId of container.widgetIds) {
const value = world.getComponent(widgetId, WidgetValueComponent)
if (value) widgets.push(value as unknown as WidgetState)
}
return widgets
}

77
src/world/world.test.ts Normal file
View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import { defineComponentKey } from './componentKey'
import type { NodeEntityId, WidgetEntityId } from './entityIds'
import { asGraphId, nodeEntityId, widgetEntityId } from './entityIds'
import { createWorld } from './world'
const TestWidgetThing = defineComponentKey<{ value: number }, WidgetEntityId>(
'TestWidgetThing'
)
const TestNodeThing = defineComponentKey<{ tag: string }, NodeEntityId>(
'TestNodeThing'
)
describe('createWorld', () => {
const graphId = asGraphId('00000000-0000-0000-0000-000000000001')
it('round-trips set / get / remove', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 1, 'seed')
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(42)
world.removeComponent(widgetId, TestWidgetThing)
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
})
it('preserves shared object identity (reactive bridging)', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 1, 'seed')
const data = { value: 42 }
world.setComponent(widgetId, TestWidgetThing, data)
data.value = 99
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(99)
})
it('iterates entities for a given component key', () => {
const world = createWorld()
const a = widgetEntityId(graphId, 1, 'seed')
const b = widgetEntityId(graphId, 1, 'cfg')
world.setComponent(a, TestWidgetThing, { value: 1 })
world.setComponent(b, TestWidgetThing, { value: 2 })
const ids = world.entitiesWith(TestWidgetThing)
expect(ids.sort()).toEqual([a, b].sort())
})
it('keeps entity kinds isolated by ComponentKey phantom param', () => {
const world = createWorld()
const nodeId = nodeEntityId(graphId, 1)
world.setComponent(nodeId, TestNodeThing, { tag: 'foo' })
expect(world.getComponent(nodeId, TestNodeThing)?.tag).toBe('foo')
// Cross-kind access is a compile error, asserted via @ts-expect-error
// @ts-expect-error WidgetEntityId is not assignable to NodeEntityId
world.getComponent(widgetEntityId(graphId, 1, 'x'), TestNodeThing)
})
})
describe('widgetEntityId', () => {
it('is deterministic across (graphId, nodeId, name)', () => {
const g = asGraphId('00000000-0000-0000-0000-000000000001')
expect(widgetEntityId(g, 1, 'seed')).toBe(widgetEntityId(g, 1, 'seed'))
})
it('preserves cross-subgraph identity (root graph keying)', () => {
// Same root graph + same nodeId + same name = same entity, regardless of
// the subgraph depth from which the consumer reaches the node.
const g = asGraphId('00000000-0000-0000-0000-000000000001')
const fromRoot = widgetEntityId(g, 42, 'seed')
const fromNested = widgetEntityId(g, 42, 'seed')
expect(fromRoot).toBe(fromNested)
})
})

76
src/world/world.ts Normal file
View File

@@ -0,0 +1,76 @@
import { reactive } from 'vue'
import type { ComponentKey } from './componentKey'
import type { EntityId } from './entityIds'
/**
* Storage strategy: AoS (per-entity reactive object reference) backed by
* `reactive(Map)`. Component values are stored by reference; mutating a
* value's fields propagates to all readers through Vue's reactive proxy.
* `setComponent(id, key, ref)` is intentionally identity-preserving.
*
* NOT a sparse-set / archetype store. A future SoA migration would break
* the shared-reactive-identity contract that BaseWidget._state and the
* widgetValueStore facade rely on; do not refactor without revisiting
* those consumers. See temp/plans/world-consolidation.md §C.
*/
/**
* Minimal ECS world surface for slice 1. Exposes plain
* `getComponent`/`setComponent`/`removeComponent` plumbing only.
*
* Deferred (later slices): commands, transactions, undo, scope filtering,
* iteration helpers beyond `entitiesWith`.
*/
export interface World {
getComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>
): TData | undefined
setComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>,
data: TData
): void
removeComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>
): void
entitiesWith<TData, TEntity extends EntityId>(
key: ComponentKey<TData, TEntity>
): TEntity[]
}
export function createWorld(): World {
const store = new Map<string, Map<EntityId, unknown>>()
return {
getComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>
): TData | undefined {
const map = store.get(key.name)
if (!map) return undefined
return map.get(id) as TData | undefined
},
setComponent(id, key, data) {
let map = store.get(key.name)
if (!map) {
map = reactive(new Map<EntityId, unknown>()) as Map<EntityId, unknown>
store.set(key.name, map)
}
map.set(id, data)
},
removeComponent(id, key) {
const map = store.get(key.name)
if (map) map.delete(id)
},
entitiesWith<TData, TEntity extends EntityId>(
key: ComponentKey<TData, TEntity>
): TEntity[] {
const map = store.get(key.name)
if (!map) return []
return Array.from(map.keys()) as TEntity[]
}
}
}

View File

@@ -0,0 +1,17 @@
import type { World } from './world'
import { createWorld } from './world'
/**
* Module-singleton `World` for the editor process. Tests can call
* `resetWorldInstance()` to start clean.
*/
let instance: World | undefined
export function getWorld(): World {
if (!instance) instance = createWorld()
return instance
}
export function resetWorldInstance(): void {
instance = undefined
}