mirror of
https://github.com/czlonkowski/n8n-mcp.git
synced 2026-01-30 14:32:04 +00:00
Implemented comprehensive node version upgrade functionality with intelligent migration and breaking change detection. Key Features: - Smart version upgrades (typeversion-upgrade fix type) - Version migration guidance (version-migration fix type) - Auto-migration for Execute Workflow v1.0→v1.1 (adds inputFieldMapping) - Auto-migration for Webhook v2.0→v2.1 (generates webhookId) - Breaking changes registry with extensible patterns - AI-friendly post-update validation guidance - Confidence-based application (HIGH/MEDIUM/LOW) Architecture: - NodeVersionService: Version discovery and comparison - BreakingChangeDetector: Registry + dynamic schema comparison - NodeMigrationService: Smart property migrations - PostUpdateValidator: Step-by-step migration instructions - Enhanced database schema: node_versions, version_property_changes tables Services Created: - src/services/breaking-changes-registry.ts - src/services/breaking-change-detector.ts - src/services/node-version-service.ts - src/services/node-migration-service.ts - src/services/post-update-validator.ts Database Enhanced: - src/database/schema.sql (new version tracking tables) - src/database/node-repository.ts (15+ version query methods) Autofixer Integration: - src/services/workflow-auto-fixer.ts (async, new fix types) - src/mcp/handlers-n8n-manager.ts (await generateFixes) - src/mcp/tools-n8n-manager.ts (schema with new fix types) Documentation: - src/mcp/tool-docs/workflow_management/n8n-autofix-workflow.ts - CHANGELOG.md (comprehensive feature documentation) Testing: - Fixed all test scripts to await async generateFixes() - Added test workflow for Execute Workflow v1.0 upgrade testing Bug Fixes: - Fixed MCP tool schema enum to include new fix types - Fixed confidence type mapping (lowercase → uppercase) Conceived by Romuald Członkowski - www.aiadvisors.pl/en
411 lines
11 KiB
TypeScript
411 lines
11 KiB
TypeScript
/**
|
|
* 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
|
|
};
|
|
}
|
|
}
|