mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-12 02:19:43 +00:00
refactor(core): Add breaking change rules for remaining v3 changes (#33733)
Co-authored-by: Danny Martini <[email protected]> Co-authored-by: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Danny Martini
Claude Fable 5
parent
41581cd818
commit
1f4e77b038
+2
-2
@@ -10,8 +10,8 @@ describe('Breaking change rules auto-discovery', () => {
|
||||
const entries = metadata.getEntries();
|
||||
|
||||
expect(entries.filter((entry) => entry.version === 'v2')).toHaveLength(16);
|
||||
expect(entries.filter((entry) => entry.version === 'v3')).toHaveLength(2);
|
||||
expect(entries).toHaveLength(18);
|
||||
expect(entries.filter((entry) => entry.version === 'v3')).toHaveLength(10);
|
||||
expect(entries).toHaveLength(26);
|
||||
});
|
||||
|
||||
it('should resolve all registered rules with valid metadata from the DI container', () => {
|
||||
|
||||
@@ -21,4 +21,12 @@ import './v2/workflow-hooks-deprecated.rule';
|
||||
|
||||
// v3 rules
|
||||
import './v3/always-output-data-multi-output.rule';
|
||||
import './v3/compression-node-limits.rule';
|
||||
import './v3/docker-only-deployment.rule';
|
||||
import './v3/execute-workflow-each-mode.rule';
|
||||
import './v3/get-paired-item.rule';
|
||||
import './v3/in-memory-binary-data.rule';
|
||||
import './v3/offload-manual-executions.rule';
|
||||
import './v3/task-runner-task-timeout.rule';
|
||||
import './v3/unverified-packages.rule';
|
||||
import './v3/workflow-import-url-removed.rule';
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
import { CompressionNodeLimitsRule } from '../compression-node-limits.rule';
|
||||
|
||||
describe('CompressionNodeLimitsRule', () => {
|
||||
let rule: CompressionNodeLimitsRule;
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES;
|
||||
delete process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES;
|
||||
rule = new CompressionNodeLimitsRule();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should report both limits when neither variable is set', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(2);
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should report only the unset limit', async () => {
|
||||
process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES = '2147483648';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toContain('ZIP entries');
|
||||
});
|
||||
|
||||
it('should report only the unset size limit', async () => {
|
||||
process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES = '5000';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].title).toContain('decompressed size');
|
||||
});
|
||||
|
||||
it('should not be affected when both variables are set', async () => {
|
||||
process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES = '2147483648';
|
||||
process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES = '5000';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { InstanceSettings } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { DockerOnlyDeploymentRule } from '../docker-only-deployment.rule';
|
||||
|
||||
describe('DockerOnlyDeploymentRule', () => {
|
||||
describe('detect()', () => {
|
||||
it('should not be affected when running in a container', async () => {
|
||||
const rule = new DockerOnlyDeploymentRule(mock<InstanceSettings>({ isDocker: true }));
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should be affected when running outside a container', async () => {
|
||||
const rule = new DockerOnlyDeploymentRule(mock<InstanceSettings>({ isDocker: false }));
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('warning');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
import { createNode, createWorkflow } from '../../../__tests__/test-helpers';
|
||||
import { GetPairedItemRule } from '../get-paired-item.rule';
|
||||
|
||||
describe('GetPairedItemRule', () => {
|
||||
let rule: GetPairedItemRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new GetPairedItemRule();
|
||||
});
|
||||
|
||||
describe('detectWorkflow()', () => {
|
||||
it('should not be affected when no node uses $getPairedItem', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Set', 'n8n-nodes-base.set', { value: '={{ $json.name }}' }),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.issues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should detect $getPairedItem in an expression', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Set', 'n8n-nodes-base.set', {
|
||||
value: "={{ $getPairedItem('Node', $input.item) }}",
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
expect(result.issues[0].title).toContain('Set');
|
||||
expect(result.issues[0].level).toBe('error');
|
||||
expect(result.issues[0].nodeName).toBe('Set');
|
||||
});
|
||||
|
||||
it('should detect $getPairedItem in nested parameters', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('HTTP', 'n8n-nodes-base.httpRequest', {
|
||||
options: { headers: { value: '={{ $getPairedItem("Webhook").json.id }}' } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should flag each affected node once', async () => {
|
||||
const { workflow, nodesGroupedByType } = createWorkflow('wf-1', 'Test Workflow', [
|
||||
createNode('Set1', 'n8n-nodes-base.set', { value: '={{ $getPairedItem("A") }}' }),
|
||||
createNode('Set2', 'n8n-nodes-base.set', { value: '={{ $json.ok }}' }),
|
||||
createNode('Set3', 'n8n-nodes-base.set', { value: '={{ $getPairedItem("B") }}' }),
|
||||
]);
|
||||
|
||||
const result = await rule.detectWorkflow(workflow, nodesGroupedByType);
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.issues).toHaveLength(2);
|
||||
expect(result.issues.map((i) => i.nodeName)).toEqual(['Set1', 'Set3']);
|
||||
});
|
||||
});
|
||||
});
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import type { BinaryDataConfig } from 'n8n-core';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { InMemoryBinaryDataRule } from '../in-memory-binary-data.rule';
|
||||
|
||||
describe('InMemoryBinaryDataRule', () => {
|
||||
let rule: InMemoryBinaryDataRule;
|
||||
const binaryDataConfig: BinaryDataConfig = mock<BinaryDataConfig>();
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new InMemoryBinaryDataRule(binaryDataConfig);
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it.each(['filesystem', 's3', 'database'] as const)(
|
||||
'should not be affected in %s mode',
|
||||
async (mode) => {
|
||||
binaryDataConfig.mode = mode;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('should be affected in default (in-memory) mode', async () => {
|
||||
binaryDataConfig.mode = 'default';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('warning');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
import type { ExecutionsConfig } from '@n8n/config';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import { OffloadManualExecutionsRule } from '../offload-manual-executions.rule';
|
||||
|
||||
describe('OffloadManualExecutionsRule', () => {
|
||||
let rule: OffloadManualExecutionsRule;
|
||||
const executionsConfig: ExecutionsConfig = mock<ExecutionsConfig>();
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS;
|
||||
rule = new OffloadManualExecutionsRule(executionsConfig);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should not be affected in regular mode', async () => {
|
||||
executionsConfig.mode = 'regular';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not be affected in queue mode with offloading enabled', async () => {
|
||||
executionsConfig.mode = 'queue';
|
||||
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS = 'true';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
});
|
||||
|
||||
it('should be affected in queue mode without offloading', async () => {
|
||||
executionsConfig.mode = 'queue';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('warning');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should be affected in queue mode with offloading explicitly disabled', async () => {
|
||||
executionsConfig.mode = 'queue';
|
||||
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS = 'false';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
import { TaskRunnerTaskTimeoutRule } from '../task-runner-task-timeout.rule';
|
||||
|
||||
describe('TaskRunnerTaskTimeoutRule', () => {
|
||||
let rule: TaskRunnerTaskTimeoutRule;
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.N8N_RUNNERS_TASK_TIMEOUT;
|
||||
rule = new TaskRunnerTaskTimeoutRule();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should be affected when the variable is not set', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('info');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not be affected when the variable is set', async () => {
|
||||
process.env.N8N_RUNNERS_TASK_TIMEOUT = '300';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
expect(result.instanceIssues).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
import type { CommunityPackagesConfig } from '../../../../community-packages/community-packages.config';
|
||||
import { UnverifiedPackagesRule } from '../unverified-packages.rule';
|
||||
|
||||
describe('UnverifiedPackagesRule', () => {
|
||||
let rule: UnverifiedPackagesRule;
|
||||
const communityPackagesConfig: CommunityPackagesConfig = mock<CommunityPackagesConfig>();
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
delete process.env.N8N_UNVERIFIED_PACKAGES_ENABLED;
|
||||
communityPackagesConfig.enabled = true;
|
||||
rule = new UnverifiedPackagesRule(communityPackagesConfig);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should be affected when the variable is not set', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('warning');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not be affected when the variable is set to true', async () => {
|
||||
process.env.N8N_UNVERIFIED_PACKAGES_ENABLED = 'true';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be affected when the variable is set to false', async () => {
|
||||
process.env.N8N_UNVERIFIED_PACKAGES_ENABLED = 'false';
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
});
|
||||
|
||||
it('should not be affected when community packages are disabled', async () => {
|
||||
communityPackagesConfig.enabled = false;
|
||||
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
import { WorkflowImportUrlRemovedRule } from '../workflow-import-url-removed.rule';
|
||||
|
||||
describe('WorkflowImportUrlRemovedRule', () => {
|
||||
let rule: WorkflowImportUrlRemovedRule;
|
||||
|
||||
beforeEach(() => {
|
||||
rule = new WorkflowImportUrlRemovedRule();
|
||||
});
|
||||
|
||||
describe('detect()', () => {
|
||||
it('should always report informationally', async () => {
|
||||
const result = await rule.detect();
|
||||
|
||||
expect(result.isAffected).toBe(true);
|
||||
expect(result.instanceIssues).toHaveLength(1);
|
||||
expect(result.instanceIssues[0].level).toBe('info');
|
||||
expect(result.recommendations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class CompressionNodeLimitsRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'compression-node-limits-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'Compression node limits are lowered',
|
||||
description:
|
||||
'The default maximum decompressed size is reduced from 2 GiB to 256 MiB and the default maximum number of ZIP entries from 5000 to 1000. Compression nodes handling archives beyond the new limits will fail.',
|
||||
category: BreakingChangeCategory.environment,
|
||||
severity: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const instanceIssues: InstanceDetectionReport['instanceIssues'] = [];
|
||||
|
||||
if (process.env.N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES === undefined) {
|
||||
instanceIssues.push({
|
||||
title: 'Instance relies on the current default decompressed size limit',
|
||||
description:
|
||||
'N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES is not set, so this instance uses the default of 2 GiB. After the update, decompressing more than 256 MiB will fail.',
|
||||
level: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES === undefined) {
|
||||
instanceIssues.push({
|
||||
title: 'Instance relies on the current default ZIP entries limit',
|
||||
description:
|
||||
'N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES is not set, so this instance uses the default of 5000 entries. After the update, ZIP archives with more than 1000 entries will fail to decompress.',
|
||||
level: 'info',
|
||||
});
|
||||
}
|
||||
|
||||
if (instanceIssues.length === 0) {
|
||||
return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues,
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Set the compression limits explicitly',
|
||||
description:
|
||||
'If your workflows decompress large archives, set N8N_COMPRESSION_NODE_MAX_DECOMPRESSED_SIZE_BYTES and N8N_COMPRESSION_NODE_MAX_ZIP_ENTRIES to keep the current limits.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class DockerOnlyDeploymentRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly instanceSettings: InstanceSettings) {}
|
||||
|
||||
id: string = 'docker-only-deployment-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'Docker becomes the only supported deployment method',
|
||||
description:
|
||||
'Support for running n8n via npm is removed. The new version must be run via the official Docker image.',
|
||||
category: BreakingChangeCategory.infrastructure,
|
||||
severity: 'medium',
|
||||
documentationUrl: 'https://docs.n8n.io/deploy/host-n8n/install-options/install-with-docker',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
if (this.instanceSettings.isDocker) {
|
||||
return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Instance is not running in a container',
|
||||
description:
|
||||
'This instance appears to run outside a container (e.g. installed via npm). The new version can only be run via the official Docker image.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Migrate to a Docker-based deployment',
|
||||
description:
|
||||
'Move this instance to the official Docker image before updating, reusing your existing database and encryption key.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { BreakingChangeAffectedWorkflow, BreakingChangeRecommendation } from '@n8n/api-types';
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeWorkflowRule,
|
||||
WorkflowDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
const HELPER_NAME = '$getPairedItem';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class GetPairedItemRule implements IBreakingChangeWorkflowRule {
|
||||
id: string = 'get-paired-item-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: '$getPairedItem expression helper is removed',
|
||||
description:
|
||||
'The $getPairedItem expression helper is removed. Expressions using it will fail to evaluate.',
|
||||
category: BreakingChangeCategory.workflow,
|
||||
severity: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async getRecommendations(
|
||||
_workflowResults: BreakingChangeAffectedWorkflow[],
|
||||
): Promise<BreakingChangeRecommendation[]> {
|
||||
return [
|
||||
{
|
||||
action: 'Replace $getPairedItem usages',
|
||||
description:
|
||||
'Rewrite the affected expressions using $input.all() or $("Node").all(), $("Node").item, or $("Node").itemMatching() instead of $getPairedItem.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/require-await
|
||||
async detectWorkflow(
|
||||
workflow: WorkflowEntity,
|
||||
_nodesGroupedByType: Map<string, INode[]>,
|
||||
): Promise<WorkflowDetectionReport> {
|
||||
const affectedNodes = workflow.nodes.filter((node) =>
|
||||
JSON.stringify(node.parameters).includes(HELPER_NAME),
|
||||
);
|
||||
|
||||
if (affectedNodes.length === 0) return { isAffected: false, issues: [] };
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
issues: affectedNodes.map((node) => ({
|
||||
title: `Node '${node.name}' uses the removed ${HELPER_NAME} helper`,
|
||||
description: `Expressions in this node call ${HELPER_NAME}, which is removed. They will fail to evaluate after the update.`,
|
||||
level: 'error',
|
||||
nodeId: node.id,
|
||||
nodeName: node.name,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
import { BinaryDataConfig } from 'n8n-core';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class InMemoryBinaryDataRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly binaryDataConfig: BinaryDataConfig) {}
|
||||
|
||||
id: string = 'in-memory-binary-data-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'In-memory binary data storage is removed',
|
||||
description:
|
||||
'The in-memory binary data storage mode (`default`) is removed. Instances using it must switch to `filesystem`, `s3`, or `database`.',
|
||||
category: BreakingChangeCategory.infrastructure,
|
||||
severity: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
if (this.binaryDataConfig.mode !== 'default') {
|
||||
return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Binary data is stored in memory',
|
||||
description:
|
||||
'This instance stores binary data in memory (`default` mode), which is removed in the new version.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Switch the binary data mode',
|
||||
description:
|
||||
'Set N8N_DEFAULT_BINARY_DATA_MODE to `filesystem`, `s3`, or `database` before updating, and ensure the chosen storage has adequate capacity.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ExecutionsConfig } from '@n8n/config';
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class OffloadManualExecutionsRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly executionsConfig: ExecutionsConfig) {}
|
||||
|
||||
id: string = 'offload-manual-executions-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'Manual executions always run on workers in queue mode',
|
||||
description:
|
||||
'In queue mode, manual executions are always routed to workers. The OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS environment variable is removed and running manual executions on the main instance is no longer possible.',
|
||||
category: BreakingChangeCategory.instance,
|
||||
severity: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const isAffected =
|
||||
this.executionsConfig.mode === 'queue' &&
|
||||
process.env.OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS !== 'true';
|
||||
|
||||
if (!isAffected) return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Manual executions currently run on the main instance',
|
||||
description:
|
||||
'This instance runs in queue mode without offloading manual executions to workers. After the update, all manual executions run on workers, which changes where their load lands.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Enable offloading before updating',
|
||||
description:
|
||||
'Set OFFLOAD_MANUAL_EXECUTIONS_TO_WORKERS=true to adopt the new behavior early. Consider increasing memory available to workers and reducing memory available to main.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class TaskRunnerTaskTimeoutRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'task-runner-task-timeout-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'Task runner timeout default is reduced to 1 minute',
|
||||
description:
|
||||
'The default of N8N_RUNNERS_TASK_TIMEOUT is reduced from 300 seconds (5 minutes) to 60 seconds (1 minute). Code node tasks running longer than the new default will be aborted.',
|
||||
category: BreakingChangeCategory.environment,
|
||||
severity: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
if (process.env.N8N_RUNNERS_TASK_TIMEOUT !== undefined) {
|
||||
return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Instance relies on the current default task timeout',
|
||||
description:
|
||||
'N8N_RUNNERS_TASK_TIMEOUT is not set, so this instance uses the default of 300 seconds. After the update, tasks running longer than 60 seconds will be aborted.',
|
||||
level: 'info',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Set N8N_RUNNERS_TASK_TIMEOUT explicitly',
|
||||
description:
|
||||
'If you have Code nodes that run longer than 1 minute, set N8N_RUNNERS_TASK_TIMEOUT=300 to keep the current timeout.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
|
||||
import { CommunityPackagesConfig } from '../../../community-packages/community-packages.config';
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class UnverifiedPackagesRule implements IBreakingChangeInstanceRule {
|
||||
constructor(private readonly communityPackagesConfig: CommunityPackagesConfig) {}
|
||||
|
||||
id: string = 'unverified-packages-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'Unverified community packages are disabled by default',
|
||||
description:
|
||||
'The default of N8N_UNVERIFIED_PACKAGES_ENABLED changes to false. Installed community packages that are not verified by n8n will stop loading unless the variable is explicitly set to true.',
|
||||
category: BreakingChangeCategory.environment,
|
||||
severity: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
const isAffected =
|
||||
this.communityPackagesConfig.enabled &&
|
||||
process.env.N8N_UNVERIFIED_PACKAGES_ENABLED === undefined;
|
||||
|
||||
if (!isAffected) return { isAffected: false, instanceIssues: [], recommendations: [] };
|
||||
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Instance relies on the current default for unverified packages',
|
||||
description:
|
||||
'N8N_UNVERIFIED_PACKAGES_ENABLED is not set, so this instance currently allows unverified community packages. After the update, workflows using nodes from unverified packages will fail to load them.',
|
||||
level: 'warning',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Set N8N_UNVERIFIED_PACKAGES_ENABLED explicitly',
|
||||
description:
|
||||
'Review your installed community packages. If any are unverified and you want to keep using them, set N8N_UNVERIFIED_PACKAGES_ENABLED=true before updating.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import { BreakingChangeRule } from '@n8n/decorators';
|
||||
|
||||
import type {
|
||||
BreakingChangeRuleMetadata,
|
||||
IBreakingChangeInstanceRule,
|
||||
InstanceDetectionReport,
|
||||
} from '../../types';
|
||||
import { BreakingChangeCategory } from '../../types';
|
||||
|
||||
@BreakingChangeRule({ version: 'v3' })
|
||||
export class WorkflowImportUrlRemovedRule implements IBreakingChangeInstanceRule {
|
||||
id: string = 'workflow-import-url-removed-v3';
|
||||
|
||||
getMetadata(): BreakingChangeRuleMetadata {
|
||||
return {
|
||||
version: 'v3',
|
||||
title: 'Workflow import from URL is removed',
|
||||
description: 'The "Import from URL" option in the editor is removed.',
|
||||
category: BreakingChangeCategory.instance,
|
||||
severity: 'low',
|
||||
};
|
||||
}
|
||||
|
||||
// Usage of an editor feature is not detectable server-side, so this rule
|
||||
// always reports informationally, like tunnel-option-v2.
|
||||
async detect(): Promise<InstanceDetectionReport> {
|
||||
return {
|
||||
isAffected: true,
|
||||
instanceIssues: [
|
||||
{
|
||||
title: 'Import from URL removed from the editor',
|
||||
description:
|
||||
'The editor no longer offers importing a workflow from a URL. If you share workflows as URLs, this will stop working after the update.',
|
||||
level: 'info',
|
||||
},
|
||||
],
|
||||
recommendations: [
|
||||
{
|
||||
action: 'Share workflows as files instead',
|
||||
description:
|
||||
'Download workflows as JSON files and use the editor\'s "Import from File" option, or exchange workflows via source control environments.',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user