mirror of
https://github.com/n8n-io/n8n.git
synced 2026-06-01 09:17:08 +02:00
263 lines
7.1 KiB
TypeScript
263 lines
7.1 KiB
TypeScript
import isEqual from 'lodash/isEqual';
|
|
import pick from 'lodash/pick';
|
|
import type { INode, IWorkflowBase } from '.';
|
|
|
|
export type DiffableNode = Pick<INode, 'id' | 'parameters' | 'name'>;
|
|
export type DiffableWorkflow<N extends DiffableNode = DiffableNode> = {
|
|
nodes: N[];
|
|
};
|
|
|
|
export const enum NodeDiffStatus {
|
|
Eq = 'equal',
|
|
Modified = 'modified',
|
|
Added = 'added',
|
|
Deleted = 'deleted',
|
|
}
|
|
|
|
export type NodeDiff<T> = {
|
|
status: NodeDiffStatus;
|
|
node: T;
|
|
};
|
|
|
|
export type WorkflowDiff<T> = Map<string, NodeDiff<T>>;
|
|
|
|
export function compareNodes<T extends DiffableNode>(
|
|
base: T | undefined,
|
|
target: T | undefined,
|
|
): boolean {
|
|
const propsToCompare = ['name', 'type', 'typeVersion', 'webhookId', 'credentials', 'parameters'];
|
|
|
|
const baseNode = pick(base, propsToCompare);
|
|
const targetNode = pick(target, propsToCompare);
|
|
|
|
return isEqual(baseNode, targetNode);
|
|
}
|
|
|
|
export function compareWorkflowsNodes<T extends DiffableNode>(
|
|
base: T[],
|
|
target: T[],
|
|
nodesEqual: (base: T | undefined, target: T | undefined) => boolean = compareNodes,
|
|
): WorkflowDiff<T> {
|
|
const baseNodes = base.reduce<Map<string, T>>((acc, node) => {
|
|
acc.set(node.id, node);
|
|
return acc;
|
|
}, new Map());
|
|
|
|
const targetNodes = target.reduce<Map<string, T>>((acc, node) => {
|
|
acc.set(node.id, node);
|
|
return acc;
|
|
}, new Map());
|
|
|
|
const diff: WorkflowDiff<T> = new Map();
|
|
|
|
for (const [id, node] of baseNodes.entries()) {
|
|
if (!targetNodes.has(id)) {
|
|
diff.set(id, { status: NodeDiffStatus.Deleted, node });
|
|
} else if (!nodesEqual(baseNodes.get(id), targetNodes.get(id))) {
|
|
diff.set(id, { status: NodeDiffStatus.Modified, node });
|
|
} else {
|
|
diff.set(id, { status: NodeDiffStatus.Eq, node });
|
|
}
|
|
}
|
|
|
|
for (const [id, node] of targetNodes.entries()) {
|
|
if (!baseNodes.has(id)) {
|
|
diff.set(id, { status: NodeDiffStatus.Added, node });
|
|
}
|
|
}
|
|
|
|
return diff;
|
|
}
|
|
|
|
function mergeNodeDiff(
|
|
prev: NodeDiffStatus,
|
|
next: NodeDiffStatus,
|
|
): NodeDiffStatus | 'undone' | 'invariant broken' {
|
|
switch (prev) {
|
|
case NodeDiffStatus.Added:
|
|
switch (next) {
|
|
case NodeDiffStatus.Added:
|
|
return 'invariant broken';
|
|
case NodeDiffStatus.Deleted:
|
|
return 'undone';
|
|
default:
|
|
return NodeDiffStatus.Added;
|
|
}
|
|
case NodeDiffStatus.Deleted:
|
|
switch (next) {
|
|
case NodeDiffStatus.Added:
|
|
return NodeDiffStatus.Modified;
|
|
default:
|
|
return 'invariant broken';
|
|
}
|
|
case NodeDiffStatus.Eq:
|
|
switch (next) {
|
|
case NodeDiffStatus.Added:
|
|
return 'invariant broken';
|
|
default:
|
|
return next;
|
|
}
|
|
case NodeDiffStatus.Modified:
|
|
switch (next) {
|
|
case NodeDiffStatus.Added:
|
|
return 'invariant broken';
|
|
case NodeDiffStatus.Deleted:
|
|
return NodeDiffStatus.Deleted;
|
|
default:
|
|
return NodeDiffStatus.Modified;
|
|
}
|
|
}
|
|
}
|
|
|
|
export class WorkflowChangeSet<T extends DiffableNode> {
|
|
constructor(public nodes: WorkflowDiff<T> = new Map()) {}
|
|
|
|
hasChanges() {
|
|
for (const nodeDiff of this.nodes.values()) {
|
|
if (nodeDiff.status !== NodeDiffStatus.Eq) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
mergeNext(wcs: WorkflowChangeSet<T>) {
|
|
for (const [key, diff] of wcs.nodes) {
|
|
const existing = this.nodes.get(key);
|
|
if (existing) {
|
|
const diffStatus = mergeNodeDiff(existing.status, diff.status);
|
|
if (diffStatus === 'invariant broken') {
|
|
throw new Error('invariant broken');
|
|
}
|
|
if (diffStatus === 'undone') {
|
|
this.nodes.delete(key);
|
|
} else {
|
|
this.nodes.set(key, { ...diff, status: diffStatus });
|
|
}
|
|
} else {
|
|
this.nodes.set(key, { ...diff, status: NodeDiffStatus.Added });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// determines whether the second node is a "superset" of the first one, i.e. whether no data
|
|
// is lost if we were to cleanse the first node
|
|
function nodeIsAdditive<T extends DiffableNode>(prevNode: T, nextNode: T) {
|
|
const { parameters: prevParams, ...prev } = prevNode;
|
|
const { parameters: nextParams, ...next } = nextNode;
|
|
|
|
// abort if the nodes don't match besides parameters
|
|
if (!compareNodes({ ...prev, parameters: {} }, { ...next, parameters: {} })) return false;
|
|
|
|
const params = Object.keys(prevParams);
|
|
// abort if prev has some field next does not have
|
|
if (params.some((x) => !Object.prototype.hasOwnProperty.call(nextParams, x))) return false;
|
|
|
|
for (const key of params) {
|
|
const left = prevParams[key];
|
|
const right = nextParams[key];
|
|
// non-strings must be exactly equal to not be lost data
|
|
if (typeof left === 'string' && typeof right === 'string') {
|
|
// strings must only be contained in the new string
|
|
if (!right.includes(left)) return false;
|
|
} else if (left !== right) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
function mergeAdditiveChanges<N extends DiffableNode = DiffableNode>(
|
|
_prev: GroupedWorkflowHistory<DiffableWorkflow<N>>,
|
|
next: GroupedWorkflowHistory<DiffableWorkflow<N>>,
|
|
diff: WorkflowDiff<N>,
|
|
) {
|
|
for (const d of diff.values()) {
|
|
if (d.status === NodeDiffStatus.Deleted) return false;
|
|
if (d.status === NodeDiffStatus.Added) continue;
|
|
const nextNode = next.from.nodes.find((x) => x.name === d.node.name);
|
|
if (!nextNode) throw new Error('invariant broken');
|
|
if (d.status === NodeDiffStatus.Modified && !nodeIsAdditive(d.node, nextNode)) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export const RULES = {
|
|
mergeAdditiveChanges,
|
|
};
|
|
|
|
type GroupedWorkflowHistory<W extends DiffableWorkflow<DiffableNode>> = {
|
|
workflowChangeSet: WorkflowChangeSet<W['nodes'][number]>;
|
|
groupedWorkflows: W[];
|
|
from: W;
|
|
to: W;
|
|
};
|
|
|
|
function compareWorkflows<W extends IWorkflowBase = IWorkflowBase>(
|
|
previous: W,
|
|
next: W,
|
|
): GroupedWorkflowHistory<W> {
|
|
const nodesDiff = compareWorkflowsNodes(previous.nodes, next.nodes);
|
|
const workflowChangeSet = new WorkflowChangeSet(nodesDiff);
|
|
return {
|
|
workflowChangeSet,
|
|
groupedWorkflows: [],
|
|
from: previous,
|
|
to: next,
|
|
};
|
|
}
|
|
|
|
export type DiffRule<
|
|
W extends IWorkflowBase = IWorkflowBase,
|
|
N extends W['nodes'][number] = W['nodes'][number],
|
|
> = (
|
|
prev: GroupedWorkflowHistory<W>,
|
|
next: GroupedWorkflowHistory<W>,
|
|
diff: WorkflowDiff<N>,
|
|
) => boolean;
|
|
|
|
export function groupWorkflows<W extends IWorkflowBase = IWorkflowBase>(
|
|
workflows: W[],
|
|
rules: Array<DiffRule<W>>,
|
|
): Array<GroupedWorkflowHistory<W>> {
|
|
if (workflows.length === 0) return [];
|
|
if (workflows.length === 1) {
|
|
return [
|
|
{
|
|
workflowChangeSet: new WorkflowChangeSet(),
|
|
groupedWorkflows: [],
|
|
from: workflows[0],
|
|
to: workflows[0],
|
|
},
|
|
];
|
|
}
|
|
|
|
const diffs: Array<GroupedWorkflowHistory<W>> = [];
|
|
|
|
for (let i = 0; i < workflows.length - 1; ++i) {
|
|
diffs.push(compareWorkflows(workflows[i], workflows[i + 1]));
|
|
}
|
|
let prevDiffsLength = diffs.length;
|
|
do {
|
|
prevDiffsLength = diffs.length;
|
|
const n = diffs.length;
|
|
for (let i = n - 1; i > 0; --i) {
|
|
const diff = compareWorkflowsNodes(diffs[i - 1].from.nodes, diffs[i].to.nodes);
|
|
for (const rule of rules) {
|
|
const shouldMerge = rule(diffs[i - 1], diffs[i], diff);
|
|
if (shouldMerge) {
|
|
const right = diffs.pop();
|
|
if (!right) throw new Error('invariant broken');
|
|
|
|
// merge diffs
|
|
diffs[i - 1].workflowChangeSet.mergeNext(right.workflowChangeSet);
|
|
diffs[i - 1].groupedWorkflows.push(diffs[i - 1].to);
|
|
diffs[i - 1].groupedWorkflows.push(...right.groupedWorkflows);
|
|
diffs[i - 1].to = right.to;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
} while (prevDiffsLength !== diffs.length);
|
|
|
|
return diffs;
|
|
}
|