Compare commits

...

1 Commits

Author SHA1 Message Date
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
17 changed files with 609 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 })

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>
}

View File

@@ -0,0 +1,16 @@
import { defineComponentKey } from '../componentKey'
import type { NodeEntityId, WidgetEntityId } from '../entityIds'
/**
* Node-side list of widget entity ids. Replaces a back-reference from
* `WidgetIdentity` to its parent node. Reverse lookup
* (`widget → node`) goes through `WorldIndex.widgetParent()`.
*/
export interface WidgetContainer {
widgetIds: WidgetEntityId[]
}
export const WidgetContainerComponent = defineComponentKey<
WidgetContainer,
NodeEntityId
>('WidgetContainer')

View File

@@ -0,0 +1,12 @@
import { defineComponentKey } from '../componentKey'
import type { WidgetEntityId } from '../entityIds'
export interface WidgetDisplayState {
label?: string
disabled?: boolean
}
export const WidgetDisplayStateComponent = defineComponentKey<
WidgetDisplayState,
WidgetEntityId
>('WidgetDisplayState')

View File

@@ -0,0 +1,17 @@
import { defineComponentKey } from '../componentKey'
import type { WidgetEntityId } from '../entityIds'
/**
* Static identity for a widget entity. Intentionally has NO `parentNodeId`
* back-reference — reverse lookup goes via the node-side `WidgetContainer`
* component plus `WorldIndex.widgetParent()`. See ADR 0008.
*/
export interface WidgetIdentity {
name: string
widgetType: string
}
export const WidgetIdentityComponent = defineComponentKey<
WidgetIdentity,
WidgetEntityId
>('WidgetIdentity')

View File

@@ -0,0 +1,14 @@
import type { IWidgetOptions } from '@/lib/litegraph/src/types/widgets'
import { defineComponentKey } from '../componentKey'
import type { WidgetEntityId } from '../entityIds'
export interface WidgetSchema {
options: IWidgetOptions
serialize?: boolean
}
export const WidgetSchemaComponent = defineComponentKey<
WidgetSchema,
WidgetEntityId
>('WidgetSchema')

View File

@@ -0,0 +1,16 @@
import { defineComponentKey } from '../componentKey'
import type { WidgetEntityId } from '../entityIds'
/**
* Per-widget value. The bridge to `WidgetValueStore` shares the same
* reactive object reference so Vue tracking is preserved across both
* read paths during the migration window.
*/
export interface WidgetValue<T = unknown> {
value: T
}
export const WidgetValueComponent = defineComponentKey<
WidgetValue,
WidgetEntityId
>('WidgetValue')

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

@@ -0,0 +1,40 @@
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,126 @@
import { describe, expect, it } from 'vitest'
import type { WidgetState } from '@/stores/widgetValueStore'
import { WidgetContainerComponent } from './components/WidgetContainer'
import { WidgetValueComponent } from './components/WidgetValue'
import { asGraphId, nodeEntityId, widgetEntityId } from './entityIds'
import { createWorld } from './world'
import { worldIndex } from './worldIndex'
import {
getNodeWidgetsThroughWorld,
registerWidgetInWorld,
unregisterWidgetInWorld
} from './widgetWorldBridge'
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('unregisterWidgetInWorld', () => {
it('removes the widget value and updates the container', () => {
const world = createWorld()
registerWidgetInWorld(world, graphId, makeState('node-1', 'seed', 1))
registerWidgetInWorld(world, graphId, makeState('node-1', 'cfg', 7))
unregisterWidgetInWorld(world, graphId, 'node-1', 'seed')
expect(
world.getComponent(
widgetEntityId(graphId, 'node-1', 'seed'),
WidgetValueComponent
)
).toBeUndefined()
const nodeId = nodeEntityId(graphId, 'node-1')
expect(
world.getComponent(nodeId, WidgetContainerComponent)?.widgetIds
).toEqual([widgetEntityId(graphId, 'node-1', 'cfg')])
})
})
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([])
})
})
describe('worldIndex.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(worldIndex.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(worldIndex.widgetParent(world, widgetId)).toBeUndefined()
})
})

View File

@@ -0,0 +1,76 @@
import type { NodeId } from '@/lib/litegraph/src/LGraphNode'
import type { WidgetState } from '@/stores/widgetValueStore'
import { WidgetContainerComponent } from './components/WidgetContainer'
import type { WidgetValue } from './components/WidgetValue'
import { WidgetValueComponent } from './components/WidgetValue'
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)
}
}
export function unregisterWidgetInWorld(
world: World,
graphId: GraphId,
nodeId: NodeId,
name: string
): void {
const widgetId = widgetEntityId(graphId, nodeId, name)
world.removeComponent(widgetId, WidgetValueComponent)
const owner = nodeEntityId(graphId, nodeId)
const container = world.getComponent(owner, WidgetContainerComponent)
if (!container) return
const idx = container.widgetIds.indexOf(widgetId)
if (idx >= 0) container.widgetIds.splice(idx, 1)
}
/**
* 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
}

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

@@ -0,0 +1,79 @@
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 / has / remove', () => {
const world = createWorld()
const widgetId = widgetEntityId(graphId, 1, 'seed')
expect(world.hasComponent(widgetId, TestWidgetThing)).toBe(false)
expect(world.getComponent(widgetId, TestWidgetThing)).toBeUndefined()
world.setComponent(widgetId, TestWidgetThing, { value: 42 })
expect(world.hasComponent(widgetId, TestWidgetThing)).toBe(true)
expect(world.getComponent(widgetId, TestWidgetThing)?.value).toBe(42)
world.removeComponent(widgetId, TestWidgetThing)
expect(world.hasComponent(widgetId, TestWidgetThing)).toBe(false)
})
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)
})
})

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

@@ -0,0 +1,81 @@
import { reactive } from 'vue'
import type { ComponentKey } from './componentKey'
import type { EntityId } from './entityIds'
/**
* 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
hasComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>
): boolean
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>
): IterableIterator<TEntity>
}
interface InternalStore {
store: Map<string, Map<EntityId, unknown>>
}
function getOrCreateMap(
internal: InternalStore,
keyName: string
): Map<EntityId, unknown> {
const existing = internal.store.get(keyName)
if (existing) return existing
const next = reactive(new Map<EntityId, unknown>()) as Map<EntityId, unknown>
internal.store.set(keyName, next)
return next
}
export function createWorld(): World {
const internal: InternalStore = { store: new Map() }
return {
getComponent<TData, TEntity extends EntityId>(
id: TEntity,
key: ComponentKey<TData, TEntity>
): TData | undefined {
const map = internal.store.get(key.name)
if (!map) return undefined
return map.get(id) as TData | undefined
},
hasComponent(id, key) {
const map = internal.store.get(key.name)
return map ? map.has(id) : false
},
setComponent(id, key, data) {
const map = getOrCreateMap(internal, key.name)
map.set(id, data)
},
removeComponent(id, key) {
const map = internal.store.get(key.name)
if (map) map.delete(id)
},
*entitiesWith(key) {
const map = internal.store.get(key.name)
if (!map) return
for (const id of map.keys()) yield id as never
}
}
}

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

@@ -0,0 +1,21 @@
import { WidgetContainerComponent } from './components/WidgetContainer'
import type { NodeEntityId, WidgetEntityId } from './entityIds'
import type { World } from './world'
/**
* Read-side helpers that compute reverse lookups from canonical components.
* Slice 1 implements `widgetParent` only. Optimisations (denormalised
* caches) are deferred until profiling demands them.
*/
export const worldIndex = {
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
}
}

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
}