From 83b2a5772e1a7e54481cbd999702c4afda6336c5 Mon Sep 17 00:00:00 2001 From: Alex Grozav Date: Thu, 18 Sep 2025 12:11:04 +0100 Subject: [PATCH] fix(editor): Update node execution `itemCount` to support multiple outputs (no-changelog) (#19646) --- cypress/utils/executions.ts | 15 +- packages/@n8n/api-types/src/push/execution.ts | 14 +- .../execution-lifecycle-hooks.test.ts | 18 +- .../execution-lifecycle-hooks.ts | 10 +- .../get-item-count-by-connection-type.test.ts | 145 +++++++++++++ .../get-item-count-by-connection-type.ts | 22 ++ .../handlers/nodeExecuteAfter.test.ts | 190 ++++++++++++++++++ .../handlers/nodeExecuteAfter.ts | 21 +- .../handlers/nodeExecuteAfterData.test.ts | 2 +- .../logs/components/LogsPanel.test.ts | 2 +- .../src/stores/workflows.store.test.ts | 2 +- packages/workflow/src/index.ts | 1 + packages/workflow/src/type-guards.ts | 20 +- 13 files changed, 435 insertions(+), 27 deletions(-) create mode 100644 packages/cli/src/utils/__tests__/get-item-count-by-connection-type.test.ts create mode 100644 packages/cli/src/utils/get-item-count-by-connection-type.ts create mode 100644 packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts diff --git a/cypress/utils/executions.ts b/cypress/utils/executions.ts index a25a50743d..d164dec5ff 100644 --- a/cypress/utils/executions.ts +++ b/cypress/utils/executions.ts @@ -118,18 +118,27 @@ export function runMockWorkflowExecution({ data: pick(nodeRunData, ['startTime', 'executionIndex', 'source', 'hints']), }); const { data: _, ...taskData } = nodeRunData; - const itemCount = nodeRunData.data?.main?.[0]?.length ?? 0; + const itemCountByConnectionType: Record = {}; + for (const connectionType of Object.keys(nodeRunData.data ?? {})) { + const connectionData = nodeRunData.data?.[connectionType]; + if (Array.isArray(connectionData)) { + itemCountByConnectionType[connectionType] = connectionData.map((d) => (d ? d.length : 0)); + } else { + itemCountByConnectionType[connectionType] = [0]; + } + } + cy.push('nodeExecuteAfter', { executionId, nodeName, data: taskData, - itemCount, + itemCountByConnectionType, }); cy.push('nodeExecuteAfterData', { executionId, nodeName, data: nodeRunData, - itemCount, + itemCountByConnectionType, }); }); diff --git a/packages/@n8n/api-types/src/push/execution.ts b/packages/@n8n/api-types/src/push/execution.ts index 23ef37e928..b2a9b5b66d 100644 --- a/packages/@n8n/api-types/src/push/execution.ts +++ b/packages/@n8n/api-types/src/push/execution.ts @@ -2,6 +2,7 @@ import type { ExecutionStatus, ITaskData, ITaskStartedData, + NodeConnectionType, WorkflowExecuteMode, } from 'n8n-workflow'; @@ -61,8 +62,15 @@ export type NodeExecuteAfter = { data: { executionId: string; nodeName: string; - data: Omit; - itemCount: number; + /** + * The data field for task data in `NodeExecuteAfter` is always trimmed (undefined). + */ + data: ITaskData; + /** + * The number of items per output connection type. This is needed so that the frontend + * can know how many items to expect when receiving the `NodeExecuteAfterData` message. + */ + itemCountByConnectionType: Partial>; }; }; @@ -81,7 +89,7 @@ export type NodeExecuteAfterData = { * Later we fetch the entire execution data and fill in any placeholders. */ data: ITaskData; - itemCount: number; + itemCountByConnectionType: NodeExecuteAfter['data']['itemCountByConnectionType']; }; }; diff --git a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts index 9b8135d376..f062e65a1e 100644 --- a/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts +++ b/packages/cli/src/execution-lifecycle/__tests__/execution-lifecycle-hooks.test.ts @@ -345,7 +345,14 @@ describe('Execution Lifecycle Hooks', () => { 1, { type: 'nodeExecuteAfter', - data: { executionId, nodeName, itemCount: 1, data: taskDataWithoutData }, + data: { + executionId, + nodeName, + itemCountByConnectionType: { + main: [1], + }, + data: taskDataWithoutData, + }, }, pushRef, ); @@ -354,7 +361,14 @@ describe('Execution Lifecycle Hooks', () => { 2, { type: 'nodeExecuteAfterData', - data: { executionId, nodeName, itemCount: 1, data: mockTaskData }, + data: { + executionId, + nodeName, + itemCountByConnectionType: { + main: [1], + }, + data: mockTaskData, + }, }, pushRef, true, diff --git a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts index fb05c152d3..a28aedc3e6 100644 --- a/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts +++ b/packages/cli/src/execution-lifecycle/execution-lifecycle-hooks.ts @@ -27,6 +27,7 @@ import { updateExistingExecution, } from './shared/shared-hook-functions'; import { type ExecutionSaveSettings, toSaveSettings } from './to-save-settings'; +import { getItemCountByConnectionType } from '@/utils/get-item-count-by-connection-type'; @Service() class ModulesHooksRegistry { @@ -185,11 +186,14 @@ function hookFunctionsPush( workflowId: this.workflowData.id, }); - const itemCount = data.data?.main?.[0]?.length ?? 0; + const itemCountByConnectionType = getItemCountByConnectionType(data?.data); const { data: _, ...taskData } = data; pushInstance.send( - { type: 'nodeExecuteAfter', data: { executionId, nodeName, itemCount, data: taskData } }, + { + type: 'nodeExecuteAfter', + data: { executionId, nodeName, itemCountByConnectionType, data: taskData }, + }, pushRef, ); @@ -203,7 +207,7 @@ function hookFunctionsPush( pushInstance.send( { type: 'nodeExecuteAfterData', - data: { executionId, nodeName, itemCount, data }, + data: { executionId, nodeName, itemCountByConnectionType, data }, }, pushRef, asBinary, diff --git a/packages/cli/src/utils/__tests__/get-item-count-by-connection-type.test.ts b/packages/cli/src/utils/__tests__/get-item-count-by-connection-type.test.ts new file mode 100644 index 0000000000..93cef5aea4 --- /dev/null +++ b/packages/cli/src/utils/__tests__/get-item-count-by-connection-type.test.ts @@ -0,0 +1,145 @@ +import type { ITaskData } from 'n8n-workflow'; + +import { getItemCountByConnectionType } from '../get-item-count-by-connection-type'; + +describe('getItemCountByConnectionType', () => { + it('should return an empty object when data is undefined', () => { + const result = getItemCountByConnectionType(undefined); + expect(result).toEqual({}); + }); + + it('should return an empty object when data is an empty object', () => { + const result = getItemCountByConnectionType({}); + expect(result).toEqual({}); + }); + + it('should count items for a single connection with single output', () => { + const data: ITaskData['data'] = { + main: [[{ json: { id: 1 } }, { json: { id: 2 } }]], + }; + + const result = getItemCountByConnectionType(data); + expect(result).toEqual({ + main: [2], + }); + }); + + it('should count items for a single connection with multiple outputs', () => { + const data: ITaskData['data'] = { + main: [ + [{ json: { id: 1 } }, { json: { id: 2 } }], + [{ json: { id: 3 } }], + [{ json: { id: 4 } }, { json: { id: 5 } }, { json: { id: 6 } }], + ], + }; + + const result = getItemCountByConnectionType(data); + expect(result).toEqual({ + main: [2, 1, 3], + }); + }); + + it('should handle multiple connection types', () => { + const data: ITaskData['data'] = { + main: [[{ json: { id: 1 } }, { json: { id: 2 } }]], + ai_agent: [[{ json: { error: 'test' } }]], + ai_memory: [ + [ + { json: { data: 'custom' } }, + { json: { data: 'custom2' } }, + { json: { data: 'custom3' } }, + ], + ], + }; + + const result = getItemCountByConnectionType(data); + expect(result).toEqual({ + main: [2], + ai_agent: [1], + ai_memory: [3], + }); + }); + + it('should handle empty arrays in connection data', () => { + const data: ITaskData['data'] = { + main: [[], [{ json: { id: 1 } }], []], + }; + + const result = getItemCountByConnectionType(data); + expect(result).toEqual({ + main: [0, 1, 0], + }); + }); + + it('should handle null values in connection data arrays', () => { + const data: ITaskData['data'] = { + main: [null, [{ json: { id: 1 } }], null], + }; + + const result = getItemCountByConnectionType(data); + expect(result).toEqual({ + main: [0, 1, 0], + }); + }); + + it('should handle connection data with mixed null and valid arrays', () => { + const data: ITaskData['data'] = { + main: [[{ json: { id: 1 } }], null, [{ json: { id: 2 } }, { json: { id: 3 } }], null, []], + }; + + const result = getItemCountByConnectionType(data); + expect(result).toEqual({ + main: [1, 0, 2, 0, 0], + }); + }); + + it('should discard unknown connection types', () => { + const data: ITaskData['data'] = { + main: [[{ json: { id: 1 } }, { json: { id: 2 } }]], + unknownType: [[{ json: { data: 'should be ignored' } }]], + anotherInvalid: [[{ json: { test: 'data' } }]], + }; + + const result = getItemCountByConnectionType(data); + + // Should only include 'main' and discard unknown types + expect(result).toEqual({ + main: [2], + }); + expect(result).not.toHaveProperty('unknownType'); + expect(result).not.toHaveProperty('anotherInvalid'); + }); + + it('should handle mix of valid and invalid connection types', () => { + const data: ITaskData['data'] = { + invalidType1: [[{ json: { data: 'ignored' } }]], + main: [[{ json: { id: 1 } }]], + invalidType2: [[{ json: { data: 'also ignored' } }]], + ai_agent: [[{ json: { error: 'test error' } }]], + notAValidType: [[{ json: { foo: 'bar' } }]], + }; + + const result = getItemCountByConnectionType(data); + + // Should only include valid NodeConnectionTypes + expect(result).toEqual({ + main: [1], + ai_agent: [1], + }); + expect(Object.keys(result)).toHaveLength(2); + }); + + it('should handle data with only invalid connection types', () => { + const data: ITaskData['data'] = { + fakeType1: [[{ json: { data: 'test' } }]], + fakeType2: [[{ json: { data: 'test2' } }]], + notReal: [[{ json: { id: 1 } }, { json: { id: 2 } }]], + }; + + const result = getItemCountByConnectionType(data); + + // Should return empty object when no valid types found + expect(result).toEqual({}); + expect(Object.keys(result)).toHaveLength(0); + }); +}); diff --git a/packages/cli/src/utils/get-item-count-by-connection-type.ts b/packages/cli/src/utils/get-item-count-by-connection-type.ts new file mode 100644 index 0000000000..65720b39d3 --- /dev/null +++ b/packages/cli/src/utils/get-item-count-by-connection-type.ts @@ -0,0 +1,22 @@ +import type { NodeConnectionType, ITaskData } from 'n8n-workflow'; +import { isNodeConnectionType } from 'n8n-workflow'; + +export function getItemCountByConnectionType( + data: ITaskData['data'], +): Partial> { + const itemCountByConnectionType: Partial> = {}; + + for (const [connectionType, connectionData] of Object.entries(data ?? {})) { + if (!isNodeConnectionType(connectionType)) { + continue; + } + + if (Array.isArray(connectionData)) { + itemCountByConnectionType[connectionType] = connectionData.map((d) => (d ? d.length : 0)); + } else { + itemCountByConnectionType[connectionType] = [0]; + } + } + + return itemCountByConnectionType; +} diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts new file mode 100644 index 0000000000..2df8bf3cc5 --- /dev/null +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.test.ts @@ -0,0 +1,190 @@ +import { createTestingPinia } from '@pinia/testing'; +import { setActivePinia } from 'pinia'; +import { nodeExecuteAfter } from './nodeExecuteAfter'; +import { useWorkflowsStore } from '@/stores/workflows.store'; +import { useAssistantStore } from '@/stores/assistant.store'; +import { mockedStore } from '@/__tests__/utils'; +import type { NodeExecuteAfter } from '@n8n/api-types/push/execution'; +import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow'; + +describe('nodeExecuteAfter', () => { + beforeEach(() => { + const pinia = createTestingPinia({ + stubActions: true, + }); + + setActivePinia(pinia); + }); + + it('should update node execution data with placeholder and remove executing node', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + const assistantStore = mockedStore(useAssistantStore); + + const event: NodeExecuteAfter = { + type: 'nodeExecuteAfter', + data: { + executionId: 'exec-1', + nodeName: 'Test Node', + itemCountByConnectionType: { main: [2, 1] }, + data: { + executionTime: 100, + startTime: 1234567890, + executionIndex: 0, + source: [], + }, + }, + }; + + await nodeExecuteAfter(event); + + expect(workflowsStore.updateNodeExecutionData).toHaveBeenCalledTimes(1); + expect(workflowsStore.removeExecutingNode).toHaveBeenCalledTimes(1); + expect(workflowsStore.removeExecutingNode).toHaveBeenCalledWith('Test Node'); + expect(assistantStore.onNodeExecution).toHaveBeenCalledTimes(1); + expect(assistantStore.onNodeExecution).toHaveBeenCalledWith(event.data); + + // Verify the placeholder data structure + const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + expect(updateCall.data.data).toEqual({ + main: [ + Array.from({ length: 2 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + ], + }); + }); + + it('should handle multiple connection types', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const event: NodeExecuteAfter = { + type: 'nodeExecuteAfter', + data: { + executionId: 'exec-1', + nodeName: 'Test Node', + itemCountByConnectionType: { + main: [3], + ai_memory: [1, 2], + ai_tool: [1], + }, + data: { + executionTime: 100, + startTime: 1234567890, + executionIndex: 0, + source: [], + }, + }, + }; + + await nodeExecuteAfter(event); + + const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + expect(updateCall.data.data).toEqual({ + main: [ + Array.from({ length: 3 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + ], + ai_memory: [ + Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + Array.from({ length: 2 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + ], + ai_tool: [ + Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + ], + }); + }); + + it('should handle empty itemCountByConnectionType', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const event: NodeExecuteAfter = { + type: 'nodeExecuteAfter', + data: { + executionId: 'exec-1', + nodeName: 'Test Node', + itemCountByConnectionType: {}, + data: { + executionTime: 100, + startTime: 1234567890, + executionIndex: 0, + source: [], + }, + }, + }; + + await nodeExecuteAfter(event); + + const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + expect(updateCall.data.data).toEqual({ + main: [], + }); + }); + + it('should preserve original data structure except for data property', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const event: NodeExecuteAfter = { + type: 'nodeExecuteAfter', + data: { + executionId: 'exec-1', + nodeName: 'Test Node', + itemCountByConnectionType: { main: [1] }, + data: { + executionTime: 100, + startTime: 1234567890, + executionIndex: 0, + source: [null], + }, + }, + }; + + await nodeExecuteAfter(event); + + const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + expect(updateCall.executionId).toBe('exec-1'); + expect(updateCall.nodeName).toBe('Test Node'); + expect(updateCall.data.executionTime).toBe(100); + expect(updateCall.data.startTime).toBe(1234567890); + expect(updateCall.data.executionIndex).toBe(0); + expect(updateCall.data.source).toEqual([null]); + + // Only the data property should be replaced with placeholder + expect(updateCall.data.data).toEqual({ + main: [ + Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + ], + }); + }); + + it('should filter out invalid connection types', async () => { + const workflowsStore = mockedStore(useWorkflowsStore); + + const event: NodeExecuteAfter = { + type: 'nodeExecuteAfter', + data: { + executionId: 'exec-1', + nodeName: 'Test Node', + itemCountByConnectionType: { + main: [1], + // @ts-expect-error Testing invalid connection type + invalid_connection: [2], // This should be filtered out by isValidNodeConnectionType + }, + data: { + executionTime: 100, + startTime: 1234567890, + executionIndex: 0, + source: [], + }, + }, + }; + + await nodeExecuteAfter(event); + + const updateCall = workflowsStore.updateNodeExecutionData.mock.calls[0][0]; + // Should only contain main connection, invalid_connection should be filtered out + expect(updateCall.data.data).toEqual({ + main: [ + Array.from({ length: 1 }).fill({ json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }), + ], + }); + expect(updateCall.data.data?.invalid_connection).toBeUndefined(); + }); +}); diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts index 01788d027b..d046eaa230 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfter.ts @@ -1,9 +1,10 @@ import type { NodeExecuteAfter } from '@n8n/api-types/push/execution'; import { useAssistantStore } from '@/stores/assistant.store'; import { useWorkflowsStore } from '@/stores/workflows.store'; -import type { ITaskData } from 'n8n-workflow'; +import type { INodeExecutionData, ITaskData } from 'n8n-workflow'; import { TRIMMED_TASK_DATA_CONNECTIONS_KEY } from 'n8n-workflow'; import type { PushPayload } from '@n8n/api-types'; +import { isValidNodeConnectionType } from '@/utils/typeGuards'; /** * Handles the 'nodeExecuteAfter' event, which happens after a node is executed. @@ -18,15 +19,23 @@ export async function nodeExecuteAfter({ data: pushData }: NodeExecuteAfter) { * a placeholder object indicating that the data has been trimmed until the * `nodeExecuteAfterData` event comes in. */ + const placeholderOutputData: ITaskData['data'] = { main: [], }; - if (typeof pushData.itemCount === 'number') { - const fillObject = { json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }; - const fillArray = new Array(pushData.itemCount).fill(fillObject); - - placeholderOutputData.main = [fillArray]; + if ( + pushData.itemCountByConnectionType && + typeof pushData.itemCountByConnectionType === 'object' + ) { + const fillObject: INodeExecutionData = { json: { [TRIMMED_TASK_DATA_CONNECTIONS_KEY]: true } }; + for (const [connectionType, outputs] of Object.entries(pushData.itemCountByConnectionType)) { + if (isValidNodeConnectionType(connectionType)) { + placeholderOutputData[connectionType] = outputs.map((count) => + Array.from({ length: count }, () => fillObject), + ); + } + } } const pushDataWithPlaceholderOutputData: PushPayload<'nodeExecuteAfterData'> = { diff --git a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts index f41235e5b4..5c1f657d1c 100644 --- a/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts +++ b/packages/frontend/editor-ui/src/composables/usePushConnection/handlers/nodeExecuteAfterData.test.ts @@ -21,7 +21,7 @@ describe('nodeExecuteAfterData', () => { data: { executionId: 'exec-1', nodeName: 'Test Node', - itemCount: 1, + itemCountByConnectionType: { main: [1] }, data: { executionTime: 0, startTime: 0, diff --git a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts index 14ac57c49a..97d90ae046 100644 --- a/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/logs/components/LogsPanel.test.ts @@ -330,7 +330,7 @@ describe('LogsPanel', () => { workflowsStore.updateNodeExecutionData({ nodeName: 'AI Agent', executionId: '567', - itemCount: 1, + itemCountByConnectionType: { ai_agent: [1] }, data: { executionIndex: 0, startTime: Date.parse('2025-04-20T12:34:51.000Z'), diff --git a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts index 92cd61a170..183846d8aa 100644 --- a/packages/frontend/editor-ui/src/stores/workflows.store.test.ts +++ b/packages/frontend/editor-ui/src/stores/workflows.store.test.ts @@ -1409,7 +1409,7 @@ function generateMockExecutionEvents() { const successEvent: PushPayload<'nodeExecuteAfter'> = { executionId: '59', nodeName: 'When clicking ‘Execute workflow’', - itemCount: 1, + itemCountByConnectionType: { main: [1] }, data: { hints: [], startTime: 1727867966633, diff --git a/packages/workflow/src/index.ts b/packages/workflow/src/index.ts index e41b2f3d72..bed083bc78 100644 --- a/packages/workflow/src/index.ts +++ b/packages/workflow/src/index.ts @@ -56,6 +56,7 @@ export { isResourceMapperValue, isResourceLocatorValue, isFilterValue, + isNodeConnectionType, } from './type-guards'; export { diff --git a/packages/workflow/src/type-guards.ts b/packages/workflow/src/type-guards.ts index 3b8b33fb52..8104a47958 100644 --- a/packages/workflow/src/type-guards.ts +++ b/packages/workflow/src/type-guards.ts @@ -1,10 +1,12 @@ -import type { - INodeProperties, - INodePropertyOptions, - INodePropertyCollection, - INodeParameterResourceLocator, - ResourceMapperValue, - FilterValue, +import { + type INodeProperties, + type INodePropertyOptions, + type INodePropertyCollection, + type INodeParameterResourceLocator, + type ResourceMapperValue, + type FilterValue, + type NodeConnectionType, + nodeConnectionTypes, } from './interfaces'; export function isResourceLocatorValue(value: unknown): value is INodeParameterResourceLocator { @@ -67,3 +69,7 @@ export const isFilterValue = (value: unknown): value is FilterValue => { typeof value === 'object' && value !== null && 'conditions' in value && 'combinator' in value ); }; + +export const isNodeConnectionType = (value: unknown): value is NodeConnectionType => { + return nodeConnectionTypes.includes(value as NodeConnectionType); +};