mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Support workflow version policies in package exports (#35962)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
committed by
GitHub
parent
440a14a859
commit
aecd1649fe
@@ -175,4 +175,36 @@ describe('ExportPackageRequestDto', () => {
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflowVersionPolicy', () => {
|
||||
it.each(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])(
|
||||
'accepts %s',
|
||||
(workflowVersionPolicy) => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
workflowVersionPolicy,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it('defaults to latest', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.workflowVersionPolicy).toBe('latest');
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects unknown values', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
workflowVersionPolicy: 'published',
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,4 +12,8 @@ export class ExportPackageRequestDto extends Z.class({
|
||||
.enum(['fail', 'reference-only', 'include-in-package'])
|
||||
.optional()
|
||||
.default('fail'),
|
||||
workflowVersionPolicy: z
|
||||
.enum(['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'])
|
||||
.optional()
|
||||
.default('latest'),
|
||||
}) {}
|
||||
|
||||
@@ -29,11 +29,19 @@ n8n-cli package export -w abc --include-tags=false -o export.n8np
|
||||
| `--include-variable-values` | `true` (default) or `false`. Whether values of variables referenced by the exported workflows are bundled into the package. When `false`, variables still travel as name/type files (and in the package requirements), just without their values. |
|
||||
| `--include-tags` | `true` (default) or `false`. Whether tags assigned to the exported workflows are bundled into the package. When `false`, no tag data is included in the package. |
|
||||
| `--missing-workflow-dependency-policy` | Policy for missing static sub-workflow dependencies: `fail` aborts when any dependency is missing, `include-in-package` automatically adds missing static sub-workflows, and `reference-only` keeps them out of the package, listing them in the package requirements as workflows expected to already exist on the target. |
|
||||
| `--workflow-version-policy` | Which version of each workflow travels in the package: `latest` (default) exports the latest version whether or not it is published, `published-strict` exports the published version and aborts when any workflow has none, `prefer-published` falls back to the latest version where there is no published one, and `ignore-unpublished` leaves unpublished workflows out of the package entirely. |
|
||||
|
||||
Provide at least one `--workflow-id`, `--folder-id`, or `--project-id`. Requires
|
||||
the API key to hold `workflow:export` when exporting workflows or folders, or
|
||||
`project:export` when exporting projects.
|
||||
|
||||
A workflow has a latest version (what you see in the editor) and, once
|
||||
published, a published version; `--workflow-version-policy` picks which one
|
||||
travels. The chosen version decides which credentials, data tables, variables
|
||||
and sub-workflows are bundled alongside it, but the workflow's name, settings
|
||||
(including `errorWorkflow`) and tags are not versioned and always come from the
|
||||
latest version.
|
||||
|
||||
Statically referenced sub-workflows are dependencies of the package. How
|
||||
missing ones are handled depends on
|
||||
`--missing-workflow-dependency-policy`. With the default `fail` policy you include them yourself. With `include-in-package`, n8n resolves the static dependency graph and adds any
|
||||
|
||||
@@ -153,6 +153,20 @@ describe('N8nClient packages', () => {
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], includeTags: false }));
|
||||
});
|
||||
|
||||
it('includes the workflow version policy when provided', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
|
||||
|
||||
await client.exportPackage({
|
||||
workflowIds: ['a'],
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
});
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(
|
||||
JSON.stringify({ workflowIds: ['a'], workflowVersionPolicy: 'published-strict' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPackage', () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ExportFlags {
|
||||
includeVariableValues?: string;
|
||||
includeTags?: string;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
workflowVersionPolicy?: string;
|
||||
}
|
||||
|
||||
/** The command methods we stub to isolate behaviour from oclif/networking. */
|
||||
@@ -133,6 +134,44 @@ describe('package export command', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a non-default workflow version policy for workflows and folders', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
workflowId: ['wf-1'],
|
||||
folderId: ['fld-1'],
|
||||
output: '/tmp/mixed.n8np',
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a non-default workflow version policy for projects', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
projectId: ['proj-1'],
|
||||
output: '/tmp/projects.n8np',
|
||||
workflowVersionPolicy: 'ignore-unpublished',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'ignore-unpublished',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards project ids and writes the archive', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
projectId: ['proj-1', 'proj-2'],
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface ExportPackageFields {
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
workflowVersionPolicy?: string;
|
||||
}
|
||||
|
||||
/** True per-entity counts of what ended up in an exported package. */
|
||||
@@ -472,6 +473,7 @@ export class N8nClient {
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
workflowVersionPolicy?: string;
|
||||
} = {};
|
||||
if (fields.workflowIds?.length) body.workflowIds = fields.workflowIds;
|
||||
if (fields.folderIds?.length) body.folderIds = fields.folderIds;
|
||||
@@ -481,6 +483,7 @@ export class N8nClient {
|
||||
body.includeTags = fields.includeTags;
|
||||
if (fields.missingWorkflowDependencyPolicy)
|
||||
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
|
||||
if (fields.workflowVersionPolicy) body.workflowVersionPolicy = fields.workflowVersionPolicy;
|
||||
|
||||
let counts: ExportPackageCounts | undefined;
|
||||
const archive = await this.request<Buffer>('POST', '/n8n-packages/export', {
|
||||
|
||||
@@ -81,6 +81,12 @@ export default class PackageExport extends BaseCommand {
|
||||
'What to do when a dependency workflow (sub-workflow) is not explicitly included in the package target',
|
||||
aliases: ['missing-workflow-dependency-policy'],
|
||||
}),
|
||||
workflowVersionPolicy: Flags.string({
|
||||
options: ['published-strict', 'prefer-published', 'ignore-unpublished', 'latest'],
|
||||
default: 'latest',
|
||||
description: 'Which version of each workflow travels in the package',
|
||||
aliases: ['workflow-version-policy'],
|
||||
}),
|
||||
};
|
||||
|
||||
async run(): Promise<void> {
|
||||
@@ -91,6 +97,7 @@ export default class PackageExport extends BaseCommand {
|
||||
const includeVariableValues = flags.includeVariableValues !== 'false';
|
||||
const includeTags = flags.includeTags !== 'false';
|
||||
const missingWorkflowDependencyPolicy = flags.missingWorkflowDependencyPolicy;
|
||||
const workflowVersionPolicy = flags.workflowVersionPolicy;
|
||||
|
||||
// A package is either loose workflows/folders or whole projects, not both.
|
||||
if (projectIds.length > 0 && (workflowIds.length > 0 || folderIds.length > 0)) {
|
||||
@@ -106,13 +113,20 @@ export default class PackageExport extends BaseCommand {
|
||||
try {
|
||||
result = await client.exportPackage(
|
||||
projectIds.length > 0
|
||||
? { projectIds, includeVariableValues, includeTags, missingWorkflowDependencyPolicy }
|
||||
? {
|
||||
projectIds,
|
||||
includeVariableValues,
|
||||
includeTags,
|
||||
missingWorkflowDependencyPolicy,
|
||||
workflowVersionPolicy,
|
||||
}
|
||||
: {
|
||||
workflowIds,
|
||||
folderIds,
|
||||
includeVariableValues,
|
||||
includeTags,
|
||||
missingWorkflowDependencyPolicy,
|
||||
workflowVersionPolicy,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@n8n/backend-test-utils';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { jsonParse, type INode } from 'n8n-workflow';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||
@@ -21,9 +22,11 @@ import { N8nPackagesService } from '../n8n-packages.service';
|
||||
import { FORMAT_VERSION } from '../spec/constants';
|
||||
import { readExport } from './utils/tar-support';
|
||||
import {
|
||||
buildVersionedWorkflow,
|
||||
buildWorkflowReferencingCredential,
|
||||
buildWorkflowCallingSubWorkflow,
|
||||
buildWorkflowReferencingVariables,
|
||||
noOpNode,
|
||||
} from './utils/test-builders';
|
||||
|
||||
let service: N8nPackagesService;
|
||||
@@ -41,7 +44,9 @@ afterAll(async () => {
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'Folder',
|
||||
// WorkflowEntity first: its activeVersionId points at WorkflowHistory.
|
||||
'WorkflowEntity',
|
||||
'WorkflowHistory',
|
||||
'SharedWorkflow',
|
||||
'CredentialsEntity',
|
||||
'SharedCredentials',
|
||||
@@ -588,6 +593,35 @@ describe('project package export — with folders / workflows', () => {
|
||||
expect(projectAEntry.target).not.toBe(projectBEntry.target);
|
||||
});
|
||||
|
||||
it('exports a project folder workflow at its published version, skipping unpublished ones', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('team-ligo', owner);
|
||||
const folder = await createFolder(project, { name: 'in_progress' });
|
||||
const { workflow: published } = await buildVersionedWorkflow({
|
||||
name: 'triage',
|
||||
project,
|
||||
parentFolder: folder,
|
||||
versions: [[noOpNode('published-v1')], [noOpNode('published-v2')]],
|
||||
publishedVersion: 0,
|
||||
});
|
||||
await buildVersionedWorkflow({ name: 'sync', project, versions: [[noOpNode('draft-v1')]] });
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
projectIds: [project.id],
|
||||
workflowVersionPolicy: 'ignore-unpublished',
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.workflows!.map(({ id }) => id)).toEqual([published.id]);
|
||||
const exported = jsonParse<{ nodes: INode[] }>(
|
||||
entries
|
||||
.find((e) => e.name === `${manifest.workflows![0].target}/workflow.json`)!
|
||||
.content.toString(),
|
||||
);
|
||||
expect(exported.nodes.map(({ name }) => name)).toEqual(['published-v1']);
|
||||
});
|
||||
|
||||
// Telemetry
|
||||
it('counts project folders, workflows and credentials in the export telemetry', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
+226
-1
@@ -9,6 +9,7 @@ import {
|
||||
import type { User } from '@n8n/db';
|
||||
import { ProjectRepository, WorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { jsonParse, type INode } from 'n8n-workflow';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||
@@ -19,11 +20,15 @@ import { PackageExportBlockedError } from '../entities/package-export.errors';
|
||||
import { N8nPackagesService } from '../n8n-packages.service';
|
||||
import { FORMAT_VERSION } from '../spec/constants';
|
||||
import { readExport } from './utils/tar-support';
|
||||
import type { UnpackedEntry } from './utils/tar-support';
|
||||
import {
|
||||
buildVersionedWorkflow,
|
||||
buildWorkflowCallingSubWorkflow,
|
||||
buildWorkflowReferencingCredential,
|
||||
buildWorkflowUsingErrorWorkflow,
|
||||
credentialNode,
|
||||
executeWorkflowNode,
|
||||
noOpNode,
|
||||
} from './utils/test-builders';
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -36,9 +41,25 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate(['WorkflowEntity', 'SharedWorkflow', 'ProjectRelation', 'Project']);
|
||||
// WorkflowEntity first: its activeVersionId points at WorkflowHistory.
|
||||
await testDb.truncate([
|
||||
'WorkflowEntity',
|
||||
'WorkflowHistory',
|
||||
'SharedWorkflow',
|
||||
'ProjectRelation',
|
||||
'Project',
|
||||
]);
|
||||
});
|
||||
|
||||
function workflowJson(entries: UnpackedEntry[], target: string) {
|
||||
const file = entries.find((entry) => entry.name === `${target}/workflow.json`);
|
||||
if (!file) throw new Error(`missing ${target}/workflow.json`);
|
||||
return jsonParse<Record<string, unknown>>(file.content.toString());
|
||||
}
|
||||
|
||||
const nodeNames = (workflow: Record<string, unknown>) =>
|
||||
(workflow.nodes as INode[]).map((node) => node.name);
|
||||
|
||||
describe('workflow package export', () => {
|
||||
let service: N8nPackagesService;
|
||||
|
||||
@@ -816,6 +837,210 @@ describe('workflow package export', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('workflow version policy', () => {
|
||||
it.each(['latest', 'published-strict'] as const)(
|
||||
'exports a single-version workflow under %s',
|
||||
async (workflowVersionPolicy) => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const { workflow } = await buildVersionedWorkflow({
|
||||
name: 'Single version',
|
||||
project,
|
||||
versions: [[noOpNode('v1')]],
|
||||
publishedVersion: 0,
|
||||
});
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
workflowVersionPolicy,
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.workflows).toHaveLength(1);
|
||||
expect(nodeNames(workflowJson(entries, manifest.workflows![0].target))).toEqual(['v1']);
|
||||
},
|
||||
);
|
||||
|
||||
it('exports the latest version by default even when an older version is published', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const { workflow, versionIds } = await buildVersionedWorkflow({
|
||||
name: 'Published but edited',
|
||||
project,
|
||||
versions: [[noOpNode('v1')], [noOpNode('v2')], [noOpNode('v3')]],
|
||||
publishedVersion: 1,
|
||||
});
|
||||
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
const exported = workflowJson(entries, manifest.workflows![0].target);
|
||||
expect(nodeNames(exported)).toEqual(['v3']);
|
||||
expect(exported.versionId).toBe(versionIds[2]);
|
||||
expect(exported.isPublished).toBe(false);
|
||||
});
|
||||
|
||||
it('exports the published version rather than the draft', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const { workflow, versionIds } = await buildVersionedWorkflow({
|
||||
name: 'Published but edited',
|
||||
project,
|
||||
versions: [[noOpNode('v1')], [noOpNode('v2')], [noOpNode('v3')]],
|
||||
publishedVersion: 0,
|
||||
settings: { executionOrder: 'v1', timezone: 'Europe/Berlin' },
|
||||
});
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
const exported = workflowJson(entries, manifest.workflows![0].target);
|
||||
expect(nodeNames(exported)).toEqual(['v1']);
|
||||
expect(exported.versionId).toBe(versionIds[0]);
|
||||
expect(exported.isPublished).toBe(true);
|
||||
// Workflow history carries no settings, so they always come from the draft.
|
||||
expect(exported.settings).toEqual({ executionOrder: 'v1', timezone: 'Europe/Berlin' });
|
||||
});
|
||||
|
||||
it('aborts under published-strict when a workflow has no published version', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const { workflow } = await buildVersionedWorkflow({
|
||||
name: 'Never published',
|
||||
project,
|
||||
versions: [[noOpNode('v1')], [noOpNode('v2')]],
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
}),
|
||||
).rejects.toThrow('1 workflow(s) have no published version. Export aborted.');
|
||||
});
|
||||
|
||||
it('falls back to the draft for unpublished workflows under prefer-published', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const { workflow: published } = await buildVersionedWorkflow({
|
||||
name: 'Published',
|
||||
project,
|
||||
versions: [[noOpNode('published-v1')], [noOpNode('published-v2')]],
|
||||
publishedVersion: 0,
|
||||
});
|
||||
const { workflow: unpublished } = await buildVersionedWorkflow({
|
||||
name: 'Never published',
|
||||
project,
|
||||
versions: [[noOpNode('draft-v1')], [noOpNode('draft-v2')]],
|
||||
});
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [published.id, unpublished.id],
|
||||
workflowVersionPolicy: 'prefer-published',
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.workflows).toHaveLength(2);
|
||||
expect(
|
||||
manifest.workflows!.map(({ target }) => nodeNames(workflowJson(entries, target))),
|
||||
).toEqual([['published-v1'], ['draft-v2']]);
|
||||
});
|
||||
|
||||
it('leaves unpublished workflows out of the package under ignore-unpublished', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const { workflow: published } = await buildVersionedWorkflow({
|
||||
name: 'Published',
|
||||
project,
|
||||
versions: [[noOpNode('published-v1')], [noOpNode('published-v2')]],
|
||||
publishedVersion: 0,
|
||||
});
|
||||
const { workflow: unpublished } = await buildVersionedWorkflow({
|
||||
name: 'Never published',
|
||||
project,
|
||||
versions: [[noOpNode('draft-v1')]],
|
||||
});
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [published.id, unpublished.id],
|
||||
workflowVersionPolicy: 'ignore-unpublished',
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.workflows!.map(({ id }) => id)).toEqual([published.id]);
|
||||
expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('names the unpublished sub-workflow when auto-include meets ignore-unpublished', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const { workflow: child } = await buildVersionedWorkflow({
|
||||
name: 'Unpublished helper',
|
||||
project,
|
||||
versions: [[noOpNode('child-v1')]],
|
||||
});
|
||||
const { workflow: parent } = await buildVersionedWorkflow({
|
||||
name: 'Published caller',
|
||||
project,
|
||||
versions: [[executeWorkflowNode(child.id)]],
|
||||
publishedVersion: 0,
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parent.id],
|
||||
workflowVersionPolicy: 'ignore-unpublished',
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
}),
|
||||
).rejects.toThrow('1 sub-workflow dependency has no published version. Export aborted.');
|
||||
});
|
||||
|
||||
it('bundles the credentials the exported version references, not the draft ones', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const publishedCredential = await saveCredential(
|
||||
{ name: 'Published cred', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
|
||||
{ project, role: 'credential:owner' },
|
||||
);
|
||||
const draftCredential = await saveCredential(
|
||||
{ name: 'Draft cred', type: 'httpHeaderAuth', data: { name: 'X', value: 'y' } },
|
||||
{ project, role: 'credential:owner' },
|
||||
);
|
||||
const { workflow } = await buildVersionedWorkflow({
|
||||
name: 'Swapped credential',
|
||||
project,
|
||||
versions: [[credentialNode(publishedCredential)], [credentialNode(draftCredential)]],
|
||||
publishedVersion: 0,
|
||||
});
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [workflow.id],
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
});
|
||||
const { manifest } = await readExport(stream);
|
||||
|
||||
expect(manifest.credentials!.map(({ id }) => id)).toEqual([publishedCredential.id]);
|
||||
expect(manifest.requirements?.credentials).toEqual([
|
||||
{
|
||||
id: publishedCredential.id,
|
||||
name: publishedCredential.name,
|
||||
type: 'httpHeaderAuth',
|
||||
usedByWorkflows: [workflow.id],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorization', () => {
|
||||
it('Lists count of workflows inaccessible', async () => {
|
||||
const owner = await createOwner();
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { createWorkflow } from '@n8n/backend-test-utils';
|
||||
import { createWorkflow, createWorkflowHistory, setActiveVersion } from '@n8n/backend-test-utils';
|
||||
import type { CredentialsEntity, Folder, Project, WorkflowEntity } from '@n8n/db';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { WorkflowRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import type { INode, IWorkflowSettings } from 'n8n-workflow';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
interface BuildWorkflowReferencingCredentialByIdOptions {
|
||||
name: string;
|
||||
@@ -208,6 +211,80 @@ export async function buildWorkflowReferencingDataTables({
|
||||
);
|
||||
}
|
||||
|
||||
export function noOpNode(name: string): INode {
|
||||
return {
|
||||
id: name,
|
||||
name,
|
||||
type: 'n8n-nodes-base.noOp',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
};
|
||||
}
|
||||
|
||||
export function credentialNode(credential: Pick<CredentialsEntity, 'id' | 'name' | 'type'>): INode {
|
||||
return {
|
||||
id: `http-${credential.id}`,
|
||||
name: `HTTP ${credential.name}`,
|
||||
type: 'n8n-nodes-base.httpRequest',
|
||||
typeVersion: 1,
|
||||
position: [0, 0],
|
||||
parameters: {},
|
||||
credentials: { [credential.type]: { id: credential.id, name: credential.name } },
|
||||
};
|
||||
}
|
||||
|
||||
interface BuildVersionedWorkflowOptions {
|
||||
name: string;
|
||||
project: Project;
|
||||
versions: INode[][];
|
||||
publishedVersion?: number;
|
||||
settings?: IWorkflowSettings;
|
||||
parentFolder?: Folder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshots each version into workflow history and leaves the workflow row holding
|
||||
* the last one, so the row is the draft and any earlier version can be published.
|
||||
*/
|
||||
export async function buildVersionedWorkflow(
|
||||
options: BuildVersionedWorkflowOptions,
|
||||
): Promise<{ workflow: WorkflowEntity; versionIds: string[] }> {
|
||||
const [firstVersion, ...laterVersions] = options.versions;
|
||||
const workflow = await createWorkflow(
|
||||
{
|
||||
name: options.name,
|
||||
nodes: firstVersion,
|
||||
connections: {},
|
||||
parentFolder: options.parentFolder,
|
||||
// An explicit `undefined` would override the default `{}` and persist as null.
|
||||
...(options.settings ? { settings: options.settings } : {}),
|
||||
},
|
||||
options.project,
|
||||
);
|
||||
await createWorkflowHistory(workflow);
|
||||
const versionIds = [workflow.versionId];
|
||||
|
||||
for (const nodes of laterVersions) {
|
||||
workflow.versionId = uuid();
|
||||
workflow.nodes = nodes;
|
||||
await Container.get(WorkflowRepository).update(workflow.id, {
|
||||
versionId: workflow.versionId,
|
||||
nodes,
|
||||
});
|
||||
await createWorkflowHistory(workflow);
|
||||
versionIds.push(workflow.versionId);
|
||||
}
|
||||
|
||||
if (options.publishedVersion !== undefined) {
|
||||
const activeVersionId = versionIds[options.publishedVersion];
|
||||
await setActiveVersion(workflow.id, activeVersionId);
|
||||
workflow.activeVersionId = activeVersionId;
|
||||
}
|
||||
|
||||
return { workflow, versionIds };
|
||||
}
|
||||
|
||||
export function executeWorkflowNode(workflowId: string): INode {
|
||||
return {
|
||||
id: `execute-${workflowId}`,
|
||||
|
||||
+3
@@ -50,6 +50,7 @@ describe('FolderExporter', () => {
|
||||
folderIds: ['fld-1'],
|
||||
writer: new CapturingWriter(),
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
basePrefix: 'projects/team-ligo',
|
||||
});
|
||||
|
||||
@@ -82,6 +83,7 @@ describe('FolderExporter', () => {
|
||||
folderIds: ['fld-1'],
|
||||
writer: new CapturingWriter(),
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
// The folder's own target is passed as basePrefix, so workflows nest under it.
|
||||
@@ -114,6 +116,7 @@ describe('FolderExporter', () => {
|
||||
folderIds: ['fld-1'],
|
||||
writer: new CapturingWriter(),
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toThrow(/not found or not accessible/);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,7 @@ import { FolderSerializer } from './folder.serializer';
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
|
||||
import type { ManifestEntry } from '../../spec/manifest.schema';
|
||||
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
|
||||
import { assertEveryRequestedEntityAccessible } from '../package-export.errors';
|
||||
import { mergeRequirements } from '../requirements.types';
|
||||
import type { WorkflowExportRequirements } from '../requirements.types';
|
||||
@@ -19,6 +20,7 @@ export interface FolderExportRequest {
|
||||
folderIds: string[];
|
||||
writer: PackageWriter;
|
||||
includeTags: boolean;
|
||||
workflowVersionPolicy: WorkflowVersionPolicy;
|
||||
/**
|
||||
* Directory the folder tree is written under. Empty for a top-level folder
|
||||
* export (`folders/...`); a project exporter passes `projects/<slug>` so the
|
||||
@@ -183,6 +185,7 @@ export class FolderExporter {
|
||||
writer: request.writer,
|
||||
workflowIds,
|
||||
includeTags: request.includeTags,
|
||||
workflowVersionPolicy: request.workflowVersionPolicy,
|
||||
basePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
+31
-4
@@ -97,7 +97,13 @@ describe('ProjectExporter', () => {
|
||||
const { exporter, projectService } = makeExporter({ projects: [project] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await exporter.export({ user, projectIds: [project.id], writer, includeTags: true });
|
||||
await exporter.export({
|
||||
user,
|
||||
projectIds: [project.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(projectService.findProjectsByIdsForUser).toHaveBeenCalledWith(
|
||||
user,
|
||||
@@ -112,7 +118,13 @@ describe('ProjectExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, projectIds: [project.id], writer, includeTags: true }),
|
||||
exporter.export({
|
||||
user,
|
||||
projectIds: [project.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toThrow('1 project(s) not found or not accessible. Export aborted.');
|
||||
});
|
||||
|
||||
@@ -122,7 +134,13 @@ describe('ProjectExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, projectIds: ['missing'], writer, includeTags: true }),
|
||||
exporter.export({
|
||||
user,
|
||||
projectIds: ['missing'],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
|
||||
});
|
||||
|
||||
@@ -132,7 +150,13 @@ describe('ProjectExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, projectIds: ['denied-1'], writer, includeTags: true }),
|
||||
exporter.export({
|
||||
user,
|
||||
projectIds: ['denied-1'],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
|
||||
});
|
||||
|
||||
@@ -146,6 +170,7 @@ describe('ProjectExporter', () => {
|
||||
projectIds: [project.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -185,6 +210,7 @@ describe('ProjectExporter', () => {
|
||||
projectIds: [newerProject.id, olderProject.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -211,6 +237,7 @@ describe('ProjectExporter', () => {
|
||||
projectIds: [project.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
|
||||
@@ -9,6 +9,7 @@ import { ProjectSerializer } from './project.serializer';
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
|
||||
import type { ManifestEntry } from '../../spec/manifest.schema';
|
||||
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
|
||||
import { FolderExporter } from '../folder/folder.exporter';
|
||||
import type { FolderExportResult } from '../folder/folder.exporter';
|
||||
import { assertEveryRequestedEntityAccessible } from '../package-export.errors';
|
||||
@@ -22,6 +23,7 @@ export interface ProjectExportRequest {
|
||||
projectIds: string[];
|
||||
writer: PackageWriter;
|
||||
includeTags: boolean;
|
||||
workflowVersionPolicy: WorkflowVersionPolicy;
|
||||
}
|
||||
|
||||
interface ProjectExportResult {
|
||||
@@ -111,6 +113,7 @@ export class ProjectExporter {
|
||||
folderIds,
|
||||
writer: request.writer,
|
||||
includeTags: request.includeTags,
|
||||
workflowVersionPolicy: request.workflowVersionPolicy,
|
||||
basePrefix: target,
|
||||
});
|
||||
}
|
||||
@@ -130,6 +133,7 @@ export class ProjectExporter {
|
||||
workflowIds: rootWorkflowIds,
|
||||
writer: request.writer,
|
||||
includeTags: request.includeTags,
|
||||
workflowVersionPolicy: request.workflowVersionPolicy,
|
||||
basePrefix: target,
|
||||
});
|
||||
}
|
||||
|
||||
+21
-1
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../package-export.errors';
|
||||
import { AutoIncludedWorkflowResolver } from '../auto-included-workflow-resolver';
|
||||
import type { WorkflowSubWorkflowRequirement } from '../workflow.types';
|
||||
import { WorkflowVersionPolicy } from '../../../n8n-packages.types';
|
||||
|
||||
const user = mock<User>({ id: 'user-1' });
|
||||
|
||||
@@ -105,6 +106,7 @@ function resolveInput(options: {
|
||||
projectWorkflowIds: options.projectWorkflowIds ?? [],
|
||||
requirements: options.requirements,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: WorkflowVersionPolicy.Latest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -317,6 +319,24 @@ describe('AutoIncludedWorkflowResolver', () => {
|
||||
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
|
||||
});
|
||||
|
||||
it('names the unpublished sub-workflow when ignore-unpublished drops a dependency', async () => {
|
||||
const unpublished = { ...makeWorkflow('b'), activeVersionId: null } as WorkflowEntity;
|
||||
const { resolver } = makeResolver({
|
||||
workflows: [makeWorkflow('seed'), unpublished],
|
||||
owners: { b: makeProject('p1') },
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolver.resolve({
|
||||
...resolveInput({
|
||||
topLevelWorkflowIds: ['seed'],
|
||||
requirements: [requirement('seed', 'b')],
|
||||
}),
|
||||
workflowVersionPolicy: WorkflowVersionPolicy.IgnoreUnpublished,
|
||||
}),
|
||||
).rejects.toThrow('1 sub-workflow dependency has no published version. Export aborted.');
|
||||
});
|
||||
|
||||
it('requests exportable workflows with the workflow:export scope and parent folder', async () => {
|
||||
const { resolver, workflowFinder } = makeResolver({
|
||||
workflows: [makeWorkflow('seed'), makeWorkflow('b')],
|
||||
@@ -334,7 +354,7 @@ describe('AutoIncludedWorkflowResolver', () => {
|
||||
['b'],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true, includeTags: true },
|
||||
{ includeParentFolder: true, includeTags: true, includeActiveVersion: false },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+65
-8
@@ -1,4 +1,4 @@
|
||||
import type { User, WorkflowEntity } from '@n8n/db';
|
||||
import type { User, WorkflowEntity, WorkflowHistory } from '@n8n/db';
|
||||
import type { INode } from 'n8n-workflow';
|
||||
import { mock } from 'vitest-mock-extended';
|
||||
|
||||
@@ -26,6 +26,22 @@ function makeWorkflow(id: string, referencedWorkflowIds: string | string[] = [])
|
||||
return { id, nodes } as WorkflowEntity;
|
||||
}
|
||||
|
||||
/** Publishes the workflow with nodes referencing `referencedWorkflowIds`, leaving its draft nodes alone. */
|
||||
function withPublishedVersion(
|
||||
workflow: WorkflowEntity,
|
||||
referencedWorkflowIds: string | string[],
|
||||
): WorkflowEntity {
|
||||
const versionId = `${workflow.id}-published`;
|
||||
workflow.activeVersionId = versionId;
|
||||
workflow.activeVersion = {
|
||||
versionId,
|
||||
nodes: makeWorkflow(workflow.id, referencedWorkflowIds).nodes,
|
||||
connections: {},
|
||||
} as WorkflowHistory;
|
||||
|
||||
return workflow;
|
||||
}
|
||||
|
||||
function makeResolver(workflows: WorkflowEntity[]) {
|
||||
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
|
||||
const workflowFinder = mock<WorkflowFinderService>();
|
||||
@@ -49,7 +65,11 @@ describe('WorkflowDependencyResolver', () => {
|
||||
makeWorkflow('workflow-c'),
|
||||
]);
|
||||
|
||||
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
|
||||
const requirements = await resolver.resolve({
|
||||
user,
|
||||
workflowIds: ['workflow-a'],
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(requirements).toEqual([
|
||||
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
|
||||
@@ -63,7 +83,11 @@ describe('WorkflowDependencyResolver', () => {
|
||||
makeWorkflow('workflow-b', 'workflow-a'),
|
||||
]);
|
||||
|
||||
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
|
||||
const requirements = await resolver.resolve({
|
||||
user,
|
||||
workflowIds: ['workflow-a'],
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(requirements).toEqual([
|
||||
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
|
||||
@@ -75,14 +99,21 @@ describe('WorkflowDependencyResolver', () => {
|
||||
it('keeps missing or inaccessible dependencies as requirements but does not traverse them', async () => {
|
||||
const { resolver, workflowFinder } = makeResolver([makeWorkflow('workflow-a', 'workflow-b')]);
|
||||
|
||||
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
|
||||
const requirements = await resolver.resolve({
|
||||
user,
|
||||
workflowIds: ['workflow-a'],
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(requirements).toEqual([
|
||||
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
|
||||
]);
|
||||
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(['workflow-b'], user, [
|
||||
'workflow:export',
|
||||
]);
|
||||
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(
|
||||
['workflow-b'],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeActiveVersion: false },
|
||||
);
|
||||
});
|
||||
|
||||
it('resolves a complex graph with fan-out, cycles, convergence, and inaccessible dependencies', async () => {
|
||||
@@ -96,7 +127,11 @@ describe('WorkflowDependencyResolver', () => {
|
||||
makeWorkflow('workflow-e', 'workflow-b'),
|
||||
]);
|
||||
|
||||
const requirements = await resolver.resolve({ user, workflowIds: ['workflow-a'] });
|
||||
const requirements = await resolver.resolve({
|
||||
user,
|
||||
workflowIds: ['workflow-a'],
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(requirements).toEqual([
|
||||
{ workflowId: 'workflow-a', referencedWorkflowId: 'workflow-b' },
|
||||
@@ -112,18 +147,21 @@ describe('WorkflowDependencyResolver', () => {
|
||||
['workflow-a'],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeActiveVersion: false },
|
||||
);
|
||||
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
['workflow-b', 'workflow-c'],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeActiveVersion: false },
|
||||
);
|
||||
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
['workflow-e', 'workflow-d'],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeActiveVersion: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -139,6 +177,7 @@ describe('WorkflowDependencyResolver', () => {
|
||||
user,
|
||||
workflowIds: ['workflow-a'],
|
||||
traversal: 'direct',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(requirements).toEqual([
|
||||
@@ -157,6 +196,7 @@ describe('WorkflowDependencyResolver', () => {
|
||||
user,
|
||||
workflowIds: ['workflow-a', 'workflow-b'],
|
||||
traversal: 'direct',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(requirements).toEqual([
|
||||
@@ -166,4 +206,21 @@ describe('WorkflowDependencyResolver', () => {
|
||||
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts the references of the published nodes under published-strict', async () => {
|
||||
const { resolver } = makeResolver([
|
||||
withPublishedVersion(makeWorkflow('workflow-a', 'draft-dep'), 'published-dep'),
|
||||
]);
|
||||
|
||||
const requirements = await resolver.resolve({
|
||||
user,
|
||||
workflowIds: ['workflow-a'],
|
||||
traversal: 'direct',
|
||||
workflowVersionPolicy: 'published-strict',
|
||||
});
|
||||
|
||||
expect(requirements).toEqual([
|
||||
{ workflowId: 'workflow-a', referencedWorkflowId: 'published-dep' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { WorkflowEntity } from '@n8n/db';
|
||||
|
||||
import { WorkflowVersionPolicy } from '../../../n8n-packages.types';
|
||||
import { applyWorkflowVersionPolicy } from '../workflow-version-policy';
|
||||
|
||||
/**
|
||||
* Policy selection itself is covered end to end by the export integration suites.
|
||||
* This guard fires only when a caller loads workflows without the published
|
||||
* version, which no export path can reach.
|
||||
*/
|
||||
describe('applyWorkflowVersionPolicy', () => {
|
||||
it('fails loudly when the published version was not loaded', () => {
|
||||
const workflow = Object.assign(new WorkflowEntity(), {
|
||||
id: 'wf-1',
|
||||
activeVersionId: 'wf-1-published-version',
|
||||
activeVersion: null,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
applyWorkflowVersionPolicy([workflow], WorkflowVersionPolicy.PublishedStrict),
|
||||
).toThrow('Published version was not loaded for workflow');
|
||||
});
|
||||
});
|
||||
+45
-6
@@ -63,13 +63,19 @@ describe('WorkflowExporter', () => {
|
||||
const { exporter, finder } = makeExporter([workflow]);
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
|
||||
await exporter.export({
|
||||
user,
|
||||
workflowIds: [workflow.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(finder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(
|
||||
[workflow.id],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true, includeTags: true },
|
||||
{ includeParentFolder: true, includeTags: true, includeActiveVersion: false },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -84,6 +90,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: ['present-1', 'missing-or-denied'],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.');
|
||||
});
|
||||
@@ -95,7 +102,13 @@ describe('WorkflowExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
|
||||
exporter.export({
|
||||
user,
|
||||
workflowIds: ['present-1', 'missing'],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
|
||||
});
|
||||
|
||||
@@ -106,7 +119,13 @@ describe('WorkflowExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, workflowIds: ['present-1', 'denied-1'], writer, includeTags: true }),
|
||||
exporter.export({
|
||||
user,
|
||||
workflowIds: ['present-1', 'denied-1'],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
|
||||
});
|
||||
|
||||
@@ -116,7 +135,13 @@ describe('WorkflowExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
|
||||
exporter.export({
|
||||
user,
|
||||
workflowIds: ['present-1', 'missing'],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(finder.findExistingWorkflowIds).toHaveBeenCalledWith(['missing']);
|
||||
@@ -132,6 +157,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [workflow.id, workflow.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -153,6 +179,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(entries.map(({ id }) => id)).toEqual([a.id, b.id]);
|
||||
@@ -181,7 +208,13 @@ describe('WorkflowExporter', () => {
|
||||
const { exporter } = makeExporter([workflow]);
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
|
||||
await exporter.export({
|
||||
user,
|
||||
workflowIds: [workflow.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
const workflowFile = writer.files.find((f) => f.path === 'workflows/my-workflow/workflow.json');
|
||||
expect(workflowFile).toBeDefined();
|
||||
@@ -208,6 +241,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [workflow.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
basePrefix: 'folders/in_progress',
|
||||
});
|
||||
|
||||
@@ -228,6 +262,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
const targets = entries.map((e) => e.target);
|
||||
@@ -260,6 +295,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(extractor.extract).toHaveBeenCalledTimes(2);
|
||||
@@ -294,6 +330,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(extractor.extract).toHaveBeenCalledTimes(2);
|
||||
@@ -318,6 +355,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(extractor.extract).toHaveBeenCalledTimes(2);
|
||||
@@ -346,6 +384,7 @@ describe('WorkflowExporter', () => {
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
|
||||
expect(requirements.nodeTypes).toEqual([
|
||||
|
||||
+34
-2
@@ -10,7 +10,9 @@ import {
|
||||
PackageExportBlockedError,
|
||||
assertEveryRequestedEntityAccessible,
|
||||
} from '../package-export.errors';
|
||||
import { applyWorkflowVersionPolicy, needsActiveVersion } from './workflow-version-policy';
|
||||
import type { WorkflowSubWorkflowRequirement } from './workflow.types';
|
||||
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
|
||||
|
||||
export type WorkflowExportOrigin = 'top-level' | 'folder' | 'project';
|
||||
|
||||
@@ -41,6 +43,7 @@ export class AutoIncludedWorkflowResolver {
|
||||
folderWorkflowIds: string[];
|
||||
projectWorkflowIds: string[];
|
||||
includeTags: boolean;
|
||||
workflowVersionPolicy: WorkflowVersionPolicy;
|
||||
}): Promise<AutoIncludedWorkflowResolution> {
|
||||
const originsByWorkflowId = this.seedExportedOrigins({
|
||||
topLevelWorkflowIds: options.topLevelWorkflowIds,
|
||||
@@ -60,6 +63,7 @@ export class AutoIncludedWorkflowResolver {
|
||||
workflowIds: autoIncludedWorkflowIds,
|
||||
originsByWorkflowId,
|
||||
includeTags: options.includeTags,
|
||||
workflowVersionPolicy: options.workflowVersionPolicy,
|
||||
});
|
||||
|
||||
return { autoIncludedWorkflows };
|
||||
@@ -148,6 +152,7 @@ export class AutoIncludedWorkflowResolver {
|
||||
workflowIds: string[];
|
||||
originsByWorkflowId: Map<string, Set<WorkflowExportOrigin>>;
|
||||
includeTags: boolean;
|
||||
workflowVersionPolicy: WorkflowVersionPolicy;
|
||||
}): Promise<AutoIncludedWorkflow[]> {
|
||||
if (options.workflowIds.length === 0) return [];
|
||||
|
||||
@@ -155,6 +160,7 @@ export class AutoIncludedWorkflowResolver {
|
||||
options.user,
|
||||
options.workflowIds,
|
||||
options.includeTags,
|
||||
options.workflowVersionPolicy,
|
||||
);
|
||||
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
|
||||
const ownersByWorkflowId = await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds(
|
||||
@@ -229,12 +235,17 @@ export class AutoIncludedWorkflowResolver {
|
||||
user: User,
|
||||
workflowIds: string[],
|
||||
includeTags: boolean,
|
||||
workflowVersionPolicy: WorkflowVersionPolicy,
|
||||
): Promise<WorkflowEntity[]> {
|
||||
const workflows = await this.workflowFinder.findWorkflowsByIdsForUser(
|
||||
workflowIds,
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true, includeTags },
|
||||
{
|
||||
includeParentFolder: true,
|
||||
includeTags,
|
||||
includeActiveVersion: needsActiveVersion(workflowVersionPolicy),
|
||||
},
|
||||
);
|
||||
|
||||
await assertEveryRequestedEntityAccessible(
|
||||
@@ -244,7 +255,28 @@ export class AutoIncludedWorkflowResolver {
|
||||
async (ids) => await this.workflowFinder.findExistingWorkflowIds(ids),
|
||||
);
|
||||
|
||||
return workflows;
|
||||
const exportableWorkflows = applyWorkflowVersionPolicy(workflows, workflowVersionPolicy);
|
||||
|
||||
// `ignore-unpublished` skips top-level workflows silently, but a dependency
|
||||
// it drops is one the package cannot ship without — abort, naming the cause.
|
||||
if (exportableWorkflows.length < workflows.length) {
|
||||
const exportableIds = new Set(exportableWorkflows.map(({ id }) => id));
|
||||
const droppedIds = workflows.map(({ id }) => id).filter((id) => !exportableIds.has(id));
|
||||
const displayed = droppedIds.slice(0, 20);
|
||||
const omittedCount = droppedIds.length - displayed.length;
|
||||
const dependencyLabel = droppedIds.length === 1 ? 'dependency has' : 'dependencies have';
|
||||
|
||||
throw new PackageExportBlockedError(
|
||||
`${droppedIds.length} sub-workflow ${dependencyLabel} no published version. Export aborted.`,
|
||||
{
|
||||
description: `Unpublished sub-workflow IDs: ${displayed.join(', ')}${
|
||||
omittedCount > 0 ? `, and ${omittedCount} more` : ''
|
||||
}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return exportableWorkflows;
|
||||
}
|
||||
|
||||
private async findAccessibleFolderChains(
|
||||
|
||||
+7
-1
@@ -4,7 +4,9 @@ import { Service } from '@n8n/di';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { extractWorkflowRequirements } from './references/extract-workflow-requirements';
|
||||
import { applyWorkflowVersionPolicy, needsActiveVersion } from './workflow-version-policy';
|
||||
import type { WorkflowSubWorkflowRequirement } from './workflow.types';
|
||||
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
|
||||
|
||||
export interface WorkflowDependencyResolveRequest {
|
||||
user: User;
|
||||
@@ -15,6 +17,7 @@ export interface WorkflowDependencyResolveRequest {
|
||||
* workflows' own references.
|
||||
*/
|
||||
traversal?: 'transitive' | 'direct';
|
||||
workflowVersionPolicy: WorkflowVersionPolicy;
|
||||
}
|
||||
|
||||
@Service()
|
||||
@@ -25,6 +28,7 @@ export class WorkflowDependencyResolver {
|
||||
request: WorkflowDependencyResolveRequest,
|
||||
): Promise<WorkflowSubWorkflowRequirement[]> {
|
||||
const traverse = (request.traversal ?? 'transitive') === 'transitive';
|
||||
const policy = request.workflowVersionPolicy;
|
||||
const queue = [...new Set(request.workflowIds)];
|
||||
const seenWorkflowIds = new Set(queue);
|
||||
const requirements: WorkflowSubWorkflowRequirement[] = [];
|
||||
@@ -32,11 +36,13 @@ export class WorkflowDependencyResolver {
|
||||
while (queue.length > 0) {
|
||||
const workflowIds = queue.splice(0);
|
||||
|
||||
const workflows = await this.workflowFinder.findWorkflowsByIdsForUser(
|
||||
const loaded = await this.workflowFinder.findWorkflowsByIdsForUser(
|
||||
workflowIds,
|
||||
request.user,
|
||||
['workflow:export'],
|
||||
{ includeActiveVersion: needsActiveVersion(policy) },
|
||||
);
|
||||
const workflows = applyWorkflowVersionPolicy(loaded, policy);
|
||||
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
|
||||
|
||||
for (const workflowId of workflowIds) {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { WorkflowEntity } from '@n8n/db';
|
||||
import { UnexpectedError } from 'n8n-workflow';
|
||||
|
||||
import { WorkflowVersionPolicy } from '../../n8n-packages.types';
|
||||
import { PackageExportBlockedError } from '../package-export.errors';
|
||||
|
||||
export function needsActiveVersion(policy: WorkflowVersionPolicy): boolean {
|
||||
return policy !== WorkflowVersionPolicy.Latest;
|
||||
}
|
||||
|
||||
/** Only nodes and connections are overlaid, so name, description, settings and tags stay at their draft values. */
|
||||
function atPublishedVersion(workflow: WorkflowEntity): WorkflowEntity {
|
||||
const { activeVersion } = workflow;
|
||||
|
||||
if (!activeVersion) {
|
||||
throw new UnexpectedError('Published version was not loaded for workflow', {
|
||||
extra: { workflowId: workflow.id, activeVersionId: workflow.activeVersionId },
|
||||
});
|
||||
}
|
||||
|
||||
// A spread would drop the entity's inherited TypeORM lifecycle hooks.
|
||||
return Object.assign(new WorkflowEntity(), workflow, {
|
||||
versionId: activeVersion.versionId,
|
||||
nodes: activeVersion.nodes,
|
||||
connections: activeVersion.connections,
|
||||
});
|
||||
}
|
||||
|
||||
const isPublished = (workflow: WorkflowEntity) => workflow.activeVersionId !== null;
|
||||
|
||||
function assertEveryWorkflowPublished(workflows: WorkflowEntity[]): void {
|
||||
const unpublished = workflows.filter((workflow) => !isPublished(workflow));
|
||||
if (unpublished.length === 0) return;
|
||||
|
||||
const displayed = unpublished.slice(0, 20);
|
||||
const omittedCount = unpublished.length - displayed.length;
|
||||
|
||||
throw new PackageExportBlockedError(
|
||||
`${unpublished.length} workflow(s) have no published version. Export aborted.`,
|
||||
{
|
||||
description: `Unpublished workflow IDs: ${displayed.map(({ id }) => id).join(', ')}${
|
||||
omittedCount > 0 ? `, and ${omittedCount} more` : ''
|
||||
}`,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const WORKFLOW_VERSION_POLICIES: Record<
|
||||
WorkflowVersionPolicy,
|
||||
(workflows: WorkflowEntity[]) => WorkflowEntity[]
|
||||
> = {
|
||||
[WorkflowVersionPolicy.Latest]: (workflows) => workflows,
|
||||
[WorkflowVersionPolicy.PublishedStrict]: (workflows) => {
|
||||
assertEveryWorkflowPublished(workflows);
|
||||
return workflows.map(atPublishedVersion);
|
||||
},
|
||||
[WorkflowVersionPolicy.PreferPublished]: (workflows) =>
|
||||
workflows.map((workflow) => (isPublished(workflow) ? atPublishedVersion(workflow) : workflow)),
|
||||
[WorkflowVersionPolicy.IgnoreUnpublished]: (workflows) =>
|
||||
workflows.filter(isPublished).map(atPublishedVersion),
|
||||
};
|
||||
|
||||
export function applyWorkflowVersionPolicy(
|
||||
workflows: WorkflowEntity[],
|
||||
policy: WorkflowVersionPolicy,
|
||||
): WorkflowEntity[] {
|
||||
return WORKFLOW_VERSION_POLICIES[policy](workflows);
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import { Service } from '@n8n/di';
|
||||
import { WorkflowFinderService } from '@/workflows/workflow-finder.service';
|
||||
|
||||
import { WorkflowSerializer } from './workflow.serializer';
|
||||
import { applyWorkflowVersionPolicy, needsActiveVersion } from './workflow-version-policy';
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import type { WorkflowVersionPolicy } from '../../n8n-packages.types';
|
||||
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
|
||||
import type { ManifestEntry } from '../../spec/manifest.schema';
|
||||
import { CredentialRequirementsExtractor } from '../credential/credential-requirements.extractor';
|
||||
@@ -24,6 +26,7 @@ export interface WorkflowExportRequest {
|
||||
workflowIds: string[];
|
||||
writer: PackageWriter;
|
||||
includeTags: boolean;
|
||||
workflowVersionPolicy: WorkflowVersionPolicy;
|
||||
|
||||
// Directory the workflow is written under. e.g. folders/{folderId}/
|
||||
basePrefix?: string;
|
||||
@@ -50,7 +53,11 @@ export class WorkflowExporter {
|
||||
request.workflowIds,
|
||||
request.user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true, includeTags: request.includeTags },
|
||||
{
|
||||
includeParentFolder: true,
|
||||
includeTags: request.includeTags,
|
||||
includeActiveVersion: needsActiveVersion(request.workflowVersionPolicy),
|
||||
},
|
||||
);
|
||||
|
||||
await assertEveryRequestedEntityAccessible(
|
||||
@@ -60,7 +67,10 @@ export class WorkflowExporter {
|
||||
async (ids) => await this.workflowFinder.findExistingWorkflowIds(ids),
|
||||
);
|
||||
|
||||
const workflowsForExport = this.orderWorkflowsByRequest(request.workflowIds, workflows);
|
||||
const workflowsForExport = this.orderWorkflowsByRequest(
|
||||
request.workflowIds,
|
||||
applyWorkflowVersionPolicy(workflows, request.workflowVersionPolicy),
|
||||
);
|
||||
const entries: ManifestEntry[] = [];
|
||||
const credentials: WorkflowCredentialRequirement[] = [];
|
||||
const dataTables: WorkflowDataTableRequirement[] = [];
|
||||
|
||||
@@ -32,6 +32,7 @@ import { TarPackageWriter } from './io/tar/tar-package-writer';
|
||||
import { PackageImportConfig } from './n8n-packages.config';
|
||||
import {
|
||||
MissingWorkflowDependencyPolicy,
|
||||
WorkflowVersionPolicy,
|
||||
type ExportPackageEventCounts,
|
||||
type ExportPackageRequest,
|
||||
type ExportPackageResult,
|
||||
@@ -79,6 +80,7 @@ export class N8nPackagesService {
|
||||
const folderIds = request.folderIds ?? [];
|
||||
const projectIds = request.projectIds ?? [];
|
||||
const includeTags = (request.includeTags ?? true) && !this.globalConfig.tags.disabled;
|
||||
const workflowVersionPolicy = request.workflowVersionPolicy ?? WorkflowVersionPolicy.Latest;
|
||||
|
||||
const folderExportResult =
|
||||
folderIds.length > 0
|
||||
@@ -87,6 +89,7 @@ export class N8nPackagesService {
|
||||
folderIds,
|
||||
writer,
|
||||
includeTags,
|
||||
workflowVersionPolicy,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -102,6 +105,7 @@ export class N8nPackagesService {
|
||||
workflowIds: workflowsForExport,
|
||||
writer,
|
||||
includeTags,
|
||||
workflowVersionPolicy,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -112,6 +116,7 @@ export class N8nPackagesService {
|
||||
projectIds,
|
||||
writer,
|
||||
includeTags,
|
||||
workflowVersionPolicy,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -133,6 +138,7 @@ export class N8nPackagesService {
|
||||
user: request.user,
|
||||
workflowIds: allWorkflowsBeforeAutoInclude.map(({ id }) => id),
|
||||
traversal: isReferenceOnly ? 'direct' : 'transitive',
|
||||
workflowVersionPolicy,
|
||||
});
|
||||
|
||||
let autoIncludedExportResult: AutoIncludedWorkflowExportResult | undefined;
|
||||
@@ -145,6 +151,7 @@ export class N8nPackagesService {
|
||||
folderWorkflowIds: folderExportResult?.workflowEntries.map(({ id }) => id) ?? [],
|
||||
projectWorkflowIds: projectExportResult?.workflowEntries.map(({ id }) => id) ?? [],
|
||||
includeTags,
|
||||
workflowVersionPolicy,
|
||||
});
|
||||
|
||||
autoIncludedExportResult = this.autoIncludedWorkflowExporter.export({
|
||||
|
||||
@@ -80,6 +80,17 @@ export const MissingWorkflowDependencyPolicy = {
|
||||
IncludeInPackage: 'include-in-package',
|
||||
} as const;
|
||||
|
||||
export const WorkflowVersionPolicy = {
|
||||
/** Exports the latest published version, failing if any workflow has none. */
|
||||
PublishedStrict: 'published-strict',
|
||||
/** Exports the latest published version where there is one, the latest version otherwise. */
|
||||
PreferPublished: 'prefer-published',
|
||||
/** Exports only published workflows, leaving unpublished ones out of the package. */
|
||||
IgnoreUnpublished: 'ignore-unpublished',
|
||||
/** Exports the latest version of every workflow, published or not. */
|
||||
Latest: 'latest',
|
||||
} as const;
|
||||
|
||||
export const DataTableMatchingMode = {
|
||||
/** Matches a package table to the target-project table with the same id. Never falls back to name matching. */
|
||||
ById: 'by-id',
|
||||
@@ -158,6 +169,9 @@ export type MissingNodeTypeMode = (typeof MissingNodeTypeMode)[keyof typeof Miss
|
||||
export type MissingWorkflowDependencyPolicy =
|
||||
(typeof MissingWorkflowDependencyPolicy)[keyof typeof MissingWorkflowDependencyPolicy];
|
||||
|
||||
export type WorkflowVersionPolicy =
|
||||
(typeof WorkflowVersionPolicy)[keyof typeof WorkflowVersionPolicy];
|
||||
|
||||
export type DataTableMatchingMode =
|
||||
(typeof DataTableMatchingMode)[keyof typeof DataTableMatchingMode];
|
||||
|
||||
@@ -186,6 +200,7 @@ export interface ExportPackageRequest {
|
||||
canExportVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: MissingWorkflowDependencyPolicy;
|
||||
workflowVersionPolicy?: WorkflowVersionPolicy;
|
||||
}
|
||||
|
||||
export type ImportPackageRequest = {
|
||||
|
||||
+29
@@ -60,6 +60,7 @@ describe('n8n-packages handler', () => {
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
workflowVersionPolicy?: string;
|
||||
},
|
||||
apiKeyScopes?: string[],
|
||||
) {
|
||||
@@ -252,6 +253,7 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -277,6 +279,7 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,6 +379,7 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
|
||||
expect(res.setHeader).toHaveBeenCalledWith(
|
||||
@@ -421,9 +425,30 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'reference-only',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a non-default workflow version policy', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
makeRequest({ workflowIds: ['wf-1'], workflowVersionPolicy: 'published-strict' }, [
|
||||
'workflow:export',
|
||||
]),
|
||||
res,
|
||||
);
|
||||
stream.end(Buffer.from('package-bytes'));
|
||||
const caught = await resultPromise;
|
||||
|
||||
expect(caught).toBeUndefined();
|
||||
expect(mockService.exportPackage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ workflowVersionPolicy: 'published-strict' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('streams the export for a valid project request', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
@@ -446,6 +471,7 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -471,6 +497,7 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -496,6 +523,7 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -521,6 +549,7 @@ describe('n8n-packages handler', () => {
|
||||
canExportVariableValues: false,
|
||||
includeTags: false,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
workflowVersionPolicy: 'latest',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,6 +36,11 @@ type ExportPackageRequest = AuthenticatedRequest<
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: 'fail' | 'reference-only' | 'include-in-package';
|
||||
workflowVersionPolicy?:
|
||||
| 'published-strict'
|
||||
| 'prefer-published'
|
||||
| 'ignore-unpublished'
|
||||
| 'latest';
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -150,6 +155,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
|
||||
canExportVariableValues: apiKeyScopes.includes('variable:list'),
|
||||
includeTags: payload.data.includeTags,
|
||||
missingWorkflowDependencyPolicy: payload.data.missingWorkflowDependencyPolicy,
|
||||
workflowVersionPolicy: payload.data.workflowVersionPolicy,
|
||||
});
|
||||
|
||||
return await streamPackageExport(res, exportResult);
|
||||
|
||||
+19
@@ -63,3 +63,22 @@ properties:
|
||||
already exist on the target.
|
||||
example: fail
|
||||
default: fail
|
||||
workflowVersionPolicy:
|
||||
type: string
|
||||
enum:
|
||||
- published-strict
|
||||
- prefer-published
|
||||
- ignore-unpublished
|
||||
- latest
|
||||
description: >-
|
||||
Which version of each workflow travels in the package. `latest` exports
|
||||
the latest version, published or not. `published-strict` exports the
|
||||
published version and aborts the export if any workflow has none.
|
||||
`prefer-published` falls back to the latest version where there is no
|
||||
published one. `ignore-unpublished` leaves unpublished workflows out of
|
||||
the package entirely. The chosen version decides which credentials, data
|
||||
tables, variables and sub-workflows are bundled alongside it; the
|
||||
workflow's name, settings and tags are not versioned and always come from
|
||||
the latest version.
|
||||
example: latest
|
||||
default: latest
|
||||
|
||||
@@ -181,7 +181,11 @@ export class WorkflowFinderService {
|
||||
workflowIds: string[],
|
||||
user: User,
|
||||
scopes: Scope[],
|
||||
options: { includeParentFolder?: boolean; includeTags?: boolean } = {},
|
||||
options: {
|
||||
includeParentFolder?: boolean;
|
||||
includeTags?: boolean;
|
||||
includeActiveVersion?: boolean;
|
||||
} = {},
|
||||
): Promise<WorkflowEntity[]> {
|
||||
if (workflowIds.length === 0) return [];
|
||||
|
||||
@@ -189,7 +193,11 @@ export class WorkflowFinderService {
|
||||
const sharedWorkflows = await this.sharedWorkflowRepository.find({
|
||||
where: { ...where, workflowId: In(workflowIds) },
|
||||
relations: {
|
||||
workflow: { parentFolder: options.includeParentFolder, tags: options.includeTags },
|
||||
workflow: {
|
||||
parentFolder: options.includeParentFolder,
|
||||
tags: options.includeTags,
|
||||
activeVersion: options.includeActiveVersion,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user