mirror of
https://github.com/n8n-io/n8n.git
synced 2026-08-29 01:39:24 +08:00
feat(core): Include workflow tags in exported packages (#34892)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,35 @@ describe('ExportPackageRequestDto', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('includeTags', () => {
|
||||
it('defaults to true when omitted', () => {
|
||||
const result = ExportPackageRequestDto.safeParse({ workflowIds: ['wf-1'] });
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.includeTags).toBe(true);
|
||||
});
|
||||
|
||||
it.each([true, false])('accepts explicit %s', (includeTags) => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
includeTags,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) expect(result.data.includeTags).toBe(includeTags);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: 'string value', includeTags: 'false' },
|
||||
{ name: 'numeric value', includeTags: 0 },
|
||||
{ name: 'null value', includeTags: null },
|
||||
])('rejects $name', ({ includeTags }) => {
|
||||
const result = ExportPackageRequestDto.safeParse({
|
||||
workflowIds: ['wf-1'],
|
||||
includeTags,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('missingWorkflowDependencyPolicy', () => {
|
||||
it.each(['fail', 'reference-only', 'include-in-package'])(
|
||||
'accepts %s',
|
||||
|
||||
@@ -7,6 +7,7 @@ export class ExportPackageRequestDto extends Z.class({
|
||||
folderIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
projectIds: z.array(z.string().trim().min(1)).min(1).max(300).optional(),
|
||||
includeVariableValues: z.boolean().default(true),
|
||||
includeTags: z.boolean().default(true),
|
||||
missingWorkflowDependencyPolicy: z
|
||||
.enum(['fail', 'reference-only', 'include-in-package'])
|
||||
.optional()
|
||||
|
||||
@@ -17,6 +17,7 @@ n8n-cli package export --folder-id=xyz -o folders.n8np
|
||||
n8n-cli package export --project-id=abc -o project.n8np
|
||||
n8n-cli package export -p abc -p def -o projects.n8np
|
||||
n8n-cli package export -w abc --include-variable-values=false -o export.n8np
|
||||
n8n-cli package export -w abc --include-tags=false -o export.n8np
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
@@ -26,6 +27,7 @@ n8n-cli package export -w abc --include-variable-values=false -o export.n8np
|
||||
| `-p, --project-id` | Project ID to include. Repeat the flag to export several. |
|
||||
| `-o, --output` | File to write the package to. Defaults to `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` is reserved for a future export mode. |
|
||||
|
||||
Provide at least one `--workflow-id`, `--folder-id`, or `--project-id`. Requires
|
||||
|
||||
@@ -144,6 +144,15 @@ describe('N8nClient packages', () => {
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'] }));
|
||||
});
|
||||
|
||||
it('includes includeTags=false in the body when provided', async () => {
|
||||
fetchMock.mockResolvedValue(binaryResponse(200, new Uint8Array([1])));
|
||||
|
||||
await client.exportPackage({ workflowIds: ['a'], includeTags: false });
|
||||
|
||||
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
||||
expect(init.body).toBe(JSON.stringify({ workflowIds: ['a'], includeTags: false }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('importPackage', () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ interface ExportFlags {
|
||||
projectId?: string[];
|
||||
output: string;
|
||||
includeVariableValues?: string;
|
||||
includeTags?: string;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
}
|
||||
|
||||
@@ -71,6 +72,7 @@ describe('package export command', () => {
|
||||
workflowIds: ['wf-1', 'wf-2'],
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/team.n8np', Buffer.from([1, 2, 3]));
|
||||
@@ -88,6 +90,7 @@ describe('package export command', () => {
|
||||
workflowIds: [],
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/folders.n8np', Buffer.from([1, 2, 3]));
|
||||
@@ -106,6 +109,7 @@ describe('package export command', () => {
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
@@ -124,6 +128,7 @@ describe('package export command', () => {
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: ['fld-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'reference-only',
|
||||
});
|
||||
});
|
||||
@@ -139,6 +144,7 @@ describe('package export command', () => {
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
projectIds: ['proj-1', 'proj-2'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
expect(mockedWriteFileSync).toHaveBeenCalledWith('/tmp/projects.n8np', Buffer.from([1, 2, 3]));
|
||||
@@ -156,6 +162,7 @@ describe('package export command', () => {
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
});
|
||||
});
|
||||
@@ -173,6 +180,7 @@ describe('package export command', () => {
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: [],
|
||||
includeVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
@@ -189,10 +197,71 @@ describe('package export command', () => {
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards includeTags=false when the flag is set', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
workflowId: ['wf-1'],
|
||||
output: '/tmp/export.n8np',
|
||||
includeTags: 'false',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: false,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards includeTags=false for a project export', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
projectId: ['proj-1'],
|
||||
output: '/tmp/project.n8np',
|
||||
includeTags: 'false',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
projectIds: ['proj-1'],
|
||||
includeVariableValues: true,
|
||||
includeTags: false,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats an explicit --include-tags=true like the default', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
workflowId: ['wf-1'],
|
||||
output: '/tmp/export.n8np',
|
||||
includeTags: 'true',
|
||||
});
|
||||
|
||||
await command.run();
|
||||
|
||||
expect(exportPackage).toHaveBeenCalledWith({
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('declares the includeTags flag with its alias, options, and default', () => {
|
||||
const flag = PackageExport.flags.includeTags;
|
||||
expect(flag.aliases).toEqual(['include-tags']);
|
||||
expect(flag.options).toEqual(['true', 'false']);
|
||||
expect(flag.default).toBe('true');
|
||||
});
|
||||
|
||||
it('treats an explicit --include-variable-values=true like the default', async () => {
|
||||
const { command, exportPackage } = stubCommand({
|
||||
workflowId: ['wf-1'],
|
||||
@@ -206,6 +275,7 @@ describe('package export command', () => {
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: [],
|
||||
includeVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
@@ -275,4 +345,20 @@ describe('package export command', () => {
|
||||
const message = vi.mocked(internals.succeed).mock.calls[0][0];
|
||||
expect(message).toBe('Exported 3 workflow(s), 1 folder(s) to /tmp/folders.n8np');
|
||||
});
|
||||
|
||||
it('includes a non-zero tag count in the export message', async () => {
|
||||
const exportPackage = vi.fn().mockResolvedValue({
|
||||
archive: Buffer.from([1, 2, 3]),
|
||||
counts: { workflows: 2, folders: 0, credentials: 0, dataTables: 0, variables: 0, tags: 2 },
|
||||
});
|
||||
const { command, internals } = stubCommand(
|
||||
{ workflowId: ['wf-1', 'wf-2'], output: '/tmp/team.n8np' },
|
||||
exportPackage,
|
||||
);
|
||||
|
||||
await command.run();
|
||||
|
||||
const message = vi.mocked(internals.succeed).mock.calls[0][0];
|
||||
expect(message).toBe('Exported 2 workflow(s), 2 tag(s) to /tmp/team.n8np');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface ExportPackageFields {
|
||||
folderIds?: string[];
|
||||
projectIds?: string[];
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
}
|
||||
|
||||
@@ -42,6 +43,8 @@ export interface ExportPackageCounts {
|
||||
credentials: number;
|
||||
dataTables: number;
|
||||
variables: number;
|
||||
/** Absent when the server predates tag export. */
|
||||
tags?: number;
|
||||
}
|
||||
|
||||
export interface ExportPackageResult {
|
||||
@@ -463,6 +466,7 @@ export class N8nClient {
|
||||
folderIds?: string[];
|
||||
projectIds?: string[];
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
} = {};
|
||||
if (fields.workflowIds?.length) body.workflowIds = fields.workflowIds;
|
||||
@@ -470,6 +474,7 @@ export class N8nClient {
|
||||
if (fields.projectIds?.length) body.projectIds = fields.projectIds;
|
||||
// `undefined` is dropped by JSON serialization, so the API's default applies.
|
||||
body.includeVariableValues = fields.includeVariableValues;
|
||||
body.includeTags = fields.includeTags;
|
||||
if (fields.missingWorkflowDependencyPolicy)
|
||||
body.missingWorkflowDependencyPolicy = fields.missingWorkflowDependencyPolicy;
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ function describeExport(counts: ExportPackageCounts & { projects?: number }): st
|
||||
if (counts.credentials) parts.push(`${counts.credentials} credential(s)`);
|
||||
if (counts.dataTables) parts.push(`${counts.dataTables} data table(s)`);
|
||||
if (counts.variables) parts.push(`${counts.variables} variable(s)`);
|
||||
if (counts.tags) parts.push(`${counts.tags} tag(s)`);
|
||||
return parts.length > 0 ? parts.join(', ') : 'nothing';
|
||||
}
|
||||
|
||||
@@ -32,6 +33,7 @@ export default class PackageExport extends BaseCommand {
|
||||
'<%= config.bin %> package export --project-id=abc -o project.n8np',
|
||||
'<%= config.bin %> package export -p abc -p def -o projects.n8np',
|
||||
'<%= config.bin %> package export -w abc --include-variable-values=false -o export.n8np',
|
||||
'<%= config.bin %> package export -w abc --include-tags=false -o export.n8np',
|
||||
];
|
||||
|
||||
static override flags = {
|
||||
@@ -66,6 +68,12 @@ export default class PackageExport extends BaseCommand {
|
||||
default: 'true',
|
||||
aliases: ['include-variable-values'],
|
||||
}),
|
||||
includeTags: Flags.string({
|
||||
description: 'Whether tags assigned to the exported workflows are bundled into the package',
|
||||
options: ['true', 'false'],
|
||||
default: 'true',
|
||||
aliases: ['include-tags'],
|
||||
}),
|
||||
missingWorkflowDependencyPolicy: Flags.string({
|
||||
options: ['fail', 'reference-only', 'include-in-package'],
|
||||
default: 'fail',
|
||||
@@ -81,6 +89,7 @@ export default class PackageExport extends BaseCommand {
|
||||
const folderIds = flags.folderId ?? [];
|
||||
const projectIds = flags.projectId ?? [];
|
||||
const includeVariableValues = flags.includeVariableValues !== 'false';
|
||||
const includeTags = flags.includeTags !== 'false';
|
||||
const missingWorkflowDependencyPolicy = flags.missingWorkflowDependencyPolicy;
|
||||
|
||||
// A package is either loose workflows/folders or whole projects, not both.
|
||||
@@ -97,8 +106,14 @@ export default class PackageExport extends BaseCommand {
|
||||
try {
|
||||
result = await client.exportPackage(
|
||||
projectIds.length > 0
|
||||
? { projectIds, includeVariableValues, missingWorkflowDependencyPolicy }
|
||||
: { workflowIds, folderIds, includeVariableValues, missingWorkflowDependencyPolicy },
|
||||
? { projectIds, includeVariableValues, includeTags, missingWorkflowDependencyPolicy }
|
||||
: {
|
||||
workflowIds,
|
||||
folderIds,
|
||||
includeVariableValues,
|
||||
includeTags,
|
||||
missingWorkflowDependencyPolicy,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
throw toPackagesError(error);
|
||||
|
||||
@@ -168,6 +168,7 @@ describe('LogStreamingEventRelay', () => {
|
||||
credentials: 1,
|
||||
dataTables: 1,
|
||||
variables: 1,
|
||||
tags: 1,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -2313,6 +2313,7 @@ describe('TelemetryEventRelay', () => {
|
||||
credentials: 2,
|
||||
dataTables: 1,
|
||||
variables: 4,
|
||||
tags: 2,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2325,6 +2326,7 @@ describe('TelemetryEventRelay', () => {
|
||||
credential_count: 2,
|
||||
data_table_count: 1,
|
||||
variable_count: 4,
|
||||
tag_count: 2,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1087,6 +1087,7 @@ export class TelemetryEventRelay extends EventRelay {
|
||||
credential_count: counts.credentials,
|
||||
data_table_count: counts.dataTables,
|
||||
variable_count: counts.variables,
|
||||
tag_count: counts.tags,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+263
@@ -0,0 +1,263 @@
|
||||
import { LicenseState } from '@n8n/backend-common';
|
||||
import { createTeamProject, createWorkflow, testDb, testModules } from '@n8n/backend-test-utils';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { TagRepository, WorkflowTagMappingRepository } from '@n8n/db';
|
||||
import { Container } from '@n8n/di';
|
||||
import { jsonParse } from 'n8n-workflow';
|
||||
|
||||
import { EventService } from '@/events/event.service';
|
||||
import type { RelayEventMap } from '@/events/maps/relay.event-map';
|
||||
import { createFolder } from '@test-integration/db/folders';
|
||||
import { assignTagToWorkflow, createTag } from '@test-integration/db/tags';
|
||||
import { createOwner } from '@test-integration/db/users';
|
||||
import { LicenseMocker } from '@test-integration/license';
|
||||
import { initNodeTypes } from '@test-integration/utils';
|
||||
|
||||
import { N8nPackagesService } from '../n8n-packages.service';
|
||||
import { readExport, streamToBuffer } from './utils/tar-support';
|
||||
import type { UnpackedEntry } from './utils/tar-support';
|
||||
import { buildWorkflowCallingSubWorkflow } from './utils/test-builders';
|
||||
|
||||
const licenseMocker = new LicenseMocker();
|
||||
|
||||
beforeAll(async () => {
|
||||
await testModules.loadModules(['n8n-packages']);
|
||||
await testDb.init();
|
||||
await initNodeTypes();
|
||||
licenseMocker.mockLicenseState(Container.get(LicenseState));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await testDb.terminate();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await testDb.truncate([
|
||||
'WorkflowTagMapping',
|
||||
'TagEntity',
|
||||
'Folder',
|
||||
'WorkflowEntity',
|
||||
'SharedWorkflow',
|
||||
'ProjectRelation',
|
||||
'Project',
|
||||
]);
|
||||
});
|
||||
|
||||
function tagFiles(entries: UnpackedEntry[]) {
|
||||
return entries.filter((entry) => entry.name.endsWith('/tag.json'));
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
describe('workflow package export — with tags', () => {
|
||||
let service: N8nPackagesService;
|
||||
|
||||
beforeAll(() => {
|
||||
service = Container.get(N8nPackagesService);
|
||||
});
|
||||
|
||||
it('bundles referenced tags, writes tagIds and catalogs them in manifest and requirements', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const workflow = await createWorkflow({ name: 'Tagged workflow' }, project);
|
||||
// Created in reverse of sorted order so the exact-order assertions pin the sort.
|
||||
const beta = await createTag({ name: 'beta' }, workflow);
|
||||
const alpha = await createTag({ name: 'alpha' }, workflow);
|
||||
|
||||
const emitSpy = vi.spyOn(Container.get(EventService), 'emit');
|
||||
try {
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.tags).toEqual([
|
||||
{ id: alpha.id, name: 'alpha', target: 'tags/alpha' },
|
||||
{ id: beta.id, name: 'beta', target: 'tags/beta' },
|
||||
]);
|
||||
expect(manifest.requirements!.tags).toEqual([
|
||||
{ id: alpha.id, name: 'alpha', usedByWorkflows: [workflow.id] },
|
||||
{ id: beta.id, name: 'beta', usedByWorkflows: [workflow.id] },
|
||||
]);
|
||||
|
||||
const serialized = workflowJson(entries, manifest.workflows![0].target);
|
||||
expect(serialized.tagIds).toEqual([alpha.id, beta.id]);
|
||||
|
||||
for (const entry of manifest.tags!) {
|
||||
const file = entries.find((e) => e.name === `${entry.target}/tag.json`);
|
||||
expect(file).toBeDefined();
|
||||
const parsed = jsonParse<Record<string, unknown>>(file!.content.toString());
|
||||
expect(parsed).toEqual({ id: entry.id, name: entry.name });
|
||||
}
|
||||
|
||||
const exportedEvents = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported');
|
||||
expect(exportedEvents).toHaveLength(1);
|
||||
const payload = exportedEvents[0][1] as RelayEventMap['n8n-package-exported'];
|
||||
expect(payload.counts.tags).toBe(2);
|
||||
} finally {
|
||||
emitSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('writes a tag shared by two workflows once', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const wfA = await createWorkflow({ name: 'Workflow A' }, project);
|
||||
const wfB = await createWorkflow({ name: 'Workflow B' }, project);
|
||||
const tag = await createTag({ name: 'shared' });
|
||||
await assignTagToWorkflow(tag, wfA);
|
||||
await assignTagToWorkflow(tag, wfB);
|
||||
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [wfA.id, wfB.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.tags).toEqual([{ id: tag.id, name: 'shared', target: 'tags/shared' }]);
|
||||
expect(tagFiles(entries)).toHaveLength(1);
|
||||
expect(manifest.requirements!.tags).toEqual([
|
||||
{ id: tag.id, name: 'shared', usedByWorkflows: [wfA.id, wfB.id] },
|
||||
]);
|
||||
|
||||
for (const entry of manifest.workflows!) {
|
||||
expect(workflowJson(entries, entry.target).tagIds).toEqual([tag.id]);
|
||||
}
|
||||
});
|
||||
|
||||
it('exports an untagged workflow without any tag artifacts', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const workflow = await createWorkflow({ name: 'Untagged workflow' }, project);
|
||||
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(workflowJson(entries, manifest.workflows![0].target)).not.toHaveProperty('tagIds');
|
||||
expect(tagFiles(entries)).toEqual([]);
|
||||
expect(manifest).not.toHaveProperty('tags');
|
||||
expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array) });
|
||||
});
|
||||
|
||||
it('with includeTags=false exports a tagged folder workflow without any tag artifacts', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const folder = await createFolder(project, { name: 'ops' });
|
||||
const workflow = await createWorkflow({ name: 'In folder', parentFolder: folder }, project);
|
||||
await createTag({ name: 'prod' }, workflow);
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
folderIds: [folder.id],
|
||||
includeTags: false,
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(workflowJson(entries, manifest.workflows![0].target)).not.toHaveProperty('tagIds');
|
||||
expect(tagFiles(entries)).toEqual([]);
|
||||
expect(manifest).not.toHaveProperty('tags');
|
||||
expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array) });
|
||||
});
|
||||
|
||||
it('writes tags at the package root for a project export', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('team-ligo', owner);
|
||||
const folder = await createFolder(project, { name: 'ops' });
|
||||
const workflow = await createWorkflow({ name: 'Deep workflow', parentFolder: folder }, project);
|
||||
const tag = await createTag({ name: 'prod' }, workflow);
|
||||
|
||||
const { stream } = await service.exportPackage({ user: owner, projectIds: [project.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.tags).toEqual([{ id: tag.id, name: 'prod', target: 'tags/prod' }]);
|
||||
expect(entries.find((e) => e.name === 'tags/prod/tag.json')).toBeDefined();
|
||||
|
||||
const workflowEntry = manifest.workflows!.find((entry) => entry.id === workflow.id)!;
|
||||
expect(workflowEntry.target).toMatch(/^projects\//);
|
||||
expect(workflowJson(entries, workflowEntry.target).tagIds).toEqual([tag.id]);
|
||||
});
|
||||
|
||||
it('bundles the tags of auto-included sub-workflows', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const sub = await createWorkflow({ name: 'Sub workflow' }, project);
|
||||
const tag = await createTag({ name: 'prod' }, sub);
|
||||
const parent = await buildWorkflowCallingSubWorkflow({
|
||||
name: 'Parent workflow',
|
||||
project,
|
||||
subWorkflowId: sub.id,
|
||||
});
|
||||
|
||||
const { stream } = await service.exportPackage({
|
||||
user: owner,
|
||||
workflowIds: [parent.id],
|
||||
missingWorkflowDependencyPolicy: 'include-in-package',
|
||||
});
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(manifest.tags).toEqual([{ id: tag.id, name: 'prod', target: 'tags/prod' }]);
|
||||
expect(manifest.requirements!.tags).toEqual([
|
||||
{ id: tag.id, name: 'prod', usedByWorkflows: [sub.id] },
|
||||
]);
|
||||
|
||||
const subEntry = manifest.workflows!.find((entry) => entry.id === sub.id)!;
|
||||
expect(workflowJson(entries, subEntry.target).tagIds).toEqual([tag.id]);
|
||||
});
|
||||
|
||||
it('skips tags silently when workflow tags are disabled on the instance', async () => {
|
||||
const owner = await createOwner();
|
||||
const project = await createTeamProject('Project A', owner);
|
||||
const workflow = await createWorkflow({ name: 'Tagged workflow' }, project);
|
||||
await createTag({ name: 'prod' }, workflow);
|
||||
|
||||
const globalConfig = Container.get(GlobalConfig);
|
||||
globalConfig.tags.disabled = true;
|
||||
try {
|
||||
const { stream } = await service.exportPackage({ user: owner, workflowIds: [workflow.id] });
|
||||
const { manifest, entries } = await readExport(stream);
|
||||
|
||||
expect(workflowJson(entries, manifest.workflows![0].target)).not.toHaveProperty('tagIds');
|
||||
expect(tagFiles(entries)).toEqual([]);
|
||||
expect(manifest).not.toHaveProperty('tags');
|
||||
expect(manifest.requirements).toEqual({ nodeTypes: expect.any(Array) });
|
||||
} finally {
|
||||
globalConfig.tags.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
it('imports a tag-bearing package as a no-op for tags', async () => {
|
||||
const owner = await createOwner();
|
||||
const source = await createTeamProject('Source project', owner);
|
||||
const workflow = await createWorkflow({ name: 'Tagged workflow' }, source);
|
||||
await createTag({ name: 'prod' }, workflow);
|
||||
const packageBuffer = await streamToBuffer(
|
||||
(await service.exportPackage({ user: owner, workflowIds: [workflow.id] })).stream,
|
||||
);
|
||||
|
||||
const target = await createTeamProject('Target project', owner);
|
||||
const result = await service.importPackage({
|
||||
user: owner,
|
||||
projectId: target.id,
|
||||
packageBuffer,
|
||||
credentialMatchingMode: 'id-only',
|
||||
credentialMissingMode: 'must-preexist',
|
||||
workflowConflictPolicy: 'fail',
|
||||
workflowPublishingPolicy: 'preserve-published-state',
|
||||
workflowIdPolicy: 'new',
|
||||
folderConflictPolicy: 'merge',
|
||||
dataTableMatchingMode: 'by-id',
|
||||
dataTableMissingMode: 'create',
|
||||
dataTableSchemaConflictPolicy: 'keep-existing',
|
||||
variableMissingMode: 'do-nothing',
|
||||
missingNodeTypeMode: 'fail',
|
||||
});
|
||||
|
||||
expect(result.workflows).toHaveLength(1);
|
||||
expect(result.workflows[0].status).toBe('created');
|
||||
|
||||
// Only the source tag and the source workflow's mapping remain — the import created neither.
|
||||
const mappings = await Container.get(WorkflowTagMappingRepository).find();
|
||||
expect(mappings).toHaveLength(1);
|
||||
expect(mappings[0].workflowId).toBe(workflow.id);
|
||||
expect(await Container.get(TagRepository).count()).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { WorkflowCredentialRequirement } from '../credential/credential.types';
|
||||
import type { WorkflowDataTableRequirement } from '../data-table/data-table.types';
|
||||
import { mergeRequirements } from '../requirements.types';
|
||||
import type { WorkflowTagUsage } from '../tag/tag.types';
|
||||
import type { WorkflowVariableRequirement } from '../variable/variable.types';
|
||||
import type { WorkflowNodeTypeSource } from '../workflow/node-type-usage';
|
||||
|
||||
@@ -21,6 +22,10 @@ function dataTable(dataTableId: string, workflowId: string): WorkflowDataTableRe
|
||||
return { workflowId, dataTableId };
|
||||
}
|
||||
|
||||
function tagUsage(tagId: string, workflowId: string): WorkflowTagUsage {
|
||||
return { workflowId, tag: { id: tagId, name: `Tag ${tagId}` } };
|
||||
}
|
||||
|
||||
function nodeTypeSource(workflowId: string): WorkflowNodeTypeSource {
|
||||
return { workflowId, nodes: [] };
|
||||
}
|
||||
@@ -32,12 +37,14 @@ describe('mergeRequirements', () => {
|
||||
credentials: [cred('c1', 'w1')],
|
||||
dataTables: [dataTable('dt1', 'w1')],
|
||||
variables: [variable('V1', 'w1')],
|
||||
tags: [tagUsage('t1', 'w1')],
|
||||
nodeTypes: [nodeTypeSource('w1')],
|
||||
},
|
||||
{
|
||||
credentials: [cred('c2', 'w2'), cred('c3', 'w3')],
|
||||
dataTables: [dataTable('dt2', 'w2')],
|
||||
variables: [variable('V2', 'w2')],
|
||||
tags: [tagUsage('t2', 'w2')],
|
||||
nodeTypes: [nodeTypeSource('w2')],
|
||||
},
|
||||
);
|
||||
@@ -45,6 +52,7 @@ describe('mergeRequirements', () => {
|
||||
expect(merged.credentials).toEqual([cred('c1', 'w1'), cred('c2', 'w2'), cred('c3', 'w3')]);
|
||||
expect(merged.dataTables).toEqual([dataTable('dt1', 'w1'), dataTable('dt2', 'w2')]);
|
||||
expect(merged.variables).toEqual([variable('V1', 'w1'), variable('V2', 'w2')]);
|
||||
expect(merged.tags).toEqual([tagUsage('t1', 'w1'), tagUsage('t2', 'w2')]);
|
||||
expect(merged.nodeTypes).toEqual([nodeTypeSource('w1'), nodeTypeSource('w2')]);
|
||||
});
|
||||
|
||||
@@ -55,6 +63,7 @@ describe('mergeRequirements', () => {
|
||||
credentials: [cred('c1', 'w1')],
|
||||
dataTables: [dataTable('dt1', 'w1')],
|
||||
variables: [variable('V1', 'w1')],
|
||||
tags: [tagUsage('t1', 'w1')],
|
||||
nodeTypes: [nodeTypeSource('w1')],
|
||||
},
|
||||
undefined,
|
||||
@@ -63,6 +72,7 @@ describe('mergeRequirements', () => {
|
||||
expect(merged.credentials).toEqual([cred('c1', 'w1')]);
|
||||
expect(merged.dataTables).toEqual([dataTable('dt1', 'w1')]);
|
||||
expect(merged.variables).toEqual([variable('V1', 'w1')]);
|
||||
expect(merged.tags).toEqual([tagUsage('t1', 'w1')]);
|
||||
expect(merged.nodeTypes).toEqual([nodeTypeSource('w1')]);
|
||||
});
|
||||
|
||||
@@ -71,6 +81,7 @@ describe('mergeRequirements', () => {
|
||||
credentials: [],
|
||||
dataTables: [],
|
||||
variables: [],
|
||||
tags: [],
|
||||
nodeTypes: [],
|
||||
});
|
||||
});
|
||||
|
||||
+9
-1
@@ -49,6 +49,7 @@ describe('FolderExporter', () => {
|
||||
user,
|
||||
folderIds: ['fld-1'],
|
||||
writer: new CapturingWriter(),
|
||||
includeTags: true,
|
||||
basePrefix: 'projects/team-ligo',
|
||||
});
|
||||
|
||||
@@ -71,6 +72,7 @@ describe('FolderExporter', () => {
|
||||
],
|
||||
dataTables: [],
|
||||
variables: [],
|
||||
tags: [],
|
||||
nodeTypes: [],
|
||||
},
|
||||
});
|
||||
@@ -79,6 +81,7 @@ describe('FolderExporter', () => {
|
||||
user,
|
||||
folderIds: ['fld-1'],
|
||||
writer: new CapturingWriter(),
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
// The folder's own target is passed as basePrefix, so workflows nest under it.
|
||||
@@ -106,7 +109,12 @@ describe('FolderExporter', () => {
|
||||
);
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, folderIds: ['fld-1'], writer: new CapturingWriter() }),
|
||||
exporter.export({
|
||||
user,
|
||||
folderIds: ['fld-1'],
|
||||
writer: new CapturingWriter(),
|
||||
includeTags: true,
|
||||
}),
|
||||
).rejects.toThrow(/not found or not accessible/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface FolderExportRequest {
|
||||
user: User;
|
||||
folderIds: string[];
|
||||
writer: PackageWriter;
|
||||
includeTags: boolean;
|
||||
/**
|
||||
* Directory the folder tree is written under. Empty for a top-level folder
|
||||
* export (`folders/...`); a project exporter passes `projects/<slug>` so the
|
||||
@@ -181,6 +182,7 @@ export class FolderExporter {
|
||||
user: request.user,
|
||||
writer: request.writer,
|
||||
workflowIds,
|
||||
includeTags: request.includeTags,
|
||||
basePrefix,
|
||||
});
|
||||
}
|
||||
|
||||
+21
-10
@@ -97,7 +97,7 @@ describe('ProjectExporter', () => {
|
||||
const { exporter, projectService } = makeExporter({ projects: [project] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await exporter.export({ user, projectIds: [project.id], writer });
|
||||
await exporter.export({ user, projectIds: [project.id], writer, includeTags: true });
|
||||
|
||||
expect(projectService.findProjectsByIdsForUser).toHaveBeenCalledWith(
|
||||
user,
|
||||
@@ -111,9 +111,9 @@ describe('ProjectExporter', () => {
|
||||
const { exporter } = makeExporter({ projects: [] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(exporter.export({ user, projectIds: [project.id], writer })).rejects.toThrow(
|
||||
'1 project(s) not found or not accessible. Export aborted.',
|
||||
);
|
||||
await expect(
|
||||
exporter.export({ user, projectIds: [project.id], writer, includeTags: true }),
|
||||
).rejects.toThrow('1 project(s) not found or not accessible. Export aborted.');
|
||||
});
|
||||
|
||||
it('throws PackageEntityNotFoundError when the missing project does not exist at all', async () => {
|
||||
@@ -121,9 +121,9 @@ describe('ProjectExporter', () => {
|
||||
projectService.findExistingProjectIds.mockResolvedValue(new Set());
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(exporter.export({ user, projectIds: ['missing'], writer })).rejects.toBeInstanceOf(
|
||||
PackageEntityNotFoundError,
|
||||
);
|
||||
await expect(
|
||||
exporter.export({ user, projectIds: ['missing'], writer, includeTags: true }),
|
||||
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
|
||||
});
|
||||
|
||||
it('throws PackageEntityAccessDeniedError when the missing project exists but is inaccessible', async () => {
|
||||
@@ -132,7 +132,7 @@ describe('ProjectExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, projectIds: ['denied-1'], writer }),
|
||||
exporter.export({ user, projectIds: ['denied-1'], writer, includeTags: true }),
|
||||
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
|
||||
});
|
||||
|
||||
@@ -141,7 +141,12 @@ describe('ProjectExporter', () => {
|
||||
const { exporter } = makeExporter({ projects: [project] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const { entries } = await exporter.export({ user, projectIds: [project.id], writer });
|
||||
const { entries } = await exporter.export({
|
||||
user,
|
||||
projectIds: [project.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
@@ -179,6 +184,7 @@ describe('ProjectExporter', () => {
|
||||
user,
|
||||
projectIds: [newerProject.id, olderProject.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -200,7 +206,12 @@ describe('ProjectExporter', () => {
|
||||
const { exporter } = makeExporter({ projects: [project] });
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const { entries } = await exporter.export({ user, projectIds: [project.id], writer });
|
||||
const { entries } = await exporter.export({
|
||||
user,
|
||||
projectIds: [project.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
{
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface ProjectExportRequest {
|
||||
user: User;
|
||||
projectIds: string[];
|
||||
writer: PackageWriter;
|
||||
includeTags: boolean;
|
||||
}
|
||||
|
||||
interface ProjectExportResult {
|
||||
@@ -109,6 +110,7 @@ export class ProjectExporter {
|
||||
user: request.user,
|
||||
folderIds,
|
||||
writer: request.writer,
|
||||
includeTags: request.includeTags,
|
||||
basePrefix: target,
|
||||
});
|
||||
}
|
||||
@@ -127,6 +129,7 @@ export class ProjectExporter {
|
||||
user: request.user,
|
||||
workflowIds: rootWorkflowIds,
|
||||
writer: request.writer,
|
||||
includeTags: request.includeTags,
|
||||
basePrefix: target,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { WorkflowCredentialRequirement } from './credential/credential.types';
|
||||
import type { WorkflowDataTableRequirement } from './data-table/data-table.types';
|
||||
import type { WorkflowTagUsage } from './tag/tag.types';
|
||||
import type { WorkflowVariableRequirement } from './variable/variable.types';
|
||||
import type { WorkflowNodeTypeSource } from './workflow/node-type-usage';
|
||||
|
||||
@@ -7,6 +8,7 @@ export interface WorkflowExportRequirements {
|
||||
credentials: WorkflowCredentialRequirement[];
|
||||
dataTables: WorkflowDataTableRequirement[];
|
||||
variables: WorkflowVariableRequirement[];
|
||||
tags: WorkflowTagUsage[];
|
||||
/** Per-workflow node lists; folded into unique pairs at manifest-assembly time. */
|
||||
nodeTypes: WorkflowNodeTypeSource[];
|
||||
}
|
||||
@@ -17,5 +19,6 @@ export const mergeRequirements = (
|
||||
credentials: parts.flatMap((part) => part?.credentials ?? []),
|
||||
dataTables: parts.flatMap((part) => part?.dataTables ?? []),
|
||||
variables: parts.flatMap((part) => part?.variables ?? []),
|
||||
tags: parts.flatMap((part) => part?.tags ?? []),
|
||||
nodeTypes: parts.flatMap((part) => part?.nodeTypes ?? []),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { WorkflowEntity } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { WorkflowTagUsage } from './tag.types';
|
||||
import type { RequirementsExtractor } from '../requirements-extractor';
|
||||
|
||||
@Service()
|
||||
export class TagRequirementsExtractor implements RequirementsExtractor<WorkflowTagUsage> {
|
||||
extract(workflow: WorkflowEntity): WorkflowTagUsage[] {
|
||||
return (workflow.tags ?? []).map((tag) => ({
|
||||
workflowId: workflow.id,
|
||||
tag: { id: tag.id, name: tag.name },
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Service } from '@n8n/di';
|
||||
|
||||
import type { PackageWriter } from '../../io/package-writer';
|
||||
import { UniqueFilenameAllocator } from '../../io/unique-filename-allocator';
|
||||
import type { ManifestEntry } from '../../spec/manifest.schema';
|
||||
import type { PackageTagRequirement } from '../../spec/requirements.schema';
|
||||
import { serializedTagSchema } from '../../spec/serialized/tag.schema';
|
||||
import { compareTagsByName, type WorkflowTagUsage } from './tag.types';
|
||||
|
||||
export interface TagExportRequest {
|
||||
usages: WorkflowTagUsage[];
|
||||
writer: PackageWriter;
|
||||
}
|
||||
|
||||
export interface TagExportResult {
|
||||
entries: ManifestEntry[];
|
||||
requirements: PackageTagRequirement[];
|
||||
}
|
||||
|
||||
@Service()
|
||||
export class TagExporter {
|
||||
export(request: TagExportRequest): TagExportResult {
|
||||
const requirementsByTagId = new Map<string, PackageTagRequirement>();
|
||||
|
||||
for (const { workflowId, tag } of request.usages) {
|
||||
const requirement = requirementsByTagId.get(tag.id) ?? {
|
||||
id: tag.id,
|
||||
name: tag.name,
|
||||
usedByWorkflows: [],
|
||||
};
|
||||
if (!requirement.usedByWorkflows.includes(workflowId)) {
|
||||
requirement.usedByWorkflows.push(workflowId);
|
||||
}
|
||||
requirementsByTagId.set(tag.id, requirement);
|
||||
}
|
||||
|
||||
const requirements = [...requirementsByTagId.values()].sort(compareTagsByName);
|
||||
|
||||
const allocator = new UniqueFilenameAllocator('tags', 'tag');
|
||||
const entries: ManifestEntry[] = [];
|
||||
|
||||
for (const { id, name } of requirements) {
|
||||
const tagDirectory = allocator.allocate(name);
|
||||
const serializedTag = serializedTagSchema.parse({ id, name });
|
||||
request.writer.writeDirectory(tagDirectory);
|
||||
request.writer.writeFile(
|
||||
`${tagDirectory}/tag.json`,
|
||||
JSON.stringify(serializedTag, null, '\t'),
|
||||
);
|
||||
entries.push({ id, name, target: tagDirectory });
|
||||
}
|
||||
|
||||
return { entries, requirements };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export interface WorkflowTagUsage {
|
||||
workflowId: string;
|
||||
tag: { id: string; name: string };
|
||||
}
|
||||
|
||||
/** Locale pinned so package output is byte-stable across environments. */
|
||||
export const compareTagsByName = (
|
||||
a: { id: string; name: string },
|
||||
b: { id: string; name: string },
|
||||
) => a.name.localeCompare(b.name, 'en') || a.id.localeCompare(b.id, 'en');
|
||||
+2
-1
@@ -104,6 +104,7 @@ function resolveInput(options: {
|
||||
folderWorkflowIds: options.folderWorkflowIds ?? [],
|
||||
projectWorkflowIds: options.projectWorkflowIds ?? [],
|
||||
requirements: options.requirements,
|
||||
includeTags: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -333,7 +334,7 @@ describe('AutoIncludedWorkflowResolver', () => {
|
||||
['b'],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true },
|
||||
{ includeParentFolder: true, includeTags: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+3
@@ -9,6 +9,7 @@ import { CredentialRequirementsExtractor } from '../../credential/credential-req
|
||||
import { DataTableRequirementsExtractor } from '../../data-table/data-table-requirements.extractor';
|
||||
import { FolderSerializer } from '../../folder/folder.serializer';
|
||||
import { ProjectSerializer } from '../../project/project.serializer';
|
||||
import { TagRequirementsExtractor } from '../../tag/tag-requirements.extractor';
|
||||
import { VariableRequirementsExtractor } from '../../variable/variable-requirements.extractor';
|
||||
import type { AutoIncludedWorkflow } from '../auto-included-workflow-resolver';
|
||||
import { AutoIncludedWorkflowExporter } from '../auto-included-workflow.exporter';
|
||||
@@ -59,6 +60,7 @@ function makeExporter(
|
||||
credentialExtractor ?? new CredentialRequirementsExtractor(),
|
||||
dataTableExtractor ?? new DataTableRequirementsExtractor(),
|
||||
variableExtractor ?? new VariableRequirementsExtractor(),
|
||||
new TagRequirementsExtractor(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,6 +71,7 @@ function emptyRequest(writer: CapturingWriter, workflows: AutoIncludedWorkflow[]
|
||||
existingWorkflowEntries: [] as ManifestEntry[],
|
||||
existingFolderEntries: [] as ManifestEntry[],
|
||||
existingProjectEntries: [] as ManifestEntry[],
|
||||
includeTags: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+22
-7
@@ -14,6 +14,7 @@ import {
|
||||
PackageEntityAccessDeniedError,
|
||||
PackageEntityNotFoundError,
|
||||
} from '../../package-export.errors';
|
||||
import { TagRequirementsExtractor } from '../../tag/tag-requirements.extractor';
|
||||
import { VariableRequirementsExtractor } from '../../variable/variable-requirements.extractor';
|
||||
import type { WorkflowVariableRequirement } from '../../variable/variable.types';
|
||||
import { WorkflowExporter } from '../workflow.exporter';
|
||||
@@ -51,6 +52,7 @@ function makeExporter(
|
||||
credentialExtractor ?? new CredentialRequirementsExtractor(),
|
||||
dataTableExtractor ?? new DataTableRequirementsExtractor(),
|
||||
variableExtractor ?? new VariableRequirementsExtractor(),
|
||||
new TagRequirementsExtractor(),
|
||||
);
|
||||
return { exporter, finder };
|
||||
}
|
||||
@@ -61,13 +63,13 @@ describe('WorkflowExporter', () => {
|
||||
const { exporter, finder } = makeExporter([workflow]);
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await exporter.export({ user, workflowIds: [workflow.id], writer });
|
||||
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
|
||||
|
||||
expect(finder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(
|
||||
[workflow.id],
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true },
|
||||
{ includeParentFolder: true, includeTags: true },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -81,6 +83,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: ['present-1', 'missing-or-denied'],
|
||||
writer,
|
||||
includeTags: true,
|
||||
}),
|
||||
).rejects.toThrow('1 workflow(s) not found or not accessible. Export aborted.');
|
||||
});
|
||||
@@ -92,7 +95,7 @@ describe('WorkflowExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer }),
|
||||
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
|
||||
).rejects.toBeInstanceOf(PackageEntityNotFoundError);
|
||||
});
|
||||
|
||||
@@ -103,7 +106,7 @@ describe('WorkflowExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, workflowIds: ['present-1', 'denied-1'], writer }),
|
||||
exporter.export({ user, workflowIds: ['present-1', 'denied-1'], writer, includeTags: true }),
|
||||
).rejects.toBeInstanceOf(PackageEntityAccessDeniedError);
|
||||
});
|
||||
|
||||
@@ -113,7 +116,7 @@ describe('WorkflowExporter', () => {
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await expect(
|
||||
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer }),
|
||||
exporter.export({ user, workflowIds: ['present-1', 'missing'], writer, includeTags: true }),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(finder.findExistingWorkflowIds).toHaveBeenCalledWith(['missing']);
|
||||
@@ -128,6 +131,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: [workflow.id, workflow.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(entries).toEqual([
|
||||
@@ -148,6 +152,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(entries.map(({ id }) => id)).toEqual([a.id, b.id]);
|
||||
@@ -176,7 +181,7 @@ describe('WorkflowExporter', () => {
|
||||
const { exporter } = makeExporter([workflow]);
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
await exporter.export({ user, workflowIds: [workflow.id], writer });
|
||||
await exporter.export({ user, workflowIds: [workflow.id], writer, includeTags: true });
|
||||
|
||||
const workflowFile = writer.files.find((f) => f.path === 'workflows/my-workflow/workflow.json');
|
||||
expect(workflowFile).toBeDefined();
|
||||
@@ -202,6 +207,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: [workflow.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
basePrefix: 'folders/in_progress',
|
||||
});
|
||||
|
||||
@@ -217,7 +223,12 @@ describe('WorkflowExporter', () => {
|
||||
const { exporter } = makeExporter([a, b]);
|
||||
const writer = new CapturingWriter();
|
||||
|
||||
const { entries } = await exporter.export({ user, workflowIds: [a.id, b.id], writer });
|
||||
const { entries } = await exporter.export({
|
||||
user,
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
const targets = entries.map((e) => e.target);
|
||||
expect(targets).toEqual(['workflows/same-name', 'workflows/same-name-2']);
|
||||
@@ -248,6 +259,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(extractor.extract).toHaveBeenCalledTimes(2);
|
||||
@@ -281,6 +293,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(extractor.extract).toHaveBeenCalledTimes(2);
|
||||
@@ -304,6 +317,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(extractor.extract).toHaveBeenCalledTimes(2);
|
||||
@@ -331,6 +345,7 @@ describe('WorkflowExporter', () => {
|
||||
user,
|
||||
workflowIds: [a.id, b.id],
|
||||
writer,
|
||||
includeTags: true,
|
||||
});
|
||||
|
||||
expect(requirements.nodeTypes).toEqual([
|
||||
|
||||
+10
-2
@@ -40,6 +40,7 @@ export class AutoIncludedWorkflowResolver {
|
||||
topLevelWorkflowIds: string[];
|
||||
folderWorkflowIds: string[];
|
||||
projectWorkflowIds: string[];
|
||||
includeTags: boolean;
|
||||
}): Promise<AutoIncludedWorkflowResolution> {
|
||||
const originsByWorkflowId = this.seedExportedOrigins({
|
||||
topLevelWorkflowIds: options.topLevelWorkflowIds,
|
||||
@@ -58,6 +59,7 @@ export class AutoIncludedWorkflowResolver {
|
||||
user: options.user,
|
||||
workflowIds: autoIncludedWorkflowIds,
|
||||
originsByWorkflowId,
|
||||
includeTags: options.includeTags,
|
||||
});
|
||||
|
||||
return { autoIncludedWorkflows };
|
||||
@@ -145,10 +147,15 @@ export class AutoIncludedWorkflowResolver {
|
||||
user: User;
|
||||
workflowIds: string[];
|
||||
originsByWorkflowId: Map<string, Set<WorkflowExportOrigin>>;
|
||||
includeTags: boolean;
|
||||
}): Promise<AutoIncludedWorkflow[]> {
|
||||
if (options.workflowIds.length === 0) return [];
|
||||
|
||||
const workflows = await this.findExportableWorkflows(options.user, options.workflowIds);
|
||||
const workflows = await this.findExportableWorkflows(
|
||||
options.user,
|
||||
options.workflowIds,
|
||||
options.includeTags,
|
||||
);
|
||||
const workflowsById = new Map(workflows.map((workflow) => [workflow.id, workflow]));
|
||||
const ownersByWorkflowId = await this.sharedWorkflowRepository.findOwnerProjectsByWorkflowIds(
|
||||
options.workflowIds,
|
||||
@@ -221,12 +228,13 @@ export class AutoIncludedWorkflowResolver {
|
||||
private async findExportableWorkflows(
|
||||
user: User,
|
||||
workflowIds: string[],
|
||||
includeTags: boolean,
|
||||
): Promise<WorkflowEntity[]> {
|
||||
const workflows = await this.workflowFinder.findWorkflowsByIdsForUser(
|
||||
workflowIds,
|
||||
user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true },
|
||||
{ includeParentFolder: true, includeTags },
|
||||
);
|
||||
|
||||
await assertEveryRequestedEntityAccessible(
|
||||
|
||||
+10
-2
@@ -15,6 +15,8 @@ import type { WorkflowDataTableRequirement } from '../data-table/data-table.type
|
||||
import { FolderSerializer } from '../folder/folder.serializer';
|
||||
import { ProjectSerializer } from '../project/project.serializer';
|
||||
import type { WorkflowExportRequirements } from '../requirements.types';
|
||||
import { TagRequirementsExtractor } from '../tag/tag-requirements.extractor';
|
||||
import type { WorkflowTagUsage } from '../tag/tag.types';
|
||||
import { VariableRequirementsExtractor } from '../variable/variable-requirements.extractor';
|
||||
import type { WorkflowVariableRequirement } from '../variable/variable.types';
|
||||
|
||||
@@ -24,6 +26,7 @@ export interface AutoIncludedWorkflowExportRequest {
|
||||
existingWorkflowEntries: ManifestEntry[];
|
||||
existingFolderEntries: ManifestEntry[];
|
||||
existingProjectEntries: ManifestEntry[];
|
||||
includeTags: boolean;
|
||||
projectTargetsById?: Map<string, string>;
|
||||
}
|
||||
|
||||
@@ -54,6 +57,7 @@ export class AutoIncludedWorkflowExporter {
|
||||
private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor,
|
||||
private readonly dataTableRequirementsExtractor: DataTableRequirementsExtractor,
|
||||
private readonly variableRequirementsExtractor: VariableRequirementsExtractor,
|
||||
private readonly tagRequirementsExtractor: TagRequirementsExtractor,
|
||||
) {}
|
||||
|
||||
export(request: AutoIncludedWorkflowExportRequest): AutoIncludedWorkflowExportResult {
|
||||
@@ -93,6 +97,7 @@ export class AutoIncludedWorkflowExporter {
|
||||
const credentials: WorkflowCredentialRequirement[] = [];
|
||||
const dataTables: WorkflowDataTableRequirement[] = [];
|
||||
const variables: WorkflowVariableRequirement[] = [];
|
||||
const tags: WorkflowTagUsage[] = [];
|
||||
const nodeTypes: WorkflowNodeTypeSource[] = [];
|
||||
|
||||
for (const included of request.workflows) {
|
||||
@@ -113,12 +118,14 @@ export class AutoIncludedWorkflowExporter {
|
||||
baseDir,
|
||||
request.writer,
|
||||
allocators.workflows,
|
||||
request.includeTags,
|
||||
);
|
||||
workflowEntries.push(entry);
|
||||
workflowEntriesById.set(entry.id, entry);
|
||||
credentials.push(...this.credentialRequirementsExtractor.extract(included.workflow));
|
||||
dataTables.push(...this.dataTableRequirementsExtractor.extract(included.workflow));
|
||||
variables.push(...this.variableRequirementsExtractor.extract(included.workflow));
|
||||
tags.push(...this.tagRequirementsExtractor.extract(included.workflow));
|
||||
nodeTypes.push({
|
||||
workflowId: included.workflow.id,
|
||||
nodes: included.workflow.nodes ?? [],
|
||||
@@ -129,7 +136,7 @@ export class AutoIncludedWorkflowExporter {
|
||||
workflowEntries,
|
||||
folderEntries,
|
||||
projectEntries,
|
||||
requirements: { credentials, dataTables, variables, nodeTypes },
|
||||
requirements: { credentials, dataTables, variables, tags, nodeTypes },
|
||||
projectTargetsById,
|
||||
};
|
||||
}
|
||||
@@ -261,9 +268,10 @@ export class AutoIncludedWorkflowExporter {
|
||||
baseDir: string,
|
||||
writer: PackageWriter,
|
||||
allocators: Map<string, UniqueFilenameAllocator>,
|
||||
includeTags: boolean,
|
||||
): ManifestEntry {
|
||||
const target = allocatorFor(allocators, baseDir, 'workflow').allocate(workflow.name);
|
||||
const serialized = this.workflowSerializer.serialize(workflow);
|
||||
const serialized = this.workflowSerializer.serialize(workflow, { includeTags });
|
||||
writer.writeDirectory(target);
|
||||
writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
|
||||
return { id: workflow.id, name: workflow.name, target };
|
||||
|
||||
@@ -14,6 +14,8 @@ import type { WorkflowDataTableRequirement } from '../data-table/data-table.type
|
||||
import type { WorkflowNodeTypeSource } from './node-type-usage';
|
||||
import { assertEveryRequestedEntityAccessible } from '../package-export.errors';
|
||||
import type { WorkflowExportRequirements } from '../requirements.types';
|
||||
import { TagRequirementsExtractor } from '../tag/tag-requirements.extractor';
|
||||
import type { WorkflowTagUsage } from '../tag/tag.types';
|
||||
import { VariableRequirementsExtractor } from '../variable/variable-requirements.extractor';
|
||||
import type { WorkflowVariableRequirement } from '../variable/variable.types';
|
||||
|
||||
@@ -21,6 +23,7 @@ export interface WorkflowExportRequest {
|
||||
user: User;
|
||||
workflowIds: string[];
|
||||
writer: PackageWriter;
|
||||
includeTags: boolean;
|
||||
|
||||
// Directory the workflow is written under. e.g. folders/{folderId}/
|
||||
basePrefix?: string;
|
||||
@@ -39,6 +42,7 @@ export class WorkflowExporter {
|
||||
private readonly credentialRequirementsExtractor: CredentialRequirementsExtractor,
|
||||
private readonly dataTableRequirementsExtractor: DataTableRequirementsExtractor,
|
||||
private readonly variableRequirementsExtractor: VariableRequirementsExtractor,
|
||||
private readonly tagRequirementsExtractor: TagRequirementsExtractor,
|
||||
) {}
|
||||
|
||||
async export(request: WorkflowExportRequest): Promise<WorkflowExportResult> {
|
||||
@@ -46,7 +50,7 @@ export class WorkflowExporter {
|
||||
request.workflowIds,
|
||||
request.user,
|
||||
['workflow:export'],
|
||||
{ includeParentFolder: true },
|
||||
{ includeParentFolder: true, includeTags: request.includeTags },
|
||||
);
|
||||
|
||||
await assertEveryRequestedEntityAccessible(
|
||||
@@ -61,6 +65,7 @@ export class WorkflowExporter {
|
||||
const credentials: WorkflowCredentialRequirement[] = [];
|
||||
const dataTables: WorkflowDataTableRequirement[] = [];
|
||||
const variables: WorkflowVariableRequirement[] = [];
|
||||
const tags: WorkflowTagUsage[] = [];
|
||||
const nodeTypes: WorkflowNodeTypeSource[] = [];
|
||||
const fileNames = new UniqueFilenameAllocator(
|
||||
request.basePrefix ? `${request.basePrefix}/workflows` : 'workflows',
|
||||
@@ -69,7 +74,9 @@ export class WorkflowExporter {
|
||||
|
||||
for (const workflow of workflowsForExport) {
|
||||
const target = fileNames.allocate(workflow.name);
|
||||
const serialized = this.workflowSerializer.serialize(workflow);
|
||||
const serialized = this.workflowSerializer.serialize(workflow, {
|
||||
includeTags: request.includeTags,
|
||||
});
|
||||
|
||||
request.writer.writeDirectory(target);
|
||||
request.writer.writeFile(`${target}/workflow.json`, JSON.stringify(serialized, null, '\t'));
|
||||
@@ -83,10 +90,11 @@ export class WorkflowExporter {
|
||||
credentials.push(...this.credentialRequirementsExtractor.extract(workflow));
|
||||
dataTables.push(...this.dataTableRequirementsExtractor.extract(workflow));
|
||||
variables.push(...this.variableRequirementsExtractor.extract(workflow));
|
||||
tags.push(...this.tagRequirementsExtractor.extract(workflow));
|
||||
nodeTypes.push({ workflowId: workflow.id, nodes: workflow.nodes ?? [] });
|
||||
}
|
||||
|
||||
return { entries, requirements: { credentials, dataTables, variables, nodeTypes } };
|
||||
return { entries, requirements: { credentials, dataTables, variables, tags, nodeTypes } };
|
||||
}
|
||||
|
||||
private orderWorkflowsByRequest(
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
serializedWorkflowSchema,
|
||||
type SerializedWorkflow,
|
||||
} from '../../spec/serialized/workflow.schema';
|
||||
import { compareTagsByName } from '../tag/tag.types';
|
||||
|
||||
/** Fields restored from package workflow.json; the target instance assigns the rest. */
|
||||
type WorkflowPackageContent = Pick<
|
||||
@@ -15,7 +16,12 @@ type WorkflowPackageContent = Pick<
|
||||
|
||||
@Service()
|
||||
export class WorkflowSerializer {
|
||||
serialize(workflow: WorkflowEntity): SerializedWorkflow {
|
||||
serialize(workflow: WorkflowEntity, options: { includeTags: boolean }): SerializedWorkflow {
|
||||
const tags =
|
||||
options.includeTags && workflow.tags?.length
|
||||
? [...workflow.tags].sort(compareTagsByName)
|
||||
: undefined;
|
||||
|
||||
return serializedWorkflowSchema.parse({
|
||||
id: workflow.id,
|
||||
name: workflow.name,
|
||||
@@ -26,6 +32,7 @@ export class WorkflowSerializer {
|
||||
parentFolderId: workflow.parentFolder?.id ?? null,
|
||||
isPublished: workflow.activeVersionId === workflow.versionId,
|
||||
isArchived: workflow.isArchived,
|
||||
...(tags ? { tagIds: tags.map((tag) => tag.id) } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import { Service } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
|
||||
@@ -15,6 +16,7 @@ import { FolderExporter } from './entities/folder/folder.exporter';
|
||||
import { PackageExportBlockedError } from './entities/package-export.errors';
|
||||
import { ProjectExporter } from './entities/project/project.exporter';
|
||||
import { mergeRequirements } from './entities/requirements.types';
|
||||
import { TagExporter } from './entities/tag/tag.exporter';
|
||||
import { VariableExporter } from './entities/variable/variable.exporter';
|
||||
import { collectNodeTypeUsage } from './entities/workflow/node-type-usage';
|
||||
import { assertStaticSubWorkflowsIncluded } from './entities/workflow/static-sub-workflow-requirements';
|
||||
@@ -54,6 +56,8 @@ export class N8nPackagesService {
|
||||
private readonly credentialExporter: CredentialExporter,
|
||||
private readonly dataTableExporter: DataTableExporter,
|
||||
private readonly variableExporter: VariableExporter,
|
||||
private readonly tagExporter: TagExporter,
|
||||
private readonly globalConfig: GlobalConfig,
|
||||
private readonly instanceSettings: InstanceSettings,
|
||||
private readonly packageParser: N8nPackageParser,
|
||||
private readonly packageImportConfig: PackageImportConfig,
|
||||
@@ -79,6 +83,7 @@ export class N8nPackagesService {
|
||||
const workflowIds = request.workflowIds ?? [];
|
||||
const folderIds = request.folderIds ?? [];
|
||||
const projectIds = request.projectIds ?? [];
|
||||
const includeTags = (request.includeTags ?? true) && !this.globalConfig.tags.disabled;
|
||||
|
||||
const folderExportResult =
|
||||
folderIds.length > 0
|
||||
@@ -86,6 +91,7 @@ export class N8nPackagesService {
|
||||
user: request.user,
|
||||
folderIds,
|
||||
writer,
|
||||
includeTags,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -100,6 +106,7 @@ export class N8nPackagesService {
|
||||
user: request.user,
|
||||
workflowIds: workflowsForExport,
|
||||
writer,
|
||||
includeTags,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -109,6 +116,7 @@ export class N8nPackagesService {
|
||||
user: request.user,
|
||||
projectIds,
|
||||
writer,
|
||||
includeTags,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
@@ -137,6 +145,7 @@ export class N8nPackagesService {
|
||||
topLevelWorkflowIds: workflowExportResult?.entries.map(({ id }) => id) ?? [],
|
||||
folderWorkflowIds: folderExportResult?.workflowEntries.map(({ id }) => id) ?? [],
|
||||
projectWorkflowIds: projectExportResult?.workflowEntries.map(({ id }) => id) ?? [],
|
||||
includeTags,
|
||||
});
|
||||
|
||||
autoIncludedExportResult = this.autoIncludedWorkflowExporter.export({
|
||||
@@ -145,6 +154,7 @@ export class N8nPackagesService {
|
||||
existingWorkflowEntries: allWorkflowsBeforeAutoInclude,
|
||||
existingFolderEntries: allFoldersBeforeAutoInclude,
|
||||
existingProjectEntries: allProjectsBeforeAutoInclude,
|
||||
includeTags,
|
||||
projectTargetsById: projectExportResult?.projectTargetsById,
|
||||
});
|
||||
}
|
||||
@@ -219,11 +229,17 @@ export class N8nPackagesService {
|
||||
projectTargetsById,
|
||||
});
|
||||
|
||||
const tagExportResult = this.tagExporter.export({
|
||||
usages: requirements.tags,
|
||||
writer,
|
||||
});
|
||||
|
||||
const manifestRequirements = this.buildManifestRequirements({
|
||||
credentials: credentialExportResult.requirements,
|
||||
dataTables: dataTableExportResult.requirements,
|
||||
workflows: workflowRequirementExportResult.requirements,
|
||||
variables: variableExportResult.requirements,
|
||||
tags: tagExportResult.requirements,
|
||||
nodeTypes: collectNodeTypeUsage(requirements.nodeTypes),
|
||||
});
|
||||
|
||||
@@ -241,6 +257,7 @@ export class N8nPackagesService {
|
||||
...(variableExportResult.entries.length > 0
|
||||
? { variables: variableExportResult.entries }
|
||||
: {}),
|
||||
...(tagExportResult.entries.length > 0 ? { tags: tagExportResult.entries } : {}),
|
||||
...(manifestRequirements ? { requirements: manifestRequirements } : {}),
|
||||
...(allWorkflowsInPackage.length > 0 ? { workflows: allWorkflowsInPackage } : {}),
|
||||
...(allFolders.length > 0 ? { folders: allFolders } : {}),
|
||||
@@ -257,6 +274,7 @@ export class N8nPackagesService {
|
||||
credentials: credentialExportResult.entries.length,
|
||||
dataTables: dataTableExportResult.entries.length,
|
||||
variables: variableExportResult.entries.length,
|
||||
tags: tagExportResult.entries.length,
|
||||
};
|
||||
|
||||
this.eventService.emit('n8n-package-exported', {
|
||||
@@ -306,15 +324,17 @@ export class N8nPackagesService {
|
||||
dataTables: PackageRequirements['dataTables'];
|
||||
workflows: PackageRequirements['workflows'];
|
||||
variables: PackageRequirements['variables'];
|
||||
tags: PackageRequirements['tags'];
|
||||
nodeTypes: PackageRequirements['nodeTypes'];
|
||||
}): PackageRequirements | undefined {
|
||||
const { credentials, dataTables, workflows, variables, nodeTypes } = input;
|
||||
const { credentials, dataTables, workflows, variables, tags, nodeTypes } = input;
|
||||
|
||||
const requirements: PackageRequirements = {
|
||||
...(credentials?.length ? { credentials } : {}),
|
||||
...(dataTables?.length ? { dataTables } : {}),
|
||||
...(workflows?.length ? { workflows } : {}),
|
||||
...(variables?.length ? { variables } : {}),
|
||||
...(tags?.length ? { tags } : {}),
|
||||
...(nodeTypes?.length ? { nodeTypes } : {}),
|
||||
};
|
||||
return Object.keys(requirements).length > 0 ? requirements : undefined;
|
||||
|
||||
@@ -132,6 +132,7 @@ export interface ExportPackageRequest {
|
||||
projectIds?: string[];
|
||||
includeVariableValues?: boolean;
|
||||
canExportVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: MissingWorkflowDependencyPolicy;
|
||||
}
|
||||
|
||||
@@ -235,6 +236,7 @@ export type ExportPackageEventCounts = {
|
||||
credentials: number;
|
||||
dataTables: number;
|
||||
variables: number;
|
||||
tags: number;
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -142,6 +142,18 @@ describe('packageManifestSchema', () => {
|
||||
expect(packageManifestSchema.parse(manifest).variables).toEqual(manifest.variables);
|
||||
});
|
||||
|
||||
it('rejects a manifest containing duplicate tag ids', () => {
|
||||
const manifest = {
|
||||
...validManifest,
|
||||
tags: [
|
||||
{ id: 'tag-1', name: 'production', target: 'tags/production' },
|
||||
{ id: 'tag-1', name: 'production', target: 'tags/production-2' },
|
||||
],
|
||||
};
|
||||
|
||||
expect(() => packageManifestSchema.parse(manifest)).toThrow(/duplicate tag id/i);
|
||||
});
|
||||
|
||||
it('accepts manifests with unknown sections for forward compatibility', () => {
|
||||
const manifest = {
|
||||
...validManifest,
|
||||
|
||||
@@ -32,6 +32,13 @@ describe('packageRequirementsSchema', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects duplicate tag ids', () => {
|
||||
const tag = (id: string) => ({ id, name: 'production', usedByWorkflows: ['wf-1'] });
|
||||
const requirements = { tags: [tag('tag-1'), tag('tag-1')] };
|
||||
|
||||
expect(() => packageRequirementsSchema.parse(requirements)).toThrow(/Duplicate tag id: tag-1/);
|
||||
});
|
||||
|
||||
it('rejects duplicate variable names', () => {
|
||||
const requirements = { variables: [variable('API_URL'), variable('API_URL')] };
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ export const packageManifestSchema = z
|
||||
credentials: z.array(manifestEntrySchema).optional(),
|
||||
dataTables: z.array(manifestEntrySchema).optional(),
|
||||
variables: z.array(manifestEntrySchema).optional(),
|
||||
tags: z.array(manifestEntrySchema).optional(),
|
||||
requirements: packageRequirementsSchema.optional(),
|
||||
})
|
||||
.superRefine((manifest, ctx) => {
|
||||
@@ -50,6 +51,7 @@ export const packageManifestSchema = z
|
||||
assertNoDuplicateIds(manifest.credentials, 'credential', ctx);
|
||||
assertNoDuplicateIds(manifest.dataTables, 'data table', ctx);
|
||||
assertNoDuplicateIds(manifest.variables, 'variable', ctx);
|
||||
assertNoDuplicateIds(manifest.tags, 'tag', ctx);
|
||||
});
|
||||
|
||||
export type ManifestEntry = z.infer<typeof manifestEntrySchema>;
|
||||
|
||||
@@ -19,6 +19,12 @@ export const packageWorkflowRequirementSchema = z.object({
|
||||
usedByWorkflows: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
|
||||
export const packageTagRequirementSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
usedByWorkflows: z.array(z.string().min(1)).min(1),
|
||||
});
|
||||
|
||||
// Node types used by the packaged workflows, folded into unique
|
||||
// `(type, typeVersion)` pairs. Informational/derived only: import re-derives
|
||||
// node type usage from workflow content and never trusts this section.
|
||||
@@ -84,6 +90,10 @@ export const packageRequirementsSchema = z.object({
|
||||
.superRefine((variables, ctx) =>
|
||||
assertNoDuplicateKey(variables, ({ name }) => name, 'variable name', ctx),
|
||||
),
|
||||
tags: z
|
||||
.array(packageTagRequirementSchema)
|
||||
.optional()
|
||||
.superRefine((tags, ctx) => assertNoDuplicateKey(tags, ({ id }) => id, 'tag id', ctx)),
|
||||
nodeTypes: z
|
||||
.array(packageNodeTypeRequirementSchema)
|
||||
.optional()
|
||||
@@ -100,6 +110,7 @@ export const packageRequirementsSchema = z.object({
|
||||
export type PackageCredentialRequirement = z.infer<typeof packageCredentialRequirementSchema>;
|
||||
export type PackageDataTableRequirement = z.infer<typeof packageDataTableRequirementSchema>;
|
||||
export type PackageWorkflowRequirement = z.infer<typeof packageWorkflowRequirementSchema>;
|
||||
export type PackageTagRequirement = z.infer<typeof packageTagRequirementSchema>;
|
||||
export type PackageVariableRequirement = z.infer<typeof packageVariableRequirementSchema>;
|
||||
export type PackageNodeTypeRequirement = z.infer<typeof packageNodeTypeRequirementSchema>;
|
||||
export type PackageRequirements = z.infer<typeof packageRequirementsSchema>;
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { serializedTagSchema } from '../tag.schema';
|
||||
|
||||
describe('serializedTagSchema', () => {
|
||||
it('accepts an id/name pair', () => {
|
||||
const tag = { id: 'tag-1', name: 'production' };
|
||||
|
||||
expect(() => serializedTagSchema.parse(tag)).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects unknown keys such as timestamps', () => {
|
||||
const tag = { id: 'tag-1', name: 'production', createdAt: '2026-01-01T00:00:00.000Z' };
|
||||
|
||||
expect(() => serializedTagSchema.parse(tag)).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty name', () => {
|
||||
const tag = { id: 'tag-1', name: '' };
|
||||
|
||||
expect(() => serializedTagSchema.parse(tag)).toThrow();
|
||||
});
|
||||
|
||||
it('accepts a supplementary-plane name', () => {
|
||||
const tag = { id: 'tag-1', name: '😀'.repeat(13) };
|
||||
|
||||
expect(() => serializedTagSchema.parse(tag)).not.toThrow();
|
||||
});
|
||||
});
|
||||
+14
@@ -28,4 +28,18 @@ describe('serializedWorkflowSchema', () => {
|
||||
it('rejects a non-finite node typeVersion (JSON `1e999` parses to Infinity)', () => {
|
||||
expect(() => serializedWorkflowSchema.parse(workflow(Infinity))).toThrow();
|
||||
});
|
||||
|
||||
it('accepts a non-empty tagIds array', () => {
|
||||
expect(() =>
|
||||
serializedWorkflowSchema.parse({ ...workflow(1), tagIds: ['tag-1', 'tag-2'] }),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty tagIds array', () => {
|
||||
expect(() => serializedWorkflowSchema.parse({ ...workflow(1), tagIds: [] })).toThrow();
|
||||
});
|
||||
|
||||
it('rejects an empty-string tag id', () => {
|
||||
expect(() => serializedWorkflowSchema.parse({ ...workflow(1), tagIds: [''] })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const serializedTagSchema = z
|
||||
.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export type SerializedTag = z.infer<typeof serializedTagSchema>;
|
||||
@@ -51,6 +51,7 @@ export const serializedWorkflowSchema = z.object({
|
||||
parentFolderId: z.string().nullable(),
|
||||
isPublished: z.boolean(),
|
||||
isArchived: z.boolean(),
|
||||
tagIds: z.array(z.string().min(1)).min(1).optional(),
|
||||
});
|
||||
|
||||
export type SerializedWorkflow = z.infer<typeof serializedWorkflowSchema>;
|
||||
|
||||
+34
@@ -45,6 +45,7 @@ const EXPORT_COUNTS = {
|
||||
credentials: 0,
|
||||
dataTables: 0,
|
||||
variables: 0,
|
||||
tags: 0,
|
||||
};
|
||||
|
||||
describe('n8n-packages handler', () => {
|
||||
@@ -57,6 +58,7 @@ describe('n8n-packages handler', () => {
|
||||
folderIds?: string[];
|
||||
projectIds?: string[];
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: string;
|
||||
},
|
||||
apiKeyScopes?: string[],
|
||||
@@ -248,6 +250,7 @@ describe('n8n-packages handler', () => {
|
||||
projectIds: [],
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
@@ -272,6 +275,7 @@ describe('n8n-packages handler', () => {
|
||||
projectIds: [],
|
||||
includeVariableValues: false,
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
@@ -370,6 +374,7 @@ describe('n8n-packages handler', () => {
|
||||
projectIds: [],
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'application/gzip');
|
||||
@@ -414,6 +419,7 @@ describe('n8n-packages handler', () => {
|
||||
projectIds: [],
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'reference-only',
|
||||
});
|
||||
});
|
||||
@@ -438,6 +444,7 @@ describe('n8n-packages handler', () => {
|
||||
projectIds: ['project-1'],
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
@@ -462,6 +469,7 @@ describe('n8n-packages handler', () => {
|
||||
projectIds: [],
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: true,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
@@ -486,6 +494,32 @@ describe('n8n-packages handler', () => {
|
||||
projectIds: [],
|
||||
includeVariableValues: false,
|
||||
canExportVariableValues: false,
|
||||
includeTags: true,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards includeTags=false to the service', async () => {
|
||||
const stream = new PassThrough();
|
||||
mockService.exportPackage.mockResolvedValue({ stream, counts: EXPORT_COUNTS });
|
||||
const res = makeResponse();
|
||||
|
||||
const resultPromise = run(
|
||||
makeRequest({ workflowIds: ['wf-1'], includeTags: false }, ['workflow:export']),
|
||||
res,
|
||||
);
|
||||
stream.end(Buffer.from('package-bytes'));
|
||||
const caught = await resultPromise;
|
||||
|
||||
expect(caught).toBeUndefined();
|
||||
expect(mockService.exportPackage).toHaveBeenCalledWith({
|
||||
user: { id: 'user-1' },
|
||||
workflowIds: ['wf-1'],
|
||||
folderIds: [],
|
||||
projectIds: [],
|
||||
includeVariableValues: true,
|
||||
canExportVariableValues: false,
|
||||
includeTags: false,
|
||||
missingWorkflowDependencyPolicy: 'fail',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,6 +34,7 @@ type ExportPackageRequest = AuthenticatedRequest<
|
||||
folderIds?: string[];
|
||||
projectIds?: string[];
|
||||
includeVariableValues?: boolean;
|
||||
includeTags?: boolean;
|
||||
missingWorkflowDependencyPolicy?: 'fail' | 'reference-only' | 'include-in-package';
|
||||
}
|
||||
>;
|
||||
@@ -147,6 +148,7 @@ const n8nPackagesHandlers: N8nPackagesHandlers = {
|
||||
projectIds,
|
||||
includeVariableValues,
|
||||
canExportVariableValues: apiKeyScopes.includes('variable:list'),
|
||||
includeTags: payload.data.includeTags,
|
||||
missingWorkflowDependencyPolicy: payload.data.missingWorkflowDependencyPolicy,
|
||||
});
|
||||
|
||||
|
||||
+7
@@ -41,6 +41,13 @@ properties:
|
||||
bundled into the package. When `false`, variables still travel as
|
||||
name/type files and are listed in the package requirements, but no
|
||||
values travel with the package.
|
||||
includeTags:
|
||||
type: boolean
|
||||
default: true
|
||||
description: >-
|
||||
Whether tags assigned to the exported workflows are bundled into the
|
||||
package. When `false`, no tag files, tag references, or tag
|
||||
requirements travel with the package.
|
||||
missingWorkflowDependencyPolicy:
|
||||
type: string
|
||||
enum:
|
||||
|
||||
@@ -165,14 +165,16 @@ export class WorkflowFinderService {
|
||||
workflowIds: string[],
|
||||
user: User,
|
||||
scopes: Scope[],
|
||||
options: { includeParentFolder?: boolean } = {},
|
||||
options: { includeParentFolder?: boolean; includeTags?: boolean } = {},
|
||||
): Promise<WorkflowEntity[]> {
|
||||
if (workflowIds.length === 0) return [];
|
||||
|
||||
const where = await this.findAllWhere(user, scopes);
|
||||
const sharedWorkflows = await this.sharedWorkflowRepository.find({
|
||||
where: { ...where, workflowId: In(workflowIds) },
|
||||
relations: { workflow: { parentFolder: options.includeParentFolder } },
|
||||
relations: {
|
||||
workflow: { parentFolder: options.includeParentFolder, tags: options.includeTags },
|
||||
},
|
||||
});
|
||||
|
||||
// A workflow may appear via several share paths (project membership +
|
||||
|
||||
@@ -183,4 +183,17 @@ describe('POST /n8n-packages/export', () => {
|
||||
expect(response.headers['content-type']).toContain('application/gzip');
|
||||
expect(response.headers['content-disposition']).toContain('export.n8np');
|
||||
});
|
||||
|
||||
// The OpenAPI request schema has `additionalProperties: false`, so acceptance
|
||||
// proves `includeTags` is declared in exportPackageRequest.yml.
|
||||
test('accepts includeTags=false through the OpenAPI request validator', async () => {
|
||||
const project = await createTeamProject('Export project', owner);
|
||||
const folder = await createFolder(project, { name: 'to_production' });
|
||||
|
||||
const response = await authOwnerAgent
|
||||
.post('/n8n-packages/export')
|
||||
.send({ folderIds: [folder.id], includeTags: false });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user