mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-24 14:45:36 +00:00
feat: add subgraph promoted-widget ratchet PR-A scaffolding
Additive prep slice for ADR 0009 (subgraph promoted widgets use linked inputs). No production code path uses these modules yet. - previewExposureSchema: parse properties.previewExposures with the same warn-don't-throw + JSON-string fallback pattern as parseProxyWidgets - proxyWidgetQuarantineSchema: parse properties.proxyWidgetErrorQuarantine; reasons enum per ADR; hostValue typed as TWidgetValue at the API boundary (z.unknown internally), attemptedAtVersion pinned to 1 - previewExposureStore: host-scoped Pinia store keyed by (rootGraphId, hostNodeLocator); add/set/remove/move/clearGraph; resolveChain stubbed to single link (full nested-host walk in PR-B) - Export serializedProxyWidgetTupleSchema + SerializedProxyWidgetTuple from promotionSchema so quarantine entries can re-use the existing tuple shape Amp-Thread-ID: https://ampcode.com/threads/T-019dfad3-5f1f-7249-8fd0-e59d36099186 Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
83
src/core/schemas/previewExposureSchema.test.ts
Normal file
83
src/core/schemas/previewExposureSchema.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parsePreviewExposures } from './previewExposureSchema'
|
||||
|
||||
describe(parsePreviewExposures, () => {
|
||||
it('parses a valid array of preview exposure objects', () => {
|
||||
const input = [
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
},
|
||||
{
|
||||
name: 'preview2',
|
||||
sourceNodeId: '7',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
expect(parsePreviewExposures(input)).toEqual(input)
|
||||
})
|
||||
|
||||
it('parses JSON-string input', () => {
|
||||
const input = [
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
]
|
||||
expect(parsePreviewExposures(JSON.stringify(input))).toEqual(input)
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
expect(parsePreviewExposures(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for malformed JSON string', () => {
|
||||
expect(parsePreviewExposures('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(
|
||||
parsePreviewExposures({
|
||||
name: 'preview',
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
).toEqual([])
|
||||
expect(parsePreviewExposures(42)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when entries are missing required fields', () => {
|
||||
expect(
|
||||
parsePreviewExposures([{ name: 'preview', sourceNodeId: '5' }])
|
||||
).toEqual([])
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{ sourceNodeId: '5', sourcePreviewName: '$$canvas-image-preview' }
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when entries have wrong types', () => {
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{
|
||||
name: 123,
|
||||
sourceNodeId: '5',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
expect(
|
||||
parsePreviewExposures([
|
||||
{
|
||||
name: 'preview',
|
||||
sourceNodeId: 5,
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
33
src/core/schemas/previewExposureSchema.ts
Normal file
33
src/core/schemas/previewExposureSchema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
export const previewExposureSchema = z.object({
|
||||
name: z.string(),
|
||||
sourceNodeId: z.string(),
|
||||
sourcePreviewName: z.string()
|
||||
})
|
||||
export type PreviewExposure = z.infer<typeof previewExposureSchema>
|
||||
|
||||
export const previewExposuresPropertySchema = z.array(previewExposureSchema)
|
||||
|
||||
export function parsePreviewExposures(
|
||||
property: NodeProperty | undefined
|
||||
): PreviewExposure[] {
|
||||
try {
|
||||
if (typeof property === 'string') property = JSON.parse(property)
|
||||
const result = previewExposuresPropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(
|
||||
`Invalid assignment for properties.previewExposures:\n${error}`
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.previewExposures:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -3,11 +3,14 @@ import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
|
||||
const proxyWidgetTupleSchema = z.union([
|
||||
export const serializedProxyWidgetTupleSchema = z.union([
|
||||
z.tuple([z.string(), z.string(), z.string()]),
|
||||
z.tuple([z.string(), z.string()])
|
||||
])
|
||||
const proxyWidgetsPropertySchema = z.array(proxyWidgetTupleSchema)
|
||||
export type SerializedProxyWidgetTuple = z.infer<
|
||||
typeof serializedProxyWidgetTupleSchema
|
||||
>
|
||||
const proxyWidgetsPropertySchema = z.array(serializedProxyWidgetTupleSchema)
|
||||
type ProxyWidgetsProperty = z.infer<typeof proxyWidgetsPropertySchema>
|
||||
|
||||
export function parseProxyWidgets(
|
||||
|
||||
75
src/core/schemas/proxyWidgetQuarantineSchema.test.ts
Normal file
75
src/core/schemas/proxyWidgetQuarantineSchema.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { parseProxyWidgetErrorQuarantine } from './proxyWidgetQuarantineSchema'
|
||||
import type { ProxyWidgetQuarantineReason } from './proxyWidgetQuarantineSchema'
|
||||
|
||||
const baseEntry = {
|
||||
originalEntry: ['10', 'seed'] as [string, string],
|
||||
reason: 'missingSourceNode' as ProxyWidgetQuarantineReason,
|
||||
attemptedAtVersion: 1 as const
|
||||
}
|
||||
|
||||
describe(parseProxyWidgetErrorQuarantine, () => {
|
||||
it('parses a valid entry without hostValue', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine([baseEntry])).toEqual([baseEntry])
|
||||
})
|
||||
|
||||
it('parses a valid entry with hostValue', () => {
|
||||
const entry = { ...baseEntry, hostValue: 42 }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses a 2-tuple originalEntry', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['10', 'seed'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses a 3-tuple originalEntry', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['3', 'text', '1'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it.each([
|
||||
'missingSourceNode',
|
||||
'missingSourceWidget',
|
||||
'missingSubgraphInput',
|
||||
'ambiguousSubgraphInput',
|
||||
'unlinkedSourceWidget',
|
||||
'primitiveBypassFailed'
|
||||
] as const)('parses reason %s', (reason) => {
|
||||
const entry = { ...baseEntry, reason }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([entry])
|
||||
})
|
||||
|
||||
it('parses JSON-string input', () => {
|
||||
const input = JSON.stringify([baseEntry])
|
||||
expect(parseProxyWidgetErrorQuarantine(input)).toEqual([baseEntry])
|
||||
})
|
||||
|
||||
it('returns empty array for undefined', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine(undefined)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for malformed JSON string', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine('not-json{')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array for non-array input', () => {
|
||||
expect(parseProxyWidgetErrorQuarantine(baseEntry)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when attemptedAtVersion is not 1', () => {
|
||||
const entry = { ...baseEntry, attemptedAtVersion: 2 }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when reason is not in the enum', () => {
|
||||
const entry = { ...baseEntry, reason: 'somethingElse' }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
|
||||
it('returns empty array when originalEntry is malformed', () => {
|
||||
const entry = { ...baseEntry, originalEntry: ['only-one'] }
|
||||
expect(parseProxyWidgetErrorQuarantine([entry])).toEqual([])
|
||||
})
|
||||
})
|
||||
54
src/core/schemas/proxyWidgetQuarantineSchema.ts
Normal file
54
src/core/schemas/proxyWidgetQuarantineSchema.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod'
|
||||
import { fromZodError } from 'zod-validation-error'
|
||||
|
||||
import type { NodeProperty } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type { TWidgetValue } from '@/lib/litegraph/src/types/widgets'
|
||||
|
||||
import { serializedProxyWidgetTupleSchema } from './promotionSchema'
|
||||
|
||||
export const proxyWidgetQuarantineReasonSchema = z.enum([
|
||||
'missingSourceNode',
|
||||
'missingSourceWidget',
|
||||
'missingSubgraphInput',
|
||||
'ambiguousSubgraphInput',
|
||||
'unlinkedSourceWidget',
|
||||
'primitiveBypassFailed'
|
||||
])
|
||||
export type ProxyWidgetQuarantineReason = z.infer<
|
||||
typeof proxyWidgetQuarantineReasonSchema
|
||||
>
|
||||
|
||||
export const proxyWidgetErrorQuarantineEntrySchema = z.object({
|
||||
originalEntry: serializedProxyWidgetTupleSchema,
|
||||
reason: proxyWidgetQuarantineReasonSchema,
|
||||
hostValue: z.unknown().optional(),
|
||||
attemptedAtVersion: z.literal(1)
|
||||
})
|
||||
|
||||
export const proxyWidgetErrorQuarantinePropertySchema = z.array(
|
||||
proxyWidgetErrorQuarantineEntrySchema
|
||||
)
|
||||
|
||||
export type ProxyWidgetErrorQuarantineEntry = Omit<
|
||||
z.infer<typeof proxyWidgetErrorQuarantineEntrySchema>,
|
||||
'hostValue'
|
||||
> & { hostValue?: TWidgetValue }
|
||||
|
||||
export function parseProxyWidgetErrorQuarantine(
|
||||
property: NodeProperty | undefined
|
||||
): ProxyWidgetErrorQuarantineEntry[] {
|
||||
try {
|
||||
const result = proxyWidgetErrorQuarantinePropertySchema.safeParse(
|
||||
typeof property === 'string' ? JSON.parse(property) : property
|
||||
)
|
||||
if (result.success) return result.data as ProxyWidgetErrorQuarantineEntry[]
|
||||
|
||||
const error = fromZodError(result.error)
|
||||
console.warn(
|
||||
`Invalid assignment for properties.proxyWidgetErrorQuarantine:\n${error}`
|
||||
)
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse properties.proxyWidgetErrorQuarantine:', e)
|
||||
}
|
||||
return []
|
||||
}
|
||||
258
src/stores/previewExposureStore.test.ts
Normal file
258
src/stores/previewExposureStore.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createNodeLocatorId } from '@/types/nodeIdentification'
|
||||
|
||||
import { usePreviewExposureStore } from './previewExposureStore'
|
||||
|
||||
describe(usePreviewExposureStore, () => {
|
||||
let store: ReturnType<typeof usePreviewExposureStore>
|
||||
const rootGraphA = 'root-graph-a' as UUID
|
||||
const rootGraphB = 'root-graph-b' as UUID
|
||||
const hostA = createNodeLocatorId(rootGraphA, 7)
|
||||
const hostB = createNodeLocatorId(rootGraphA, 8)
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createTestingPinia({ stubActions: false }))
|
||||
store = usePreviewExposureStore()
|
||||
})
|
||||
|
||||
describe('getExposures', () => {
|
||||
it('returns empty readonly array for unknown host', () => {
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('addExposure', () => {
|
||||
it('appends a new exposure and returns it with name = sourcePreviewName when no collision', () => {
|
||||
const entry = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
|
||||
expect(entry).toEqual({
|
||||
name: '$$canvas-image-preview',
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: '$$canvas-image-preview'
|
||||
})
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([entry])
|
||||
})
|
||||
|
||||
it('disambiguates name collisions via nextUniqueName', () => {
|
||||
const first = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
const second = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '43',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
const third = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '44',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
expect(first.name).toBe('preview')
|
||||
expect(second.name).toBe('preview_1')
|
||||
expect(third.name).toBe('preview_2')
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'preview',
|
||||
'preview_1',
|
||||
'preview_2'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('setExposures', () => {
|
||||
it('replaces the array for the host', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
const next = [
|
||||
{
|
||||
name: 'replaced',
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'other'
|
||||
}
|
||||
]
|
||||
store.setExposures(rootGraphA, hostA, next)
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual(next)
|
||||
})
|
||||
|
||||
it('clears the host bucket when given an empty array', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
store.setExposures(rootGraphA, hostA, [])
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeExposure', () => {
|
||||
beforeEach(() => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '43',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
})
|
||||
|
||||
it('removes the matching entry by name', () => {
|
||||
store.removeExposure(rootGraphA, hostA, 'preview_1')
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'preview'
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op when no entry matches', () => {
|
||||
const before = store.getExposures(rootGraphA, hostA)
|
||||
store.removeExposure(rootGraphA, hostA, 'does-not-exist')
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual(before)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moveExposure', () => {
|
||||
beforeEach(() => {
|
||||
store.setExposures(rootGraphA, hostA, [
|
||||
{ name: 'a', sourceNodeId: '1', sourcePreviewName: 'a' },
|
||||
{ name: 'b', sourceNodeId: '2', sourcePreviewName: 'b' },
|
||||
{ name: 'c', sourceNodeId: '3', sourcePreviewName: 'c' }
|
||||
])
|
||||
})
|
||||
|
||||
it('reorders entries from -> to', () => {
|
||||
store.moveExposure(rootGraphA, hostA, 0, 2)
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'b',
|
||||
'c',
|
||||
'a'
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op for equal indices', () => {
|
||||
store.moveExposure(rootGraphA, hostA, 1, 1)
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c'
|
||||
])
|
||||
})
|
||||
|
||||
it('is a no-op for out-of-bounds indices', () => {
|
||||
store.moveExposure(rootGraphA, hostA, -1, 2)
|
||||
store.moveExposure(rootGraphA, hostA, 0, 5)
|
||||
expect(store.getExposures(rootGraphA, hostA).map((e) => e.name)).toEqual([
|
||||
'a',
|
||||
'b',
|
||||
'c'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearGraph', () => {
|
||||
it('removes all hosts under the rootGraphId without affecting others', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '1',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostB, {
|
||||
sourceNodeId: '2',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
const hostInB = createNodeLocatorId(rootGraphB, 7)
|
||||
store.addExposure(rootGraphB, hostInB, {
|
||||
sourceNodeId: '3',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
|
||||
store.clearGraph(rootGraphA)
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toEqual([])
|
||||
expect(store.getExposures(rootGraphA, hostB)).toEqual([])
|
||||
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isolation between (rootGraphId, hostNodeLocator) pairs', () => {
|
||||
it('keeps separate buckets per host and per root graph', () => {
|
||||
store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '1',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
store.addExposure(rootGraphA, hostB, {
|
||||
sourceNodeId: '2',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
const hostInB = createNodeLocatorId(rootGraphB, 7)
|
||||
store.addExposure(rootGraphB, hostInB, {
|
||||
sourceNodeId: '3',
|
||||
sourcePreviewName: 'p'
|
||||
})
|
||||
|
||||
expect(store.getExposures(rootGraphA, hostA)).toHaveLength(1)
|
||||
expect(store.getExposures(rootGraphA, hostB)).toHaveLength(1)
|
||||
expect(store.getExposures(rootGraphB, hostInB)).toHaveLength(1)
|
||||
expect(store.getExposures(rootGraphA, hostA)[0].sourceNodeId).toBe('1')
|
||||
expect(store.getExposures(rootGraphA, hostB)[0].sourceNodeId).toBe('2')
|
||||
expect(store.getExposures(rootGraphB, hostInB)[0].sourceNodeId).toBe('3')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveChain', () => {
|
||||
it('returns a single-link chain for an existing exposure', () => {
|
||||
const entry = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
})
|
||||
|
||||
expect(store.resolveChain(rootGraphA, hostA, entry.name)).toEqual({
|
||||
rootGraphId: rootGraphA,
|
||||
hostNodeLocator: hostA,
|
||||
name: 'preview',
|
||||
source: {
|
||||
sourceNodeId: '42',
|
||||
sourcePreviewName: 'preview'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('returns undefined when the named exposure is missing', () => {
|
||||
expect(store.resolveChain(rootGraphA, hostA, 'absent')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not yet walk nested-host chains (PR-A stub)', () => {
|
||||
// Set up a host whose exposure points to a sourceNodeId that itself
|
||||
// has its own preview exposure registered. PR-A must surface only the
|
||||
// direct source — no recursion through the inner host.
|
||||
const innerHost = createNodeLocatorId(rootGraphA, 99)
|
||||
store.addExposure(rootGraphA, innerHost, {
|
||||
sourceNodeId: 'inner-leaf',
|
||||
sourcePreviewName: 'inner-preview'
|
||||
})
|
||||
const outer = store.addExposure(rootGraphA, hostA, {
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'outer-preview'
|
||||
})
|
||||
|
||||
const resolved = store.resolveChain(rootGraphA, hostA, outer.name)
|
||||
|
||||
expect(resolved?.source).toEqual({
|
||||
sourceNodeId: '99',
|
||||
sourcePreviewName: 'outer-preview'
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
173
src/stores/previewExposureStore.ts
Normal file
173
src/stores/previewExposureStore.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { PreviewExposure } from '@/core/schemas/previewExposureSchema'
|
||||
import { nextUniqueName } from '@/lib/litegraph/src/strings'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
|
||||
const EMPTY_EXPOSURES: readonly PreviewExposure[] = Object.freeze([])
|
||||
|
||||
/**
|
||||
* A resolved chain of preview exposures from a host node down to the originating
|
||||
* source preview.
|
||||
*
|
||||
* @remarks
|
||||
* PR-A skeleton type: this currently represents a single-link chain (the
|
||||
* direct exposure on the host). PR-B will expand this to model a full
|
||||
* walk through nested subgraph hosts so that an interior host's exposure
|
||||
* may resolve through one or more enclosing hosts to its ultimate source.
|
||||
*/
|
||||
export interface ResolvedPreviewChain {
|
||||
rootGraphId: UUID
|
||||
hostNodeLocator: string
|
||||
name: string
|
||||
source: {
|
||||
sourceNodeId: string
|
||||
sourcePreviewName: string
|
||||
}
|
||||
}
|
||||
|
||||
export const usePreviewExposureStore = defineStore('previewExposure', () => {
|
||||
// Keyed by (rootGraphId, hostNodeLocator).
|
||||
const exposures = ref(new Map<UUID, Map<string, PreviewExposure[]>>())
|
||||
|
||||
function _getHostsForGraph(
|
||||
rootGraphId: UUID
|
||||
): Map<string, PreviewExposure[]> {
|
||||
const hosts = exposures.value.get(rootGraphId)
|
||||
if (hosts) return hosts
|
||||
|
||||
const nextHosts = new Map<string, PreviewExposure[]>()
|
||||
exposures.value.set(rootGraphId, nextHosts)
|
||||
return nextHosts
|
||||
}
|
||||
|
||||
function _getExposuresRef(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string
|
||||
): PreviewExposure[] | undefined {
|
||||
return exposures.value.get(rootGraphId)?.get(hostNodeLocator)
|
||||
}
|
||||
|
||||
function getExposures(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string
|
||||
): readonly PreviewExposure[] {
|
||||
return _getExposuresRef(rootGraphId, hostNodeLocator) ?? EMPTY_EXPOSURES
|
||||
}
|
||||
|
||||
function setExposures(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
next: readonly PreviewExposure[]
|
||||
): void {
|
||||
const hosts = _getHostsForGraph(rootGraphId)
|
||||
if (next.length === 0) {
|
||||
hosts.delete(hostNodeLocator)
|
||||
if (hosts.size === 0) exposures.value.delete(rootGraphId)
|
||||
return
|
||||
}
|
||||
hosts.set(hostNodeLocator, [...next])
|
||||
}
|
||||
|
||||
function addExposure(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
source: { sourceNodeId: string; sourcePreviewName: string }
|
||||
): PreviewExposure {
|
||||
const hosts = _getHostsForGraph(rootGraphId)
|
||||
const current = hosts.get(hostNodeLocator) ?? []
|
||||
const existingNames = current.map((e) => e.name)
|
||||
const name = nextUniqueName(source.sourcePreviewName, existingNames)
|
||||
const entry: PreviewExposure = {
|
||||
name,
|
||||
sourceNodeId: source.sourceNodeId,
|
||||
sourcePreviewName: source.sourcePreviewName
|
||||
}
|
||||
hosts.set(hostNodeLocator, [...current, entry])
|
||||
return entry
|
||||
}
|
||||
|
||||
function removeExposure(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string
|
||||
): void {
|
||||
const current = _getExposuresRef(rootGraphId, hostNodeLocator)
|
||||
if (!current?.length) return
|
||||
const next = current.filter((e) => e.name !== name)
|
||||
if (next.length === current.length) return
|
||||
setExposures(rootGraphId, hostNodeLocator, next)
|
||||
}
|
||||
|
||||
function moveExposure(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
fromIndex: number,
|
||||
toIndex: number
|
||||
): void {
|
||||
const hosts = exposures.value.get(rootGraphId)
|
||||
const current = hosts?.get(hostNodeLocator)
|
||||
if (!hosts || !current?.length) return
|
||||
|
||||
if (
|
||||
fromIndex < 0 ||
|
||||
fromIndex >= current.length ||
|
||||
toIndex < 0 ||
|
||||
toIndex >= current.length ||
|
||||
fromIndex === toIndex
|
||||
)
|
||||
return
|
||||
|
||||
const next = [...current]
|
||||
const [entry] = next.splice(fromIndex, 1)
|
||||
next.splice(toIndex, 0, entry)
|
||||
hosts.set(hostNodeLocator, next)
|
||||
}
|
||||
|
||||
function clearGraph(rootGraphId: UUID): void {
|
||||
exposures.value.delete(rootGraphId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the chain of exposures from a host down to the originating source preview.
|
||||
*
|
||||
* @remarks
|
||||
* PR-A stub: returns a single-link chain wrapping the direct exposure if it
|
||||
* exists on `(rootGraphId, hostNodeLocator)`, otherwise `undefined`. No nested
|
||||
* subgraph host walking is performed yet.
|
||||
*
|
||||
* TODO(PR-B): implement full nested-host chain walking so that when an
|
||||
* exposure's `sourceNodeId` itself refers to a subgraph host, this method
|
||||
* follows the named exposure on that interior host recursively until it
|
||||
* reaches a leaf (non-host) source.
|
||||
*/
|
||||
function resolveChain(
|
||||
rootGraphId: UUID,
|
||||
hostNodeLocator: string,
|
||||
name: string
|
||||
): ResolvedPreviewChain | undefined {
|
||||
const current = _getExposuresRef(rootGraphId, hostNodeLocator)
|
||||
const entry = current?.find((e) => e.name === name)
|
||||
if (!entry) return undefined
|
||||
return {
|
||||
rootGraphId,
|
||||
hostNodeLocator,
|
||||
name: entry.name,
|
||||
source: {
|
||||
sourceNodeId: entry.sourceNodeId,
|
||||
sourcePreviewName: entry.sourcePreviewName
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getExposures,
|
||||
setExposures,
|
||||
addExposure,
|
||||
removeExposure,
|
||||
moveExposure,
|
||||
clearGraph,
|
||||
resolveChain
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user