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; export type DiffableWorkflow = { nodes: N[]; connections: IConnections; createdAt: Date; authors?: string; }; export const enum NodeDiffStatus { Eq = 'equal', Modified = 'modified', Added = 'added', Deleted = 'deleted', } export type NodeDiff = { status: NodeDiffStatus; node: T; }; export type WorkflowDiff = Map>; export function compareNodes( 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( base: T[], target: T[], nodesEqual: (base: T | undefined, target: T | undefined) => boolean = compareNodes, ): WorkflowDiff { const baseNodes = base.reduce>((acc, node) => { acc.set(node.id, node); return acc; }, new Map()); const targetNodes = target.reduce>((acc, node) => { acc.set(node.id, node); return acc; }, new Map()); const diff: WorkflowDiff = 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 { readonly nodes: WorkflowDiff; readonly connections: ConnectionsDiff; constructor(from: DiffableWorkflow, to: DiffableWorkflow) { 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)[key], (next as Record)[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(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( _prev: DiffableWorkflow, next: DiffableWorkflow, diff: WorkflowChangeSet, ) { 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 ( prev: DiffableWorkflow, next: DiffableWorkflow, ) => { const timeDifference = next.createdAt.getTime() - prev.createdAt.getTime(); return Math.abs(timeDifference) > timeDiffMs; }; }; const makeMergeShortTimeSpan = (timeDiffMs: number) => { return ( prev: DiffableWorkflow, next: DiffableWorkflow, ) => { 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(mapping: Map) { const pairs = [...mapping.entries()] .sort((a, b) => b[0] - a[0]) .map(([count, time]) => [count, makeMergeShortTimeSpan(time)] as const); return ( prev: DiffableWorkflow, next: DiffableWorkflow, _wcs: WorkflowChangeSet, 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( prev: DiffableWorkflow, next: DiffableWorkflow, ) { 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, metaData: Partial) => 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((acc, v) => acc + determineNodeSize(v as INodeParameters), 1); } else { // Record case return Object.values(parameters).reduce( (acc, v) => acc + determineNodeSize(v as NodeParameterValueType), 1, ); } } function determineNodeParametersSize(workflow: W) { return workflow.nodes.reduce((acc, x) => acc + determineNodeSize(x.parameters), 0); } export function groupWorkflows( workflows: W[], rules: Array>, skipRules: Array> = [], metaDataFields?: Partial>, ): { 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; }