mirror of
https://github.com/n8n-io/n8n.git
synced 2026-05-31 16:57:08 +02:00
410 lines
12 KiB
TypeScript
410 lines
12 KiB
TypeScript
import isEqual from 'lodash/isEqual';
|
|
import pick from 'lodash/pick';
|
|
|
|
import type {
|
|
IConnections,
|
|
INode,
|
|
INodeParameters,
|
|
IWorkflowBase,
|
|
NodeParameterValueType,
|
|
} from '.';
|
|
import { compareConnections, type ConnectionsDiff } from './connections-diff';
|
|
|
|
export type WorkflowDiffBase = Omit<
|
|
IWorkflowBase,
|
|
'id' | 'active' | 'activeVersionId' | 'isArchived' | 'name'
|
|
> & { name: string | null };
|
|
|
|
export type DiffableNode = Pick<INode, 'id' | 'parameters' | 'name'>;
|
|
export type DiffableWorkflow<N extends DiffableNode = DiffableNode> = {
|
|
nodes: N[];
|
|
connections: IConnections;
|
|
createdAt: Date;
|
|
authors?: string;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
export class WorkflowChangeSet<T extends DiffableNode> {
|
|
readonly nodes: WorkflowDiff<T>;
|
|
readonly connections: ConnectionsDiff;
|
|
|
|
constructor(from: DiffableWorkflow<T>, to: DiffableWorkflow<T>) {
|
|
if (from === to) {
|
|
// avoid expensive deep comparison
|
|
this.nodes = new Map(
|
|
from.nodes.map((node) => [node.id, { node, status: NodeDiffStatus.Eq }]),
|
|
);
|
|
this.connections = { added: {}, removed: {} };
|
|
} else {
|
|
this.nodes = compareWorkflowsNodes(from.nodes, to.nodes);
|
|
this.connections = compareConnections(from.connections, to.connections);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns true if `s` contains all characters of `substr` in order
|
|
* e.g. s='abcde'
|
|
* substr:
|
|
* 'abde' -> true
|
|
* 'abcd' -> false
|
|
* 'abced' -> false
|
|
*/
|
|
export function stringContainsParts(s: string, substr: string) {
|
|
if (substr.length > s.length) return false;
|
|
const diffSize = s.length - substr.length;
|
|
let marker = 0;
|
|
for (let i = 0; i < s.length; ++i) {
|
|
if (substr[marker] === s[i]) marker++;
|
|
|
|
if (i - marker > diffSize) return false;
|
|
}
|
|
return marker >= substr.length;
|
|
}
|
|
|
|
export function parametersAreSuperset(prev: unknown, next: unknown): boolean {
|
|
if (typeof prev !== typeof next) return false;
|
|
if (typeof prev !== 'object' || !prev || !next) {
|
|
if (typeof prev === 'string') {
|
|
// We assert above that these are the same type
|
|
return stringContainsParts(next as string, prev);
|
|
}
|
|
return prev === next;
|
|
}
|
|
|
|
if (Array.isArray(prev)) {
|
|
if (!Array.isArray(next)) return false;
|
|
if (prev.length !== next.length) return false;
|
|
return prev.every((v, i) => parametersAreSuperset(v, next[i]));
|
|
}
|
|
|
|
const params = Object.keys(prev);
|
|
|
|
if (params.length !== Object.keys(next).length) return false;
|
|
// abort if keys differ
|
|
if (params.some((x) => !Object.prototype.hasOwnProperty.call(next, x))) return false;
|
|
|
|
return params.every((key) =>
|
|
parametersAreSuperset(
|
|
(prev as Record<string, unknown>)[key],
|
|
(next as Record<string, unknown>)[key],
|
|
),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Determines whether the second node is a "superset" of the first one, i.e. whether no data
|
|
* is lost if we were to replace `prev` with `next`.
|
|
*
|
|
* Specifically this is the case if
|
|
* - Both nodes have the exact same keys
|
|
* - All values are either strings where `next.x` contains `prev.x`, or hold the exact same value
|
|
*/
|
|
function nodeIsSuperset<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;
|
|
|
|
return parametersAreSuperset(prevParams, nextParams);
|
|
}
|
|
|
|
function mergeAdditiveChanges<N extends DiffableNode = DiffableNode>(
|
|
_prev: DiffableWorkflow<N>,
|
|
next: DiffableWorkflow<N>,
|
|
diff: WorkflowChangeSet<N>,
|
|
) {
|
|
for (const d of diff.nodes.values()) {
|
|
if (d.status === NodeDiffStatus.Deleted) return false;
|
|
if (d.status === NodeDiffStatus.Added) continue;
|
|
const nextNode = next.nodes.find((x) => x.id === d.node.id);
|
|
if (!nextNode) throw new Error('invariant broken - no next node');
|
|
if (d.status === NodeDiffStatus.Modified && !nodeIsSuperset(d.node, nextNode)) return false;
|
|
}
|
|
|
|
if (Object.keys(diff.connections.removed).length > 0) return false;
|
|
|
|
return true;
|
|
}
|
|
|
|
// We want to avoid merging versions from different editing "sessions"
|
|
//
|
|
const makeSkipTimeDifference = (timeDiffMs: number) => {
|
|
return <N extends DiffableNode = DiffableNode>(
|
|
prev: DiffableWorkflow<N>,
|
|
next: DiffableWorkflow<N>,
|
|
) => {
|
|
const timeDifference = next.createdAt.getTime() - prev.createdAt.getTime();
|
|
|
|
return Math.abs(timeDifference) > timeDiffMs;
|
|
};
|
|
};
|
|
|
|
const makeMergeShortTimeSpan = (timeDiffMs: number) => {
|
|
return <N extends DiffableNode = DiffableNode>(
|
|
prev: DiffableWorkflow<N>,
|
|
next: DiffableWorkflow<N>,
|
|
) => {
|
|
const timeDifference = next.createdAt.getTime() - prev.createdAt.getTime();
|
|
|
|
return Math.abs(timeDifference) < timeDiffMs;
|
|
};
|
|
};
|
|
|
|
// Takes a mapping from minimumSize to the minimum time between versions and
|
|
// applies the largest one applicable to the given workflow
|
|
function makeMergeDependingOnSizeRule<W extends DiffableWorkflow>(mapping: Map<number, number>) {
|
|
const pairs = [...mapping.entries()]
|
|
.sort((a, b) => b[0] - a[0])
|
|
.map(([count, time]) => [count, makeMergeShortTimeSpan(time)] as const);
|
|
|
|
return <N extends DiffableNode = DiffableNode>(
|
|
prev: DiffableWorkflow<N>,
|
|
next: DiffableWorkflow<N>,
|
|
_wcs: WorkflowChangeSet<N>,
|
|
metaData: DiffMetaData,
|
|
) => {
|
|
if (metaData.workflowSizeScore === undefined) {
|
|
console.warn('Called mergeDependingOnSizeRule rule without providing required metaData');
|
|
return false;
|
|
}
|
|
for (const [count, time] of pairs) {
|
|
if (metaData.workflowSizeScore > count) return time(prev, next);
|
|
}
|
|
return false;
|
|
};
|
|
}
|
|
|
|
function skipDifferentUsers<N extends DiffableNode = DiffableNode>(
|
|
prev: DiffableWorkflow<N>,
|
|
next: DiffableWorkflow<N>,
|
|
) {
|
|
return next.authors !== prev.authors;
|
|
}
|
|
|
|
export const RULES = {
|
|
mergeAdditiveChanges,
|
|
makeMergeDependingOnSizeRule,
|
|
};
|
|
|
|
export const SKIP_RULES = {
|
|
makeSkipTimeDifference,
|
|
skipDifferentUsers,
|
|
};
|
|
|
|
// MetaData fields are only included if requested
|
|
export type DiffMetaData = Partial<{
|
|
workflowSizeScore: number;
|
|
}>;
|
|
|
|
export type DiffRule<
|
|
W extends WorkflowDiffBase = WorkflowDiffBase,
|
|
N extends W['nodes'][number] = W['nodes'][number],
|
|
> = (prev: W, next: W, diff: WorkflowChangeSet<N>, metaData: Partial<DiffMetaData>) => boolean;
|
|
|
|
// Rough estimation of a node's size in abstract "character" count
|
|
// Does not care about key names which do technically factor in when stringified
|
|
export function determineNodeSize(parameters: INodeParameters | NodeParameterValueType): number {
|
|
if (!parameters) return 1;
|
|
|
|
if (typeof parameters === 'string') {
|
|
return parameters.length;
|
|
} else if (typeof parameters !== 'object' || parameters instanceof Date) {
|
|
return 1;
|
|
} else if (Array.isArray(parameters)) {
|
|
return parameters.reduce<number>((acc, v) => acc + determineNodeSize(v as INodeParameters), 1);
|
|
} else {
|
|
// Record case
|
|
return Object.values(parameters).reduce<number>(
|
|
(acc, v) => acc + determineNodeSize(v as NodeParameterValueType),
|
|
1,
|
|
);
|
|
}
|
|
}
|
|
|
|
function determineNodeParametersSize<W extends WorkflowDiffBase>(workflow: W) {
|
|
return workflow.nodes.reduce((acc, x) => acc + determineNodeSize(x.parameters), 0);
|
|
}
|
|
|
|
export function groupWorkflows<W extends WorkflowDiffBase = WorkflowDiffBase>(
|
|
workflows: W[],
|
|
rules: Array<DiffRule<W>>,
|
|
skipRules: Array<DiffRule<W>> = [],
|
|
metaDataFields?: Partial<Record<keyof DiffMetaData, boolean>>,
|
|
): { removed: W[]; remaining: W[] } {
|
|
if (workflows.length === 0) return { removed: [], remaining: [] };
|
|
if (workflows.length === 1) {
|
|
return {
|
|
removed: [],
|
|
remaining: workflows,
|
|
};
|
|
}
|
|
|
|
const remaining = [...workflows];
|
|
const removed: W[] = [];
|
|
|
|
const n = remaining.length;
|
|
|
|
const metaData = {
|
|
// check latest and an "average" workflow to get a somewhat accurate representation
|
|
// without counting through the entire history
|
|
workflowSizeScore: metaDataFields?.workflowSizeScore
|
|
? Math.max(
|
|
determineNodeParametersSize(workflows[Math.floor(workflows.length / 2)]),
|
|
determineNodeParametersSize(workflows[workflows.length - 1]),
|
|
)
|
|
: undefined,
|
|
} satisfies DiffMetaData;
|
|
|
|
diffLoop: for (let i = n - 1; i > 0; --i) {
|
|
const wcs = new WorkflowChangeSet(remaining[i - 1], remaining[i]);
|
|
|
|
for (const shouldSkip of skipRules) {
|
|
if (shouldSkip(remaining[i - 1], remaining[i], wcs, metaData)) continue diffLoop;
|
|
}
|
|
for (const rule of rules) {
|
|
const shouldMerge = rule(remaining[i - 1], remaining[i], wcs, metaData);
|
|
if (shouldMerge) {
|
|
const left = remaining.splice(i - 1, 1)[0];
|
|
removed.push(left);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { removed, remaining };
|
|
}
|
|
|
|
/**
|
|
* Checks if workflows have non-positional differences (changes to nodes or connections,
|
|
* excluding position changes).
|
|
* Returns true if there are meaningful changes, false if only positions changed.
|
|
*/
|
|
export function hasNonPositionalChanges(
|
|
oldNodes: INode[],
|
|
newNodes: INode[],
|
|
oldConnections: IConnections,
|
|
newConnections: IConnections,
|
|
): boolean {
|
|
// Check for node changes (compareNodes already excludes position)
|
|
const nodesDiff = compareWorkflowsNodes(oldNodes, newNodes);
|
|
for (const diff of nodesDiff.values()) {
|
|
if (diff.status !== NodeDiffStatus.Eq) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Check for connection changes (connections don't have position data)
|
|
if (!isEqual(oldConnections, newConnections)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Checks if any credential IDs changed between old and new workflow nodes.
|
|
* Compares node by node - returns true if for any node:
|
|
* - A credential was added (new credential type not in old node)
|
|
* - A credential was removed (old credential type not in new node)
|
|
* - A credential was changed (same credential type but different credential ID)
|
|
*/
|
|
export function hasCredentialChanges(oldNodes: INode[], newNodes: INode[]): boolean {
|
|
const newNodesMap = new Map(newNodes.map((node) => [node.id, node]));
|
|
|
|
for (const oldNode of oldNodes) {
|
|
const newNode = newNodesMap.get(oldNode.id);
|
|
|
|
// Skip nodes that were deleted - deletion is not a credential change
|
|
if (!newNode) continue;
|
|
|
|
const oldCreds = oldNode.credentials ?? {};
|
|
const newCreds = newNode.credentials ?? {};
|
|
|
|
const oldCredTypes = Object.keys(oldCreds);
|
|
const newCredTypes = Object.keys(newCreds);
|
|
|
|
// Check for removed credentials (in old but not in new)
|
|
for (const credType of oldCredTypes) {
|
|
if (!(credType in newCreds)) {
|
|
return true; // Credential removed
|
|
}
|
|
// Check for changed credentials (same type but different ID)
|
|
if (oldCreds[credType]?.id !== newCreds[credType]?.id) {
|
|
return true; // Credential changed
|
|
}
|
|
}
|
|
|
|
// Check for added credentials (in new but not in old)
|
|
for (const credType of newCredTypes) {
|
|
if (!(credType in oldCreds)) {
|
|
return true; // Credential added
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|