import isEqual from 'lodash/isEqual'; import pick from 'lodash/pick'; import type { INode, IWorkflowBase } from '.'; export type DiffableNode = Pick; export type DiffableWorkflow = { nodes: N[]; }; 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; } 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 { constructor(public nodes: WorkflowDiff = new Map()) {} hasChanges() { for (const nodeDiff of this.nodes.values()) { if (nodeDiff.status !== NodeDiffStatus.Eq) return true; } return false; } mergeNext(wcs: WorkflowChangeSet) { 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(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( _prev: GroupedWorkflowHistory>, next: GroupedWorkflowHistory>, diff: WorkflowDiff, ) { 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> = { workflowChangeSet: WorkflowChangeSet; groupedWorkflows: W[]; from: W; to: W; }; function compareWorkflows( previous: W, next: W, ): GroupedWorkflowHistory { 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, next: GroupedWorkflowHistory, diff: WorkflowDiff, ) => boolean; export function groupWorkflows( workflows: W[], rules: Array>, ): Array> { if (workflows.length === 0) return []; if (workflows.length === 1) { return [ { workflowChangeSet: new WorkflowChangeSet(), groupedWorkflows: [], from: workflows[0], to: workflows[0], }, ]; } const diffs: Array> = []; 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; }