node-migration-service.ts•11.2 kB
/**
* Node Migration Service
*
* Handles smart auto-migration of node configurations during version upgrades.
* Applies migration strategies from the breaking changes registry and detectors.
*
* Migration strategies:
* - add_property: Add new required/optional properties with defaults
* - remove_property: Remove deprecated properties
* - rename_property: Rename properties that changed names
* - set_default: Set default values for properties
*/
import { v4 as uuidv4 } from 'uuid';
import { BreakingChangeDetector, DetectedChange } from './breaking-change-detector';
import { NodeVersionService } from './node-version-service';
export interface MigrationResult {
success: boolean;
nodeId: string;
nodeName: string;
fromVersion: string;
toVersion: string;
appliedMigrations: AppliedMigration[];
remainingIssues: string[];
confidence: 'HIGH' | 'MEDIUM' | 'LOW';
updatedNode: any; // The migrated node configuration
}
export interface AppliedMigration {
propertyName: string;
action: string;
oldValue?: any;
newValue?: any;
description: string;
}
export class NodeMigrationService {
constructor(
private versionService: NodeVersionService,
private breakingChangeDetector: BreakingChangeDetector
) {}
/**
* Migrate a node from its current version to a target version
*/
async migrateNode(
node: any,
fromVersion: string,
toVersion: string
): Promise<MigrationResult> {
const nodeId = node.id || 'unknown';
const nodeName = node.name || 'Unknown Node';
const nodeType = node.type;
// Analyze the version upgrade
const analysis = await this.breakingChangeDetector.analyzeVersionUpgrade(
nodeType,
fromVersion,
toVersion
);
// Start with a copy of the node
const migratedNode = JSON.parse(JSON.stringify(node));
// Apply the version update
migratedNode.typeVersion = this.parseVersion(toVersion);
const appliedMigrations: AppliedMigration[] = [];
const remainingIssues: string[] = [];
// Apply auto-migratable changes
for (const change of analysis.changes.filter(c => c.autoMigratable)) {
const migration = this.applyMigration(migratedNode, change);
if (migration) {
appliedMigrations.push(migration);
}
}
// Collect remaining manual issues
for (const change of analysis.changes.filter(c => !c.autoMigratable)) {
remainingIssues.push(
`Manual action required for "${change.propertyName}": ${change.migrationHint}`
);
}
// Determine confidence based on remaining issues
let confidence: 'HIGH' | 'MEDIUM' | 'LOW' = 'HIGH';
if (remainingIssues.length > 0) {
confidence = remainingIssues.length > 3 ? 'LOW' : 'MEDIUM';
}
return {
success: remainingIssues.length === 0,
nodeId,
nodeName,
fromVersion,
toVersion,
appliedMigrations,
remainingIssues,
confidence,
updatedNode: migratedNode
};
}
/**
* Apply a single migration change to a node
*/
private applyMigration(node: any, change: DetectedChange): AppliedMigration | null {
if (!change.migrationStrategy) return null;
const { type, defaultValue, sourceProperty, targetProperty } = change.migrationStrategy;
switch (type) {
case 'add_property':
return this.addProperty(node, change.propertyName, defaultValue, change);
case 'remove_property':
return this.removeProperty(node, change.propertyName, change);
case 'rename_property':
return this.renameProperty(node, sourceProperty!, targetProperty!, change);
case 'set_default':
return this.setDefault(node, change.propertyName, defaultValue, change);
default:
return null;
}
}
/**
* Add a new property to the node configuration
*/
private addProperty(
node: any,
propertyPath: string,
defaultValue: any,
change: DetectedChange
): AppliedMigration {
const value = this.resolveDefaultValue(propertyPath, defaultValue, node);
// Handle nested property paths (e.g., "parameters.inputFieldMapping")
const parts = propertyPath.split('.');
let target = node;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!target[part]) {
target[part] = {};
}
target = target[part];
}
const finalKey = parts[parts.length - 1];
target[finalKey] = value;
return {
propertyName: propertyPath,
action: 'Added property',
newValue: value,
description: `Added "${propertyPath}" with default value`
};
}
/**
* Remove a deprecated property from the node configuration
*/
private removeProperty(
node: any,
propertyPath: string,
change: DetectedChange
): AppliedMigration | null {
const parts = propertyPath.split('.');
let target = node;
for (let i = 0; i < parts.length - 1; i++) {
const part = parts[i];
if (!target[part]) return null; // Property doesn't exist
target = target[part];
}
const finalKey = parts[parts.length - 1];
const oldValue = target[finalKey];
if (oldValue !== undefined) {
delete target[finalKey];
return {
propertyName: propertyPath,
action: 'Removed property',
oldValue,
description: `Removed deprecated property "${propertyPath}"`
};
}
return null;
}
/**
* Rename a property (move value from old name to new name)
*/
private renameProperty(
node: any,
sourcePath: string,
targetPath: string,
change: DetectedChange
): AppliedMigration | null {
// Get old value
const sourceParts = sourcePath.split('.');
let sourceTarget = node;
for (let i = 0; i < sourceParts.length - 1; i++) {
if (!sourceTarget[sourceParts[i]]) return null;
sourceTarget = sourceTarget[sourceParts[i]];
}
const sourceKey = sourceParts[sourceParts.length - 1];
const oldValue = sourceTarget[sourceKey];
if (oldValue === undefined) return null; // Source doesn't exist
// Set new value
const targetParts = targetPath.split('.');
let targetTarget = node;
for (let i = 0; i < targetParts.length - 1; i++) {
if (!targetTarget[targetParts[i]]) {
targetTarget[targetParts[i]] = {};
}
targetTarget = targetTarget[targetParts[i]];
}
const targetKey = targetParts[targetParts.length - 1];
targetTarget[targetKey] = oldValue;
// Remove old value
delete sourceTarget[sourceKey];
return {
propertyName: targetPath,
action: 'Renamed property',
oldValue: `${sourcePath}: ${JSON.stringify(oldValue)}`,
newValue: `${targetPath}: ${JSON.stringify(oldValue)}`,
description: `Renamed "${sourcePath}" to "${targetPath}"`
};
}
/**
* Set a default value for a property
*/
private setDefault(
node: any,
propertyPath: string,
defaultValue: any,
change: DetectedChange
): AppliedMigration | null {
const parts = propertyPath.split('.');
let target = node;
for (let i = 0; i < parts.length - 1; i++) {
if (!target[parts[i]]) {
target[parts[i]] = {};
}
target = target[parts[i]];
}
const finalKey = parts[parts.length - 1];
// Only set if not already defined
if (target[finalKey] === undefined) {
const value = this.resolveDefaultValue(propertyPath, defaultValue, node);
target[finalKey] = value;
return {
propertyName: propertyPath,
action: 'Set default value',
newValue: value,
description: `Set default value for "${propertyPath}"`
};
}
return null;
}
/**
* Resolve default value with special handling for certain property types
*/
private resolveDefaultValue(propertyPath: string, defaultValue: any, node: any): any {
// Special case: webhookId needs a UUID
if (propertyPath === 'webhookId' || propertyPath.endsWith('.webhookId')) {
return uuidv4();
}
// Special case: webhook path needs a unique value
if (propertyPath === 'path' || propertyPath.endsWith('.path')) {
if (node.type === 'n8n-nodes-base.webhook') {
return `/webhook-${Date.now()}`;
}
}
// Return provided default or null
return defaultValue !== null && defaultValue !== undefined ? defaultValue : null;
}
/**
* Parse version string to number (for typeVersion field)
*/
private parseVersion(version: string): number {
const parts = version.split('.').map(Number);
// Handle versions like "1.1" -> 1.1, "2.0" -> 2
if (parts.length === 1) return parts[0];
if (parts.length === 2) return parts[0] + parts[1] / 10;
// For more complex versions, just use first number
return parts[0];
}
/**
* Validate that a migrated node is valid
*/
async validateMigratedNode(node: any, nodeType: string): Promise<{
valid: boolean;
errors: string[];
warnings: string[];
}> {
const errors: string[] = [];
const warnings: string[] = [];
// Basic validation
if (!node.typeVersion) {
errors.push('Missing typeVersion after migration');
}
if (!node.parameters) {
errors.push('Missing parameters object');
}
// Check for common issues
if (nodeType === 'n8n-nodes-base.webhook') {
if (!node.parameters?.path) {
errors.push('Webhook node missing required "path" parameter');
}
if (node.typeVersion >= 2.1 && !node.webhookId) {
warnings.push('Webhook v2.1+ typically requires webhookId');
}
}
if (nodeType === 'n8n-nodes-base.executeWorkflow') {
if (node.typeVersion >= 1.1 && !node.parameters?.inputFieldMapping) {
errors.push('Execute Workflow v1.1+ requires inputFieldMapping');
}
}
return {
valid: errors.length === 0,
errors,
warnings
};
}
/**
* Batch migrate multiple nodes in a workflow
*/
async migrateWorkflowNodes(
workflow: any,
targetVersions: Record<string, string> // nodeId -> targetVersion
): Promise<{
success: boolean;
results: MigrationResult[];
overallConfidence: 'HIGH' | 'MEDIUM' | 'LOW';
}> {
const results: MigrationResult[] = [];
for (const node of workflow.nodes || []) {
const targetVersion = targetVersions[node.id];
if (targetVersion && node.typeVersion) {
const currentVersion = node.typeVersion.toString();
const result = await this.migrateNode(node, currentVersion, targetVersion);
results.push(result);
// Update node in place
Object.assign(node, result.updatedNode);
}
}
// Calculate overall confidence
const confidences = results.map(r => r.confidence);
let overallConfidence: 'HIGH' | 'MEDIUM' | 'LOW' = 'HIGH';
if (confidences.includes('LOW')) {
overallConfidence = 'LOW';
} else if (confidences.includes('MEDIUM')) {
overallConfidence = 'MEDIUM';
}
const success = results.every(r => r.success);
return {
success,
results,
overallConfidence
};
}
}