From 62ba80b2dfe48b8d1be7e1a768166c6485ee6e20 Mon Sep 17 00:00:00 2001 From: DrJKL Date: Wed, 6 May 2026 09:37:39 -0700 Subject: [PATCH] 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 --- .../schemas/previewExposureSchema.test.ts | 83 ++++++ src/core/schemas/previewExposureSchema.ts | 33 +++ src/core/schemas/promotionSchema.ts | 7 +- .../proxyWidgetQuarantineSchema.test.ts | 75 +++++ .../schemas/proxyWidgetQuarantineSchema.ts | 54 ++++ src/stores/previewExposureStore.test.ts | 258 ++++++++++++++++++ src/stores/previewExposureStore.ts | 173 ++++++++++++ 7 files changed, 681 insertions(+), 2 deletions(-) create mode 100644 src/core/schemas/previewExposureSchema.test.ts create mode 100644 src/core/schemas/previewExposureSchema.ts create mode 100644 src/core/schemas/proxyWidgetQuarantineSchema.test.ts create mode 100644 src/core/schemas/proxyWidgetQuarantineSchema.ts create mode 100644 src/stores/previewExposureStore.test.ts create mode 100644 src/stores/previewExposureStore.ts diff --git a/src/core/schemas/previewExposureSchema.test.ts b/src/core/schemas/previewExposureSchema.test.ts new file mode 100644 index 0000000000..091abb14a0 --- /dev/null +++ b/src/core/schemas/previewExposureSchema.test.ts @@ -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([]) + }) +}) diff --git a/src/core/schemas/previewExposureSchema.ts b/src/core/schemas/previewExposureSchema.ts new file mode 100644 index 0000000000..52cc8225c7 --- /dev/null +++ b/src/core/schemas/previewExposureSchema.ts @@ -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 + +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 [] +} diff --git a/src/core/schemas/promotionSchema.ts b/src/core/schemas/promotionSchema.ts index 4e7e506ef9..d46703d9fb 100644 --- a/src/core/schemas/promotionSchema.ts +++ b/src/core/schemas/promotionSchema.ts @@ -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 export function parseProxyWidgets( diff --git a/src/core/schemas/proxyWidgetQuarantineSchema.test.ts b/src/core/schemas/proxyWidgetQuarantineSchema.test.ts new file mode 100644 index 0000000000..59cd6e787d --- /dev/null +++ b/src/core/schemas/proxyWidgetQuarantineSchema.test.ts @@ -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([]) + }) +}) diff --git a/src/core/schemas/proxyWidgetQuarantineSchema.ts b/src/core/schemas/proxyWidgetQuarantineSchema.ts new file mode 100644 index 0000000000..3079da0e83 --- /dev/null +++ b/src/core/schemas/proxyWidgetQuarantineSchema.ts @@ -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, + '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 [] +} diff --git a/src/stores/previewExposureStore.test.ts b/src/stores/previewExposureStore.test.ts new file mode 100644 index 0000000000..a8016dbd08 --- /dev/null +++ b/src/stores/previewExposureStore.test.ts @@ -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 + 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' + }) + }) + }) +}) diff --git a/src/stores/previewExposureStore.ts b/src/stores/previewExposureStore.ts new file mode 100644 index 0000000000..fa8c43e2ed --- /dev/null +++ b/src/stores/previewExposureStore.ts @@ -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>()) + + function _getHostsForGraph( + rootGraphId: UUID + ): Map { + const hosts = exposures.value.get(rootGraphId) + if (hosts) return hosts + + const nextHosts = new Map() + 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 + } +})