mirror of
https://github.com/n8n-io/n8n.git
synced 2026-09-19 01:45:48 +08:00
feat(core): Complete v1-to-v2 workflow converter (no-changelog) (#35189)
This commit is contained in:
@@ -1,4 +1,10 @@
|
||||
import { defineConfig } from 'eslint/config';
|
||||
import { nodeConfig } from '@n8n/eslint-config/node';
|
||||
|
||||
export default defineConfig({ ignores: ['dist/**'] }, nodeConfig);
|
||||
export default defineConfig({ ignores: ['dist/**'] }, nodeConfig, {
|
||||
files: ['src/__tests__/**'],
|
||||
rules: {
|
||||
// Workflow fixtures key connections by node name, e.g. "When clicking Execute"
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { ExecuteContext } from 'n8n-core';
|
||||
import { NoOp } from 'n8n-nodes-base/nodes/NoOp/NoOp.node';
|
||||
import type {
|
||||
CloseFunction,
|
||||
IConnections,
|
||||
IDataObject,
|
||||
IExecuteFunctions,
|
||||
INodeExecutionData,
|
||||
@@ -90,6 +91,28 @@ class NewStyleEcho extends Node {
|
||||
}
|
||||
}
|
||||
|
||||
class TwoOutputs implements INodeType {
|
||||
description = {
|
||||
displayName: 'Two Outputs',
|
||||
name: 'twoOutputs',
|
||||
group: ['transform'],
|
||||
version: 1,
|
||||
description: 'Emits on two output slots',
|
||||
defaults: { name: 'Two Outputs' },
|
||||
inputs: [NodeConnectionTypes.Main],
|
||||
outputs: [NodeConnectionTypes.Main, NodeConnectionTypes.Main],
|
||||
properties: [],
|
||||
} as unknown as INodeType['description'];
|
||||
|
||||
async execute(this: IExecuteFunctions): Promise<INodeExecutionData[][]> {
|
||||
const items = this.getInputData();
|
||||
return await Promise.resolve([
|
||||
items,
|
||||
items.map((item) => ({ json: { ...item.json, second: true } })),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
const pushCloseFunction = (context: IExecuteFunctions, close: CloseFunction) => {
|
||||
(context as unknown as ExecuteContext).closeFunctions.push(close);
|
||||
};
|
||||
@@ -163,6 +186,7 @@ const registry = new Map<string, INodeType>([
|
||||
['test.alwaysFails', new AlwaysFails()],
|
||||
['test.noExecute', new NoExecute()],
|
||||
['test.newStyleEcho', new NewStyleEcho() as unknown as INodeType],
|
||||
['test.twoOutputs', new TwoOutputs()],
|
||||
['test.succeedsWithFailingCleanup', new SucceedsWithFailingCleanup()],
|
||||
['test.failsWithFailingCleanup', new FailsWithFailingCleanup()],
|
||||
['test.returnsEngineRequest', new ReturnsEngineRequest() as unknown as INodeType],
|
||||
@@ -191,14 +215,21 @@ export const testAdditionalDataFactory = async (
|
||||
} as unknown as IWorkflowExecuteAdditionalData);
|
||||
|
||||
export const v1Workflow = (
|
||||
nodes: Array<{ id: string; name: string; type: string; parameters?: IDataObject }>,
|
||||
nodes: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
typeVersion?: number;
|
||||
parameters?: IDataObject;
|
||||
}>,
|
||||
connections: IConnections = {},
|
||||
): IWorkflowBase =>
|
||||
({
|
||||
id: 'wf-1',
|
||||
name: 'fixture',
|
||||
active: false,
|
||||
nodes: nodes.map((n) => ({ typeVersion: 1, position: [0, 0], parameters: {}, ...n })),
|
||||
connections: {},
|
||||
connections,
|
||||
}) as unknown as IWorkflowBase;
|
||||
|
||||
export const stepRequest = (
|
||||
|
||||
@@ -137,6 +137,51 @@ describe('V1StepExecutor', () => {
|
||||
await expect(execution).rejects.toThrow(EngineRequestNotSupportedError);
|
||||
});
|
||||
|
||||
it('routes multi-output results to their output slots', async () => {
|
||||
const graph = converter.convert(
|
||||
v1Workflow(
|
||||
[
|
||||
{ id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' },
|
||||
{ id: 'n', name: 'Splitter', type: 'test.twoOutputs' },
|
||||
{ id: 'a', name: 'A', type: 'n8n-nodes-base.noOp' },
|
||||
{ id: 'b', name: 'B', type: 'n8n-nodes-base.noOp' },
|
||||
],
|
||||
{
|
||||
Manual: { main: [[{ node: 'Splitter', type: 'main', index: 0 }]] },
|
||||
Splitter: {
|
||||
main: [
|
||||
[{ node: 'A', type: 'main', index: 0 }],
|
||||
[{ node: 'B', type: 'main', index: 0 }],
|
||||
],
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const result = await executor.execute(stepRequest(graph, 'n', items({ a: 1 })));
|
||||
expect(result.outputs).toEqual([[{ json: { a: 1 } }], [{ json: { a: 1, second: true } }]]);
|
||||
});
|
||||
|
||||
it('rejects batch steps until the engine iterates loops natively', async () => {
|
||||
const graph = converter.convert(
|
||||
v1Workflow(
|
||||
[
|
||||
{ id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' },
|
||||
{ id: 'loop', name: 'Loop', type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 },
|
||||
{ id: 'body', name: 'Body', type: 'n8n-nodes-base.noOp' },
|
||||
],
|
||||
{
|
||||
Manual: { main: [[{ node: 'Loop', type: 'main', index: 0 }]] },
|
||||
Loop: { main: [[], [{ node: 'Body', type: 'main', index: 0 }]] },
|
||||
Body: { main: [[{ node: 'Loop', type: 'main', index: 0 }]] },
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
const execution = executor.execute(stepRequest(graph, 'loop', items({ a: 1 })));
|
||||
await expect(execution).rejects.toThrow(UnsupportedStepTypeError);
|
||||
});
|
||||
|
||||
it('honors onError=continueRegularOutput as passthrough', async () => {
|
||||
const workflow = v1Workflow([
|
||||
{ id: 't', name: 'Manual', type: 'n8n-nodes-base.manualTrigger' },
|
||||
|
||||
+631
-2
@@ -1,7 +1,12 @@
|
||||
import type { INode, IWorkflowBase } from 'n8n-workflow';
|
||||
import type { IConnection, INode, IWorkflowBase } from 'n8n-workflow';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { UnsupportedTriggerError } from '../errors';
|
||||
import {
|
||||
UnsupportedConnectionTypeError,
|
||||
UnsupportedCycleError,
|
||||
UnsupportedTriggerError,
|
||||
UnsupportedLoopEntryError,
|
||||
} from '../errors';
|
||||
import { V1WorkflowConverter } from '../v1-workflow-converter';
|
||||
|
||||
const converter = new V1WorkflowConverter();
|
||||
@@ -15,6 +20,18 @@ const manualTrigger: INode = {
|
||||
parameters: {},
|
||||
};
|
||||
|
||||
const node = (id: string, name: string, extra: Partial<INode> = {}): INode => ({
|
||||
id,
|
||||
name,
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
...extra,
|
||||
});
|
||||
|
||||
const main = (target: string, index = 0): IConnection => ({ node: target, type: 'main', index });
|
||||
|
||||
function workflow(overrides: Partial<IWorkflowBase>): IWorkflowBase {
|
||||
return {
|
||||
id: 'wf-1',
|
||||
@@ -28,6 +45,13 @@ function workflow(overrides: Partial<IWorkflowBase>): IWorkflowBase {
|
||||
} as IWorkflowBase;
|
||||
}
|
||||
|
||||
// NOTE: Topology diagrams follow the convention of core's partial-execution
|
||||
// tests (drawn with https://asciiflow.com/#/). If you update a test, update
|
||||
// its diagram.
|
||||
//
|
||||
// oN / iN the output / input slot, where it matters
|
||||
// XX the node is disabled
|
||||
// (back) the loop-return edge expected to be marked `isBackEdge`
|
||||
describe('V1WorkflowConverter', () => {
|
||||
describe('trigger nodes', () => {
|
||||
it('maps a manual trigger to a single trigger graph node', () => {
|
||||
@@ -146,4 +170,609 @@ describe('V1WorkflowConverter', () => {
|
||||
).toThrow(UnsupportedTriggerError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edges', () => {
|
||||
it('maps connections to id-keyed edges with slot indexes', () => {
|
||||
// ┌───────┐ ┌─┐
|
||||
// │trigger├───►│A│
|
||||
// └───────┘ └─┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [manualTrigger, node('a-uuid', 'A')],
|
||||
connections: { 'When clicking Execute': { main: [[main('A')]] } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toEqual([
|
||||
{ from: 'trigger-uuid', to: 'a-uuid', outputIndex: 0, inputIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('maps multi-output sources to distinct output slots', () => {
|
||||
// ┌───────┐ ┌──┐ o0 ┌─┐
|
||||
// │trigger├───►│ ├──────►│A│
|
||||
// └───────┘ │IF│ └─┘
|
||||
// │ │ o1 ┌─┐
|
||||
// │ ├──────►│B│
|
||||
// └──┘ └─┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [manualTrigger, node('if-uuid', 'IF'), node('a-uuid', 'A'), node('b-uuid', 'B')],
|
||||
connections: {
|
||||
'When clicking Execute': { main: [[main('IF')]] },
|
||||
IF: { main: [[main('A')], [main('B')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toContainEqual({
|
||||
from: 'if-uuid',
|
||||
to: 'a-uuid',
|
||||
outputIndex: 0,
|
||||
inputIndex: 0,
|
||||
});
|
||||
expect(graph.edges).toContainEqual({
|
||||
from: 'if-uuid',
|
||||
to: 'b-uuid',
|
||||
outputIndex: 1,
|
||||
inputIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('maps multi-input targets to distinct input slots', () => {
|
||||
// ┌─┐ i0 ┌─────┐
|
||||
// │A├─────────►│ │
|
||||
// └─┘ │Merge│
|
||||
// ┌─┐ i1 │ │
|
||||
// │B├─────────►│ │
|
||||
// └─┘ └─────┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [node('a-uuid', 'A'), node('b-uuid', 'B'), node('merge-uuid', 'Merge')],
|
||||
connections: {
|
||||
A: { main: [[main('Merge', 0)]] },
|
||||
B: { main: [[main('Merge', 1)]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toEqual([
|
||||
{ from: 'a-uuid', to: 'merge-uuid', outputIndex: 0, inputIndex: 0 },
|
||||
{ from: 'b-uuid', to: 'merge-uuid', outputIndex: 0, inputIndex: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves rootless nodes unconnected', () => {
|
||||
const graph = converter.convert(
|
||||
workflow({ nodes: [manualTrigger, node('orphan-uuid', 'Orphan')], connections: {} }),
|
||||
);
|
||||
|
||||
expect(graph.nodes).toHaveLength(2);
|
||||
expect(graph.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops connections referencing missing nodes', () => {
|
||||
// ┌─────┐ ┌─┐ ┌───────┐ ┌─────────────┐
|
||||
// │Ghost├───►│A│ │trigger├───►│Another Ghost│
|
||||
// └─────┘ └─┘ └───────┘ └─────────────┘
|
||||
// neither ghost exists as a node
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [manualTrigger, node('a-uuid', 'A')],
|
||||
connections: {
|
||||
Ghost: { main: [[main('A')]] },
|
||||
'When clicking Execute': { main: [[main('Another Ghost')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it('rejects non-main connection types', () => {
|
||||
expect(() =>
|
||||
converter.convert(
|
||||
workflow({
|
||||
nodes: [node('tool-uuid', 'Tool'), node('agent-uuid', 'Agent')],
|
||||
connections: {
|
||||
Tool: { ai_tool: [[{ node: 'Agent', type: 'ai_tool', index: 0 }]] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(UnsupportedConnectionTypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled nodes', () => {
|
||||
it('splices out a disabled node, keeping the outer slot indexes', () => {
|
||||
// XX
|
||||
// ┌───────┐ ┌─┐ o1 ┌────────┐ i2 ┌─┐
|
||||
// │trigger├───►│A├───────►│Disabled├───────►│S│
|
||||
// └───────┘ └─┘ └────────┘ └─┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
manualTrigger,
|
||||
node('a-uuid', 'A'),
|
||||
node('d-uuid', 'Disabled', { disabled: true }),
|
||||
node('s-uuid', 'S'),
|
||||
],
|
||||
connections: {
|
||||
'When clicking Execute': { main: [[main('A')]] },
|
||||
A: { main: [[], [main('Disabled')]] },
|
||||
Disabled: { main: [[main('S', 2)]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.nodes.map((n) => n.id)).toEqual(['trigger-uuid', 'a-uuid', 's-uuid']);
|
||||
expect(graph.edges).toContainEqual({
|
||||
from: 'a-uuid',
|
||||
to: 's-uuid',
|
||||
outputIndex: 1,
|
||||
inputIndex: 2,
|
||||
});
|
||||
expect(graph.edges).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('splices chained disabled nodes transitively', () => {
|
||||
// XX XX
|
||||
// ┌─┐ ┌──┐ ┌──┐ ┌─┐
|
||||
// │A├──────►│D1├────►│D2├────►│B│
|
||||
// └─┘ └──┘ └──┘ └─┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('a-uuid', 'A'),
|
||||
node('d1-uuid', 'D1', { disabled: true }),
|
||||
node('d2-uuid', 'D2', { disabled: true }),
|
||||
node('b-uuid', 'B'),
|
||||
],
|
||||
connections: {
|
||||
A: { main: [[main('D1')]] },
|
||||
D1: { main: [[main('D2')]] },
|
||||
D2: { main: [[main('B')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toEqual([
|
||||
{ from: 'a-uuid', to: 'b-uuid', outputIndex: 0, inputIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('fans out the splice as a cross-product', () => {
|
||||
// XX
|
||||
// ┌──┐ ┌────────┐ ┌──┐
|
||||
// │P1├──────►│ ├────►│S1│
|
||||
// └──┘ │Disabled│ └──┘
|
||||
// ┌──┐ │ │ ┌──┐
|
||||
// │P2├──────►│ ├────►│S2│
|
||||
// └──┘ └────────┘ └──┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('p1-uuid', 'P1'),
|
||||
node('p2-uuid', 'P2'),
|
||||
node('d-uuid', 'Disabled', { disabled: true }),
|
||||
node('s1-uuid', 'S1'),
|
||||
node('s2-uuid', 'S2'),
|
||||
],
|
||||
connections: {
|
||||
P1: { main: [[main('Disabled')]] },
|
||||
P2: { main: [[main('Disabled')]] },
|
||||
Disabled: { main: [[main('S1'), main('S2')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toHaveLength(4);
|
||||
for (const from of ['p1-uuid', 'p2-uuid']) {
|
||||
for (const to of ['s1-uuid', 's2-uuid']) {
|
||||
expect(graph.edges).toContainEqual({ from, to, outputIndex: 0, inputIndex: 0 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('dedupes identical edges created by the splice', () => {
|
||||
// XX
|
||||
// ┌──┐
|
||||
// ┌───►│D1├────┐
|
||||
// ┌─┐ │ └──┘ │ ┌─┐
|
||||
// │A├───┤ XX ├──►│S│
|
||||
// └─┘ │ ┌──┐ │ └─┘
|
||||
// └───►│D2├────┘
|
||||
// └──┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('a-uuid', 'A'),
|
||||
node('d1-uuid', 'D1', { disabled: true }),
|
||||
node('d2-uuid', 'D2', { disabled: true }),
|
||||
node('s-uuid', 'S'),
|
||||
],
|
||||
connections: {
|
||||
A: { main: [[main('D1'), main('D2')]] },
|
||||
D1: { main: [[main('S')]] },
|
||||
D2: { main: [[main('S')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toEqual([
|
||||
{ from: 'a-uuid', to: 's-uuid', outputIndex: 0, inputIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops predecessors feeding input slots other than 0, matching v1 pass-through', () => {
|
||||
// XX
|
||||
// ┌─┐ i0 ┌─────┐
|
||||
// │A├────────────►│ │ ┌─┐
|
||||
// └─┘ │Merge├────►│S│
|
||||
// ┌─┐ i1 │ │ └─┘
|
||||
// │B├────────────►│ │
|
||||
// └─┘ └─────┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('a-uuid', 'A'),
|
||||
node('b-uuid', 'B'),
|
||||
node('merge-uuid', 'Merge', { disabled: true }),
|
||||
node('s-uuid', 'S'),
|
||||
],
|
||||
connections: {
|
||||
A: { main: [[main('Merge', 0)]] },
|
||||
B: { main: [[main('Merge', 1)]] },
|
||||
Merge: { main: [[main('S')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toEqual([
|
||||
{ from: 'a-uuid', to: 's-uuid', outputIndex: 0, inputIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('ignores unsupported constructs on disabled nodes', () => {
|
||||
// XX
|
||||
// ┌───────┐ ┌─┐
|
||||
// │Webhook├───►│A│ an unsupported trigger, but disabled
|
||||
// └───────┘ └─┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('webhook-uuid', 'Webhook', { type: 'n8n-nodes-base.webhook', disabled: true }),
|
||||
node('a-uuid', 'A'),
|
||||
],
|
||||
connections: { Webhook: { main: [[main('A')]] } },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.nodes.map((n) => n.id)).toEqual(['a-uuid']);
|
||||
expect(graph.edges).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loops', () => {
|
||||
it('maps Split In Batches to a batch step and marks its loop-back edge', () => {
|
||||
// ┌───────┐ ┌────┐ o0 ┌────┐
|
||||
// │trigger├───►│ ├──────►│Done│
|
||||
// └───────┘ │Loop│ └────┘
|
||||
// │ │ o1 ┌────┐
|
||||
// │ ├──────►│Body│
|
||||
// └─▲──┘ └──┬─┘
|
||||
// └───(back)────┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
manualTrigger,
|
||||
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('done-uuid', 'Done'),
|
||||
node('body-uuid', 'Body'),
|
||||
],
|
||||
connections: {
|
||||
'When clicking Execute': { main: [[main('Loop')]] },
|
||||
Loop: { main: [[main('Done')], [main('Body')]] },
|
||||
Body: { main: [[main('Loop')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.nodes).toContainEqual({
|
||||
id: 'loop-uuid',
|
||||
name: 'Loop',
|
||||
type: 'batch',
|
||||
config: {
|
||||
nodeType: 'n8n-nodes-base.splitInBatches',
|
||||
typeVersion: 3,
|
||||
parameters: {},
|
||||
continueOnFail: false,
|
||||
},
|
||||
});
|
||||
expect(graph.edges).toEqual([
|
||||
{ from: 'trigger-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0 },
|
||||
{ from: 'loop-uuid', to: 'done-uuid', outputIndex: 0, inputIndex: 0 },
|
||||
{ from: 'loop-uuid', to: 'body-uuid', outputIndex: 1, inputIndex: 0 },
|
||||
{ from: 'body-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks both back edges of nested loops, but not the loop-entry edge', () => {
|
||||
// ┌───────┐ ┌─────┐ o0 ┌───┐
|
||||
// │trigger├───►│ ├──────►│End│
|
||||
// └───────┘ │Outer│ └───┘
|
||||
// │ │ o1 ┌─────┐ o0 ┌───────────┐
|
||||
// │ ├──────►│ ├──────►│After Inner│
|
||||
// └──▲──┘ │Inner│ └─────┬─────┘
|
||||
// │ │ │ o1 ┌────┐│
|
||||
// │ │ ├──────►│Body││
|
||||
// │ └──▲──┘ └──┬─┘│
|
||||
// │ └───(back)────┘ │
|
||||
// └───(back)─────────────────────┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
manualTrigger,
|
||||
node('outer-uuid', 'Outer', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('inner-uuid', 'Inner', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('body-uuid', 'Body'),
|
||||
node('after-inner-uuid', 'After Inner'),
|
||||
node('end-uuid', 'End'),
|
||||
],
|
||||
connections: {
|
||||
'When clicking Execute': { main: [[main('Outer')]] },
|
||||
Outer: { main: [[main('End')], [main('Inner')]] },
|
||||
Inner: { main: [[main('After Inner')], [main('Body')]] },
|
||||
Body: { main: [[main('Inner')]] },
|
||||
'After Inner': { main: [[main('Outer')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const backEdges = graph.edges.filter((edge) => edge.isBackEdge);
|
||||
expect(backEdges).toEqual([
|
||||
{ from: 'body-uuid', to: 'inner-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
|
||||
{
|
||||
from: 'after-inner-uuid',
|
||||
to: 'outer-uuid',
|
||||
outputIndex: 0,
|
||||
inputIndex: 0,
|
||||
isBackEdge: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the loop-back edge of a rootless loop regardless of node order', () => {
|
||||
// ┌────┐ o1 ┌────┐
|
||||
// │Loop├──────►│Body│ nothing else points in
|
||||
// └─▲──┘ └──┬─┘
|
||||
// └───(back)────┘
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('body-uuid', 'Body'),
|
||||
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
],
|
||||
connections: {
|
||||
Body: { main: [[main('Loop')]] },
|
||||
Loop: { main: [[], [main('Body')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(graph.edges).toEqual([
|
||||
{ from: 'body-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
|
||||
{ from: 'loop-uuid', to: 'body-uuid', outputIndex: 1, inputIndex: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a loop entered mid-body, regardless of node order', () => {
|
||||
// ┌──┐ ┌────┐ o1 ┌────┐ ┌──┐
|
||||
// │T1├───►│Loop├──────►│Body│◄───┤T2│
|
||||
// └──┘ └─▲──┘ └──┬─┘ └──┘
|
||||
// └─────────────┘
|
||||
// two ways into the loop: through Loop (T1) and mid-body (T2)
|
||||
const t1 = node('t1-uuid', 'T1', { type: 'n8n-nodes-base.manualTrigger' });
|
||||
const t2 = node('t2-uuid', 'T2', { type: 'n8n-nodes-base.manualTrigger' });
|
||||
const loop = node('loop-uuid', 'Loop', {
|
||||
type: 'n8n-nodes-base.splitInBatches',
|
||||
typeVersion: 3,
|
||||
});
|
||||
const body = node('body-uuid', 'Body');
|
||||
const connections = {
|
||||
T1: { main: [[main('Loop')]] },
|
||||
T2: { main: [[main('Body')]] },
|
||||
Loop: { main: [[], [main('Body')]] },
|
||||
Body: { main: [[main('Loop')]] },
|
||||
};
|
||||
|
||||
for (const nodes of [
|
||||
[t1, t2, loop, body],
|
||||
[t2, t1, loop, body],
|
||||
]) {
|
||||
expect(() => converter.convert(workflow({ nodes, connections }))).toThrow(
|
||||
UnsupportedLoopEntryError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('converts a loop fed by multiple triggers through its batch node', () => {
|
||||
// ┌──┐ ┌────┐ o1 ┌────┐
|
||||
// │T1├───►│ ├──────►│Body│
|
||||
// └──┘ │Loop│ └──┬─┘
|
||||
// ┌──┐ │ │ │
|
||||
// │T2├───►│ │◄─(back)──┘
|
||||
// └──┘ └────┘ one way in, used by both triggers
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('t1-uuid', 'T1', { type: 'n8n-nodes-base.manualTrigger' }),
|
||||
node('t2-uuid', 'T2', { type: 'n8n-nodes-base.manualTrigger' }),
|
||||
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('body-uuid', 'Body'),
|
||||
],
|
||||
connections: {
|
||||
T1: { main: [[main('Loop')]] },
|
||||
T2: { main: [[main('Loop')]] },
|
||||
Loop: { main: [[], [main('Body')]] },
|
||||
Body: { main: [[main('Loop')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const backEdges = graph.edges.filter((edge) => edge.isBackEdge);
|
||||
expect(backEdges).toEqual([
|
||||
{ from: 'body-uuid', to: 'loop-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects a non-batch cycle nested inside a loop body', () => {
|
||||
// ┌───────┐ ┌────┐ o1 ┌─┐ ┌─┐
|
||||
// │trigger├───►│Loop├──────►│A├────►│B│
|
||||
// └───────┘ └─▲──┘ └▲┘ └┬┘
|
||||
// │ └───────┤
|
||||
// └───────────────────┘
|
||||
// B feeds both Loop and A, making A -> B -> A a cycle with no batch node
|
||||
expect(() =>
|
||||
converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
manualTrigger,
|
||||
node('loop-uuid', 'Loop', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('a-uuid', 'A'),
|
||||
node('b-uuid', 'B'),
|
||||
],
|
||||
connections: {
|
||||
'When clicking Execute': { main: [[main('Loop')]] },
|
||||
Loop: { main: [[], [main('A')]] },
|
||||
A: { main: [[main('B')]] },
|
||||
B: { main: [[main('A'), main('Loop')]] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(UnsupportedCycleError);
|
||||
});
|
||||
|
||||
it('rejects an inner loop entered mid-body by the outer loop, regardless of edge order', () => {
|
||||
// ┌───────┐ ┌─────┐ o1 ┌─────┐ o1 ┌──┐
|
||||
// │trigger├──►│ ├────────►│ ├────────►│ │
|
||||
// └───────┘ │Outer│ │Inner│ │B1│
|
||||
// │ │ o1 │ │◄────────┤ │
|
||||
// │ ├────────────────────────►│ │
|
||||
// └──▲──┘ └──┬──┘ o0 └──┘
|
||||
// │ ▼
|
||||
// │ ┌───────────┐
|
||||
// └────────┤After Inner│
|
||||
// └───────────┘
|
||||
// Outer o1 fans out to both Inner and B1: two ways into the inner loop
|
||||
const nodes = [
|
||||
manualTrigger,
|
||||
node('outer-uuid', 'Outer', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('inner-uuid', 'Inner', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('b1-uuid', 'B1'),
|
||||
node('after-inner-uuid', 'After Inner'),
|
||||
];
|
||||
|
||||
for (const outerLoopOutput of [
|
||||
[main('Inner'), main('B1')],
|
||||
[main('B1'), main('Inner')],
|
||||
]) {
|
||||
const connections = {
|
||||
'When clicking Execute': { main: [[main('Outer')]] },
|
||||
Outer: { main: [[], outerLoopOutput] },
|
||||
Inner: { main: [[main('After Inner')], [main('B1')]] },
|
||||
B1: { main: [[main('Inner')]] },
|
||||
'After Inner': { main: [[main('Outer')]] },
|
||||
};
|
||||
|
||||
expect(() => converter.convert(workflow({ nodes, connections }))).toThrow(
|
||||
UnsupportedLoopEntryError,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects rootless nested loops as ambiguous, regardless of node order', () => {
|
||||
// ┌─────┐ o1 ┌─────┐ o0 ┌───────────┐
|
||||
// │ ├──────►│ ├──────►│After Inner│
|
||||
// │Outer│ │Inner│ └─────┬─────┘
|
||||
// │ │ │ │ o1 ┌────┐│
|
||||
// │ │ │ ├──────►│Body││
|
||||
// └──▲──┘ └──▲──┘ └──┬─┘│
|
||||
// │ └─────────────┘ │
|
||||
// └──────────────────────────────┘
|
||||
// no trigger anywhere: no way to tell which loop is the outer one
|
||||
expect(() =>
|
||||
converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('inner-uuid', 'Inner', {
|
||||
type: 'n8n-nodes-base.splitInBatches',
|
||||
typeVersion: 3,
|
||||
}),
|
||||
node('outer-uuid', 'Outer', {
|
||||
type: 'n8n-nodes-base.splitInBatches',
|
||||
typeVersion: 3,
|
||||
}),
|
||||
node('body-uuid', 'Body'),
|
||||
node('after-inner-uuid', 'After Inner'),
|
||||
],
|
||||
connections: {
|
||||
Outer: { main: [[], [main('Inner')]] },
|
||||
Inner: { main: [[main('After Inner')], [main('Body')]] },
|
||||
Body: { main: [[main('Inner')]] },
|
||||
'After Inner': { main: [[main('Outer')]] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(UnsupportedLoopEntryError);
|
||||
});
|
||||
|
||||
it('converts chained rootless loops, which have an unambiguous entry', () => {
|
||||
// ┌──┐ o1 ┌──┐ ┌──┐ o1 ┌──┐
|
||||
// │L1├──────►│B1│ │L2├──────►│B2│
|
||||
// └─▲┘ └┬─┘ └─▲┘ └┬─┘
|
||||
// └─(back)──┘ └─(back)──┘
|
||||
// L1 -o0-> L2 chains the loops, no trigger anywhere
|
||||
const graph = converter.convert(
|
||||
workflow({
|
||||
nodes: [
|
||||
node('l2-uuid', 'L2', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('l1-uuid', 'L1', { type: 'n8n-nodes-base.splitInBatches', typeVersion: 3 }),
|
||||
node('b1-uuid', 'B1'),
|
||||
node('b2-uuid', 'B2'),
|
||||
],
|
||||
connections: {
|
||||
L1: { main: [[main('L2')], [main('B1')]] },
|
||||
B1: { main: [[main('L1')]] },
|
||||
L2: { main: [[], [main('B2')]] },
|
||||
B2: { main: [[main('L2')]] },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const backEdges = graph.edges.filter((edge) => edge.isBackEdge);
|
||||
expect(backEdges).toEqual([
|
||||
{ from: 'b1-uuid', to: 'l1-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
|
||||
{ from: 'b2-uuid', to: 'l2-uuid', outputIndex: 0, inputIndex: 0, isBackEdge: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects cycles that are not Split In Batches loops', () => {
|
||||
// ┌─┐ ┌─┐
|
||||
// │A├────►│B│
|
||||
// │ │◄────┤ │
|
||||
// └─┘ └─┘
|
||||
expect(() =>
|
||||
converter.convert(
|
||||
workflow({
|
||||
nodes: [node('a-uuid', 'A'), node('b-uuid', 'B')],
|
||||
connections: {
|
||||
A: { main: [[main('B')]] },
|
||||
B: { main: [[main('A')]] },
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(UnsupportedCycleError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { UserError } from 'n8n-workflow';
|
||||
|
||||
const quote = (names: string[]) => names.map((name) => `"${name}"`).join(', ');
|
||||
|
||||
/**
|
||||
* Thrown when a v1 workflow uses a construct the converter does not (yet)
|
||||
* support. Caused by user-provided workflow content, hence a `UserError`.
|
||||
@@ -14,6 +16,30 @@ export class UnsupportedTriggerError extends UserError {
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedConnectionTypeError extends UserError {
|
||||
constructor(nodeName: string, connectionType: string) {
|
||||
super(
|
||||
`Node "${nodeName}" has a "${connectionType}" connection, which is not supported yet. Only "main" connections are currently supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedCycleError extends UserError {
|
||||
constructor(nodeNames: string[]) {
|
||||
super(
|
||||
`The cycle involving ${quote(nodeNames)} is not supported yet. Only Split In Batches loops are currently supported.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedLoopEntryError extends UserError {
|
||||
constructor(memberNames: string[], entryNames: string[]) {
|
||||
super(
|
||||
`The loop formed by ${quote(memberNames)} must be entered only through its Split In Batches node, but its entry points are: ${quote(entryNames)}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnsupportedStepTypeError extends UserError {
|
||||
constructor(stepType: string) {
|
||||
super(`V1StepExecutor only handles 'v1-node' steps, got '${stepType}'`);
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { GraphNode, WorkflowGraph } from '@n8n/engine';
|
||||
import type { INode, IWorkflowBase } from 'n8n-workflow';
|
||||
import type { GraphEdge, GraphNode, WorkflowGraph } from '@n8n/engine';
|
||||
import type { INode, INodeConnections, IWorkflowBase } from 'n8n-workflow';
|
||||
|
||||
import { UnsupportedTriggerError, UnsupportedWorkflowError } from './errors';
|
||||
import {
|
||||
UnsupportedConnectionTypeError,
|
||||
UnsupportedCycleError,
|
||||
UnsupportedLoopEntryError,
|
||||
UnsupportedTriggerError,
|
||||
UnsupportedWorkflowError,
|
||||
} from './errors';
|
||||
import type { V1NodeStepConfig } from './types';
|
||||
|
||||
const MANUAL_TRIGGER_TYPE = 'n8n-nodes-base.manualTrigger';
|
||||
const SPLIT_IN_BATCHES_TYPE = 'n8n-nodes-base.splitInBatches';
|
||||
const MAIN_CONNECTION_TYPE = 'main';
|
||||
|
||||
/**
|
||||
* Common v1 trigger types that don't end in "Trigger". Non-exhaustive: combined
|
||||
@@ -21,11 +29,44 @@ const KNOWN_TRIGGER_TYPES = new Set(['n8n-nodes-base.webhook', 'n8n-nodes-base.c
|
||||
* graph nodes and edges and never executes anything. Supported surface is kept
|
||||
* deliberately small (see the converter tests); unsupported constructs are
|
||||
* rejected with a clear error rather than silently mistranslated.
|
||||
*
|
||||
* Branching is purely structural, so an edge records which output slot feeds
|
||||
* which input slot, nothing more. Branches leaving the same node are
|
||||
* unordered, so the engine may run them in parallel. This deliberately
|
||||
* **diverges from v1**, which runs them one after another in connection order.
|
||||
* If branch B must run after branch A, that needs a connection from A to B.
|
||||
* We do not invent connections to force v1 order. If it is ever needed, the
|
||||
* engine can be made to support running branches one at a time.
|
||||
*
|
||||
* Disabled nodes never appear in the graph: every edge into their input
|
||||
* slot 0 is joined to every edge leaving them, so A -> disabled -> B becomes
|
||||
* A -> B, keeping A's `outputIndex` and B's `inputIndex`. Input slot 0 is
|
||||
* the only one joined because v1 passes nothing else through. On the way out
|
||||
* we keep edges from every output slot, deliberately **diverging from v1**,
|
||||
* where the pass-through data leaves on output 0 only.
|
||||
*
|
||||
* The only cycle allowed is a Split In Batches loop: items leave the batch
|
||||
* node, traverse the body, and return on an edge marked `isBackEdge`. We can
|
||||
* only tell which edge is the return when the sole way into a loop, at every
|
||||
* nesting level, is through its batch node. Hence cycles with no batch node,
|
||||
* loops entered mid-body, and loops with more than one candidate entry are
|
||||
* all rejected.
|
||||
*/
|
||||
export class V1WorkflowConverter {
|
||||
convert(workflow: IWorkflowBase): WorkflowGraph {
|
||||
const nodes = workflow.nodes.map((node) => this.toGraphNode(node));
|
||||
return { nodes, edges: [] };
|
||||
const liveNodes = workflow.nodes.filter((node) => node.disabled !== true);
|
||||
const disabledNodeIds = workflow.nodes
|
||||
.filter((node) => node.disabled === true)
|
||||
.map((node) => node.id);
|
||||
|
||||
const nodes = liveNodes.map((node) => this.toGraphNode(node));
|
||||
|
||||
let edges = this.toEdges(workflow);
|
||||
edges = this.spliceOutDisabledNodes(edges, disabledNodeIds);
|
||||
edges = this.dedupeEdges(edges);
|
||||
this.markBackEdges(nodes, edges);
|
||||
|
||||
return { nodes, edges };
|
||||
}
|
||||
|
||||
private toGraphNode(node: INode): GraphNode {
|
||||
@@ -50,7 +91,9 @@ export class V1WorkflowConverter {
|
||||
continueOnFail: node.continueOnFail === true || node.onError === 'continueRegularOutput',
|
||||
};
|
||||
|
||||
return { id: node.id, name: node.name, type: 'v1-node', config };
|
||||
const type = node.type === SPLIT_IN_BATCHES_TYPE ? 'batch' : 'v1-node';
|
||||
|
||||
return { id: node.id, name: node.name, type, config };
|
||||
}
|
||||
|
||||
/** Heuristic trigger detection — see {@link KNOWN_TRIGGER_TYPES}. */
|
||||
@@ -61,4 +104,236 @@ export class V1WorkflowConverter {
|
||||
node.type.toLowerCase().endsWith('trigger')
|
||||
);
|
||||
}
|
||||
|
||||
private toEdges(workflow: IWorkflowBase): GraphEdge[] {
|
||||
const idsByName = new Map(workflow.nodes.map((node) => [node.name, node.id]));
|
||||
|
||||
return Object.entries(workflow.connections).flatMap(([sourceName, connectionsByType]) =>
|
||||
this.toEdgesForSource(sourceName, connectionsByType, idsByName),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* v1 connections of one source node have the shape
|
||||
* `{ main: [ [connection, ...], null, [connection, ...] ] }`: connections
|
||||
* grouped by type, then by the source's output slot, where a slot with
|
||||
* nothing connected may hold null.
|
||||
*/
|
||||
private toEdgesForSource(
|
||||
sourceName: string,
|
||||
connectionsByType: INodeConnections,
|
||||
idsByName: Map<string, string>,
|
||||
): GraphEdge[] {
|
||||
const from = idsByName.get(sourceName);
|
||||
const edges: GraphEdge[] = [];
|
||||
|
||||
for (const [connectionType, outputSlots] of Object.entries(connectionsByType)) {
|
||||
this.validateSupportedConnectionType(sourceName, connectionType);
|
||||
|
||||
for (let outputIndex = 0; outputIndex < outputSlots.length; outputIndex++) {
|
||||
for (const connection of outputSlots[outputIndex] ?? []) {
|
||||
this.validateSupportedConnectionType(sourceName, connection.type);
|
||||
|
||||
const to = idsByName.get(connection.node);
|
||||
|
||||
// v1 skips connections whose endpoint no longer exists, so dropping
|
||||
// them is the faithful translation
|
||||
if (from === undefined || to === undefined) continue;
|
||||
|
||||
edges.push({ from, to, outputIndex, inputIndex: connection.index });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
private validateSupportedConnectionType(nodeName: string, connectionType: string): void {
|
||||
if (connectionType !== MAIN_CONNECTION_TYPE) {
|
||||
throw new UnsupportedConnectionTypeError(nodeName, connectionType);
|
||||
}
|
||||
}
|
||||
|
||||
private spliceOutDisabledNodes(edges: GraphEdge[], disabledNodeIds: string[]): GraphEdge[] {
|
||||
for (const disabledNodeId of disabledNodeIds) {
|
||||
const incoming = edges.filter(
|
||||
(edge) =>
|
||||
edge.to === disabledNodeId && edge.from !== disabledNodeId && edge.inputIndex === 0,
|
||||
);
|
||||
const outgoing = edges.filter(
|
||||
(edge) => edge.from === disabledNodeId && edge.to !== disabledNodeId,
|
||||
);
|
||||
const spliced = incoming.flatMap((into) =>
|
||||
outgoing.map((outOf) => ({
|
||||
from: into.from,
|
||||
to: outOf.to,
|
||||
outputIndex: into.outputIndex,
|
||||
inputIndex: outOf.inputIndex,
|
||||
})),
|
||||
);
|
||||
|
||||
edges = this.dedupeEdges(
|
||||
edges
|
||||
.filter((edge) => edge.from !== disabledNodeId && edge.to !== disabledNodeId)
|
||||
.concat(spliced),
|
||||
);
|
||||
}
|
||||
|
||||
return edges;
|
||||
}
|
||||
|
||||
private dedupeEdges(edges: GraphEdge[]): GraphEdge[] {
|
||||
const byKey = new Map<string, GraphEdge>();
|
||||
for (const edge of edges) {
|
||||
byKey.set(`${edge.from}|${edge.to}|${edge.outputIndex}|${edge.inputIndex}`, edge);
|
||||
}
|
||||
return [...byKey.values()];
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds every loop, marks its return edges as `isBackEdge`, and rejects
|
||||
* every other cycle, peeling loops from the outside in. Each round: group
|
||||
* the nodes that can reach each other in a circle ({@link computeSccs}),
|
||||
* require each group to have a single valid entry
|
||||
* ({@link resolveSingleBatchEntry}), mark the group's edges into that
|
||||
* entry as returns, and cut them. Cutting breaks the outer circle, so a
|
||||
* nested loop surfaces as its own group in the next round. When nothing
|
||||
* circular remains, every loop is marked.
|
||||
*
|
||||
* Example with a nested loop:
|
||||
*
|
||||
* ┌───────┐ ┌─────┐ ┌─────┐ ┌──────────┐
|
||||
* │Trigger├──►│ ├───►│ ├───►│AfterInner│
|
||||
* └───────┘ │Outer│ │Inner│ └────┬─────┘
|
||||
* │ │ │ │ ┌────┐ │
|
||||
* │ │ │ ├►│Body│ │
|
||||
* └──▲──┘ └──▲──┘ └──┬─┘ │
|
||||
* │ └(back)─┘ │
|
||||
* └───────(back)──────────┘
|
||||
*
|
||||
* Round 1 groups {Outer, Inner, Body, AfterInner} with entry Outer, then
|
||||
* marks and cuts AfterInner -> Outer. Round 2 groups {Inner, Body} with
|
||||
* entry Inner, then marks and cuts Body -> Inner. Round 3 finds nothing
|
||||
* circular and stops.
|
||||
*
|
||||
* Only set membership decides, never traversal order, so the outcome does
|
||||
* not depend on node or edge order.
|
||||
*/
|
||||
private markBackEdges(nodes: GraphNode[], edges: GraphEdge[]): void {
|
||||
const batchNodeIds = new Set(nodes.filter((node) => node.type === 'batch').map((n) => n.id));
|
||||
const namesById = new Map(nodes.map((node) => [node.id, node.name]));
|
||||
|
||||
let remaining = edges;
|
||||
while (remaining.length > 0) {
|
||||
const outgoingByNode = new Map<string, GraphEdge[]>();
|
||||
const selfLoopNodeIds = new Set<string>();
|
||||
for (const edge of remaining) {
|
||||
const outgoing = outgoingByNode.get(edge.from);
|
||||
if (outgoing) outgoing.push(edge);
|
||||
else outgoingByNode.set(edge.from, [edge]);
|
||||
if (edge.from === edge.to) selfLoopNodeIds.add(edge.from);
|
||||
}
|
||||
|
||||
const cyclicSccs = this.computeSccs(nodes, outgoingByNode).filter(
|
||||
(members) => members.length > 1 || selfLoopNodeIds.has(members[0]),
|
||||
);
|
||||
if (cyclicSccs.length === 0) return;
|
||||
|
||||
for (const members of cyclicSccs) {
|
||||
const entryId = this.resolveSingleBatchEntry(members, remaining, batchNodeIds, namesById);
|
||||
const memberSet = new Set(members);
|
||||
for (const edge of remaining) {
|
||||
if (edge.to === entryId && memberSet.has(edge.from)) edge.isBackEdge = true;
|
||||
}
|
||||
}
|
||||
|
||||
remaining = remaining.filter((edge) => !edge.isBackEdge);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A loop is convertible only when there is exactly one way into it from
|
||||
* the outside and that way in is the batch node. Otherwise there is no
|
||||
* single answer to which edge is the return. A loop that nothing points
|
||||
* into has no outside entries, so its batch node stands in, provided it
|
||||
* has exactly one.
|
||||
*/
|
||||
private resolveSingleBatchEntry(
|
||||
members: string[],
|
||||
edges: GraphEdge[],
|
||||
batchNodeIds: Set<string>,
|
||||
namesById: Map<string, string>,
|
||||
): string {
|
||||
const memberSet = new Set(members);
|
||||
const toNames = (ids: string[]) => ids.map((id) => namesById.get(id) ?? id);
|
||||
|
||||
const batchMembers = members.filter((id) => batchNodeIds.has(id));
|
||||
if (batchMembers.length === 0) {
|
||||
throw new UnsupportedCycleError(toNames(members));
|
||||
}
|
||||
|
||||
const externalEntries = new Set<string>();
|
||||
for (const edge of edges) {
|
||||
if (memberSet.has(edge.to) && !memberSet.has(edge.from)) externalEntries.add(edge.to);
|
||||
}
|
||||
|
||||
const entries = externalEntries.size > 0 ? [...externalEntries] : batchMembers;
|
||||
if (entries.length !== 1 || !batchNodeIds.has(entries[0])) {
|
||||
throw new UnsupportedLoopEntryError(toNames(members), toNames(entries));
|
||||
}
|
||||
|
||||
return entries[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the nodes into groups (strongly connected components): two nodes
|
||||
* share a group when you can walk from one to the other and back along
|
||||
* edges, through any number of nodes. A -> B -> C -> A puts all three in
|
||||
* one group. A node on no such round trip is a group of one, so a group
|
||||
* with more than one member, or with a self-loop, is a cycle.
|
||||
* Textbook Tarjan's algorithm, best reviewed against a reference:
|
||||
* https://en.wikipedia.org/wiki/Tarjan%27s_strongly_connected_components_algorithm
|
||||
*/
|
||||
private computeSccs(nodes: GraphNode[], outgoingByNode: Map<string, GraphEdge[]>): string[][] {
|
||||
const indexById = new Map<string, number>();
|
||||
const lowlinkById = new Map<string, number>();
|
||||
const stack: string[] = [];
|
||||
const onStack = new Set<string>();
|
||||
const sccs: string[][] = [];
|
||||
let nextIndex = 0;
|
||||
|
||||
const connect = (nodeId: string): void => {
|
||||
indexById.set(nodeId, nextIndex);
|
||||
lowlinkById.set(nodeId, nextIndex);
|
||||
nextIndex += 1;
|
||||
stack.push(nodeId);
|
||||
onStack.add(nodeId);
|
||||
|
||||
for (const edge of outgoingByNode.get(nodeId) ?? []) {
|
||||
if (!indexById.has(edge.to)) {
|
||||
connect(edge.to);
|
||||
lowlinkById.set(nodeId, Math.min(lowlinkById.get(nodeId)!, lowlinkById.get(edge.to)!));
|
||||
} else if (onStack.has(edge.to)) {
|
||||
lowlinkById.set(nodeId, Math.min(lowlinkById.get(nodeId)!, indexById.get(edge.to)!));
|
||||
}
|
||||
}
|
||||
|
||||
if (lowlinkById.get(nodeId) === indexById.get(nodeId)) {
|
||||
const members: string[] = [];
|
||||
let member: string;
|
||||
do {
|
||||
member = stack.pop()!;
|
||||
onStack.delete(member);
|
||||
members.push(member);
|
||||
} while (member !== nodeId);
|
||||
sccs.push(members);
|
||||
}
|
||||
};
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!indexById.has(node.id)) connect(node.id);
|
||||
}
|
||||
|
||||
return sccs;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user