refactor(core): Use typed package requirement consumers (#40621)

This commit is contained in:
Robin Braumann 2026-10-09 07:42:23 +00:00 committed by GitHub
parent 13679fcb97
commit 806a2aa94b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
70 changed files with 1077 additions and 547 deletions

View File

@ -491,6 +491,7 @@ export {
export { ExportPackageRequestDto } from './packages/export-package-request.dto';
export {
importBlockingIssueSchema,
packageRequirementConsumerSchema,
type ImportBlockingIssue,
} from './packages/import-blocking-issue.schema';
export {

View File

@ -205,10 +205,10 @@ export const tagUnresolvedFieldDocs = {
existingName: {
description: 'For `rename-drift`: the current name of the same-id target tag.',
},
usedByWorkflows: {
usedBy: {
description:
'Package workflow ids (non-skipped) that reference the source tag — not workflows ' +
'attached to the contested target tag.',
'Package consumers that reference the source tag. This excludes skipped consumers ' +
'and workflows attached to the contested target tag.',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
@ -263,8 +263,8 @@ export const variableLimitExceededFieldDocs = {
description: 'Number of new variable rows the import would create (destination-deduplicated).',
},
names: { description: 'The unique variable names the import would create.' },
usedByWorkflows: {
description: 'Package workflow ids that reference any of the listed variables.',
usedBy: {
description: 'Package consumers that reference any of the listed variables.',
},
} as const satisfies Record<string, ZodOpenAPIMetadata>;
@ -278,7 +278,7 @@ export const variableLimitExceededIssueOpenApi: ZodOpenAPIMetadata = {
export const missingNodeTypeFieldDocs = {
nodeType: { description: "Full node type name as used by the package's workflows." },
typeVersion: { description: "Node type version the package's workflows use." },
usedByWorkflows: { description: 'Package workflow ids that use this node type and version.' },
usedBy: { description: 'Package consumers that use this node type and version.' },
} as const satisfies Record<string, ZodOpenAPIMetadata>;
export const missingNodeTypeIssueOpenApi: ZodOpenAPIMetadata = {

View File

@ -42,6 +42,13 @@ import {
import { dataTableColumnTypeSchema } from '../../schemas/data-table.schema';
import { policyViolationSchema } from '../../schemas/policy-violation.schema';
export const packageRequirementConsumerSchema = z
.object({
kind: z.literal('workflow'),
id: z.string().min(1),
})
.openapi('PackageRequirementConsumer');
/**
* A reason an import cannot proceed, matching the `BlockingIssue` union in
* `packages/cli/src/modules/n8n-packages/n8n-packages.types.ts`. A type-level test in
@ -121,7 +128,7 @@ const credentialUnresolvedIssueSchema = z
targetId: z.string().optional().openapi(credentialUnresolvedFieldDocs.targetId),
expectedType: z.string().optional().openapi(credentialUnresolvedFieldDocs.expectedType),
actualType: z.string().optional().openapi(credentialUnresolvedFieldDocs.actualType),
usedByWorkflows: z.array(z.string()),
usedBy: z.array(packageRequirementConsumerSchema),
})
.openapi(credentialUnresolvedIssueOpenApi);
@ -265,7 +272,7 @@ const dataTableUnresolvedIssueSchema = z
.optional()
.openapi(dataTableUnresolvedFieldDocs.conflictingTableId),
currentName: z.string().optional().openapi(dataTableUnresolvedFieldDocs.currentName),
usedByWorkflows: z.array(z.string()),
usedBy: z.array(packageRequirementConsumerSchema),
})
.openapi(dataTableUnresolvedIssueOpenApi);
@ -287,7 +294,7 @@ const tagUnresolvedIssueSchema = z
.openapi(tagUnresolvedFieldDocs.missingScope),
existingTagId: z.string().optional().openapi(tagUnresolvedFieldDocs.existingTagId),
existingName: z.string().optional().openapi(tagUnresolvedFieldDocs.existingName),
usedByWorkflows: z.array(z.string()).openapi(tagUnresolvedFieldDocs.usedByWorkflows),
usedBy: z.array(packageRequirementConsumerSchema).openapi(tagUnresolvedFieldDocs.usedBy),
})
.openapi(tagUnresolvedIssueOpenApi);
@ -295,7 +302,7 @@ const variableUnresolvedIssueSchema = z
.object({
type: z.literal('variable-unresolved'),
name: z.string().openapi(variableUnresolvedFieldDocs.name),
usedByWorkflows: z.array(z.string()),
usedBy: z.array(packageRequirementConsumerSchema),
})
.openapi(variableUnresolvedIssueOpenApi);
@ -304,7 +311,7 @@ const variableConflictIssueSchema = z
type: z.literal('variable-conflict'),
name: z.string().openapi(variableConflictFieldDocs.name),
projectId: z.string().optional().openapi(variableConflictFieldDocs.projectId),
usedByWorkflows: z.array(z.string()),
usedBy: z.array(packageRequirementConsumerSchema),
})
.openapi(variableConflictIssueOpenApi);
@ -316,7 +323,9 @@ const variableLimitExceededIssueSchema = z
remaining: z.number().int().openapi(variableLimitExceededFieldDocs.remaining),
requested: z.number().int().openapi(variableLimitExceededFieldDocs.requested),
names: z.array(z.string()).openapi(variableLimitExceededFieldDocs.names),
usedByWorkflows: z.array(z.string()).openapi(variableLimitExceededFieldDocs.usedByWorkflows),
usedBy: z
.array(packageRequirementConsumerSchema)
.openapi(variableLimitExceededFieldDocs.usedBy),
})
.openapi(variableLimitExceededIssueOpenApi);
@ -325,7 +334,7 @@ const missingNodeTypeIssueSchema = z
type: z.literal('missing-node-type'),
nodeType: z.string().openapi(missingNodeTypeFieldDocs.nodeType),
typeVersion: z.number().openapi(missingNodeTypeFieldDocs.typeVersion),
usedByWorkflows: z.array(z.string()).openapi(missingNodeTypeFieldDocs.usedByWorkflows),
usedBy: z.array(packageRequirementConsumerSchema).openapi(missingNodeTypeFieldDocs.usedBy),
})
.openapi(missingNodeTypeIssueOpenApi);

View File

@ -58,7 +58,7 @@ describe('toPackagesError', () => {
{ kind: 'remove-column', column: 'note', type: 'string', destructive: true },
{ kind: 'add-column', column: 'BaaId', type: 'string', destructive: false },
],
usedByWorkflows: ['wf1'],
usedBy: [{ kind: 'workflow', id: 'wf1' }],
},
],
}),
@ -66,7 +66,7 @@ describe('toPackagesError', () => {
const hint = (result as ApiError).hint ?? '';
expect(hint).toContain(
'data table "First" (dt1) does not match the package schema (missing columns: BaaId), used by workflow(s) wf1',
'data table "First" (dt1) does not match the package schema (missing columns: BaaId), used by workflow wf1',
);
expect(hint).toContain(
'--data-table-schema-conflict-policy=overwrite would: remove column note (data lost), add column BaaId',
@ -84,14 +84,14 @@ describe('toPackagesError', () => {
name: 'Sales',
currentName: 'Orders',
conflictingTableId: 'sales1',
usedByWorkflows: ['wf1'],
usedBy: [{ kind: 'workflow', id: 'wf1' }],
},
],
}),
);
expect((result as ApiError).hint ?? '').toContain(
'data table "Orders" (orders1) cannot be renamed to "Sales": the name is also used by table sales1, used by workflow(s) wf1',
'data table "Orders" (orders1) cannot be renamed to "Sales": the name is also used by table sales1, used by workflow wf1',
);
});
@ -141,7 +141,10 @@ describe('toPackagesError', () => {
type: 'credential-unresolved',
kind: 'not_found',
sourceId: 'c1',
usedByWorkflows: ['w1', 'w2'],
usedBy: [
{ kind: 'workflow', id: 'w1' },
{ kind: 'workflow', id: 'w2' },
],
},
],
}),
@ -149,17 +152,33 @@ describe('toPackagesError', () => {
const hint = (result as ApiError).hint ?? '';
expect(hint).toContain('credential c1 unresolved (not_found)');
expect(hint).toContain('w1, w2');
expect(hint).toContain('workflow w1, workflow w2');
});
it('lists variable-unresolved issues for a 422', () => {
it.each([
{
usedBy: [{ kind: 'workflow', id: 'w1' }],
expected: 'workflow w1',
},
{
usedBy: [{ kind: 'agent', id: 'a1' }],
expected: 'agent a1',
},
{
usedBy: [
{ kind: 'workflow', id: 'shared' },
{ kind: 'agent', id: 'shared' },
],
expected: 'workflow shared, agent shared',
},
])('lists variable-unresolved issues for $expected', ({ usedBy, expected }) => {
const result = toPackagesError(
new ApiError(422, 'Import blocked', undefined, {
issues: [
{
type: 'variable-unresolved',
name: 'var1',
usedByWorkflows: ['w1', 'w2'],
usedBy,
},
],
}),
@ -167,7 +186,7 @@ describe('toPackagesError', () => {
const hint = (result as ApiError).hint ?? '';
expect(hint).toContain('variable "var1" unresolved');
expect(hint).toContain('w1, w2');
expect(hint).toContain(`used by ${expected}`);
});
it('lists variable-conflict issues for a 409, naming the scope', () => {
@ -178,9 +197,9 @@ describe('toPackagesError', () => {
type: 'variable-conflict',
name: 'API_URL',
projectId: 'p1',
usedByWorkflows: ['w1'],
usedBy: [{ kind: 'workflow', id: 'w1' }],
},
{ type: 'variable-conflict', name: 'DB_URL', usedByWorkflows: ['w2'] },
{ type: 'variable-conflict', name: 'DB_URL', usedBy: [{ kind: 'workflow', id: 'w2' }] },
],
}),
);
@ -202,7 +221,10 @@ describe('toPackagesError', () => {
remaining: 1,
requested: 3,
names: ['API_URL', 'DB_URL', 'TOKEN'],
usedByWorkflows: ['w1', 'w2'],
usedBy: [
{ kind: 'workflow', id: 'w1' },
{ kind: 'workflow', id: 'w2' },
],
},
],
}),
@ -212,7 +234,7 @@ describe('toPackagesError', () => {
expect(hint).toContain('variable limit reached: 3 new variable(s)');
expect(hint).toContain('API_URL, DB_URL, TOKEN');
expect(hint).toContain('1 of 5 remaining');
expect(hint).toContain('w1, w2');
expect(hint).toContain('workflow w1, workflow w2');
});
it('lists tag-unresolved issues for a 409', () => {
@ -225,13 +247,13 @@ describe('toPackagesError', () => {
sourceId: 't1',
name: 'prod',
existingName: 'production',
usedByWorkflows: ['w1'],
usedBy: [{ kind: 'workflow', id: 'w1' }],
},
{
type: 'tag-unresolved',
kind: 'permission-denied',
missingScope: 'tag:create',
usedByWorkflows: ['w2'],
usedBy: [{ kind: 'workflow', id: 'w2' }],
},
],
}),

View File

@ -1,5 +1,7 @@
import { ApiError } from '../../client';
type RequirementConsumer = { kind: string; id: string };
type BlockingIssue =
| {
type: 'workflow-conflict';
@ -30,22 +32,22 @@ type BlockingIssue =
transition: 'archive' | 'unarchive';
}
| { type: 'folder-removal-forbidden'; folderId: string; name: string; projectId: string }
| { type: 'credential-unresolved'; kind: string; sourceId: string; usedByWorkflows: string[] }
| { type: 'variable-unresolved'; name: string; usedByWorkflows: string[] }
| { type: 'variable-conflict'; name: string; projectId?: string; usedByWorkflows: string[] }
| { type: 'credential-unresolved'; kind: string; sourceId: string; usedBy: RequirementConsumer[] }
| { type: 'variable-unresolved'; name: string; usedBy: RequirementConsumer[] }
| { type: 'variable-conflict'; name: string; projectId?: string; usedBy: RequirementConsumer[] }
| {
type: 'variable-limit-exceeded';
limit: number;
remaining: number;
requested: number;
names: string[];
usedByWorkflows: string[];
usedBy: RequirementConsumer[];
}
| {
type: 'missing-node-type';
nodeType: string;
typeVersion: number;
usedByWorkflows: string[];
usedBy: RequirementConsumer[];
}
| {
type: 'tag-unresolved';
@ -53,7 +55,7 @@ type BlockingIssue =
sourceId?: string;
name?: string;
missingScope?: string;
usedByWorkflows: string[];
usedBy: RequirementConsumer[];
}
| {
type: 'data-table-unresolved';
@ -67,7 +69,7 @@ type BlockingIssue =
overwriteChanges?: DataTableSchemaChange[];
currentName?: string;
conflictingTableId?: string;
usedByWorkflows: string[];
usedBy: RequirementConsumer[];
};
type DataTableSchemaChange = { destructive?: boolean } & (
@ -93,6 +95,14 @@ function describeSchemaChange(change: DataTableSchemaChange): string {
}
}
function formatConsumers(consumers: RequirementConsumer[] | undefined): string {
if (!Array.isArray(consumers)) return '';
return consumers
.filter((consumer) => typeof consumer?.kind === 'string' && typeof consumer.id === 'string')
.map(({ kind, id }) => `${kind} ${id}`)
.join(', ');
}
function formatIssue(issue: unknown): string {
if (typeof issue !== 'object' || issue === null) return JSON.stringify(issue);
const it = issue as Partial<BlockingIssue> & Record<string, unknown>;
@ -123,38 +133,38 @@ function formatIssue(issue: unknown): string {
return `folder "${it.name}" (${it.folderId}) in project ${it.projectId} is not in the package and would be removed, but you lack permission to remove it`;
}
if (it.type === 'credential-unresolved') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
return `credential ${it.sourceId} unresolved (${it.kind}), used by workflow(s) ${usedBy}`;
const usedBy = formatConsumers(it.usedBy);
return `credential ${it.sourceId} unresolved (${it.kind}), used by ${usedBy}`;
}
if (it.type === 'variable-unresolved') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
return `variable "${it.name}" unresolved, used by workflow(s) ${usedBy}`;
const usedBy = formatConsumers(it.usedBy);
return `variable "${it.name}" unresolved, used by ${usedBy}`;
}
if (it.type === 'variable-conflict') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
const usedBy = formatConsumers(it.usedBy);
const scope = it.projectId ? `project ${it.projectId}` : 'the global scope';
return `variable "${it.name}" in ${scope} holds a different value, used by workflow(s) ${usedBy}`;
return `variable "${it.name}" in ${scope} holds a different value, used by ${usedBy}`;
}
if (it.type === 'variable-limit-exceeded') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
const usedBy = formatConsumers(it.usedBy);
const names = Array.isArray(it.names) ? it.names.join(', ') : '';
return `variable limit reached: ${it.requested} new variable(s) (${names}) with ${it.remaining} of ${it.limit} remaining, used by workflow(s) ${usedBy}`;
return `variable limit reached: ${it.requested} new variable(s) (${names}) with ${it.remaining} of ${it.limit} remaining, used by ${usedBy}`;
}
if (it.type === 'missing-node-type') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
return `node type ${it.nodeType} @ v${it.typeVersion} missing on this instance, used by workflow(s) ${usedBy}`;
const usedBy = formatConsumers(it.usedBy);
return `node type ${it.nodeType} @ v${it.typeVersion} missing on this instance, used by ${usedBy}`;
}
if (it.type === 'tag-unresolved') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
const usedBy = formatConsumers(it.usedBy);
if (it.kind === 'permission-denied') {
return `tag import requires the ${it.missingScope} scope, needed by workflow(s) ${usedBy}`;
return `tag import requires the ${it.missingScope} scope, needed by ${usedBy}`;
}
return `tag "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by workflow(s) ${usedBy}`;
return `tag "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by ${usedBy}`;
}
if (it.type === 'data-table-unresolved') {
const usedBy = Array.isArray(it.usedByWorkflows) ? it.usedByWorkflows.join(', ') : '';
const usedBy = formatConsumers(it.usedBy);
if (it.kind === 'permission-denied') {
return `data table import requires the ${it.missingScope} scope, needed by workflow(s) ${usedBy}`;
return `data table import requires the ${it.missingScope} scope, needed by ${usedBy}`;
}
if (it.kind === 'schema-incompatible') {
const reasons = [
@ -167,15 +177,15 @@ function formatIssue(issue: unknown): string {
const changes = it.overwriteChanges?.length
? `\n --data-table-schema-conflict-policy=overwrite would: ${it.overwriteChanges.map((change) => (change.destructive ? `${describeSchemaChange(change)} (data lost)` : describeSchemaChange(change))).join(', ')}`
: '';
return `data table "${it.name}" (${it.sourceId}) does not match the package schema (${reasons.join('; ')}), used by workflow(s) ${usedBy}${changes}`;
return `data table "${it.name}" (${it.sourceId}) does not match the package schema (${reasons.join('; ')}), used by ${usedBy}${changes}`;
}
if (it.kind === 'name-conflict') {
const action = it.currentName
? `"${it.currentName}" (${it.sourceId}) cannot be renamed to "${it.name}"`
: `"${it.name}" (${it.sourceId}) cannot be created`;
return `data table ${action}: the name is also used by table ${it.conflictingTableId}, used by workflow(s) ${usedBy}`;
return `data table ${action}: the name is also used by table ${it.conflictingTableId}, used by ${usedBy}`;
}
return `data table "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by workflow(s) ${usedBy}`;
return `data table "${it.name}" (${it.sourceId}) unresolved (${it.kind}), used by ${usedBy}`;
}
return JSON.stringify(issue);
}

View File

@ -2,6 +2,17 @@
This list shows all the versions which include breaking changes and how to upgrade.
# Unreleased
## n8n Packages (preview)
Package manifest requirements now use `usedBy: [{ "kind": "workflow", "id": "workflow-id" }]`.
The `usedByWorkflows` field is no longer supported.
Re-export packages after upgrading.
Regenerate existing Promotions snapshots.
Package import errors also use `usedBy` for resource consumers.
Update API clients to read each consumer's `kind` and `id`.
# 2.0.0
### What changed?

View File

@ -3,6 +3,7 @@ import { serializeInternalRestError, serializePublicApiError } from '@n8n/backen
import { LicenseEulaRequiredError } from '@/errors/response-errors/license-eula-required.error';
import { WorkflowPublishBlockedError } from '@/errors/response-errors/workflow-publish-blocked.error';
import { toImportBlockedError } from '@/modules/n8n-packages/engine/import-blocked.error';
import type { BlockingIssue } from '@/modules/n8n-packages/n8n-packages.types';
import { PromotionsWorkflowsMovedCrossProjectError } from '@/modules/promotions.ee/promotions-selective-push.error';
import { PolicyViolationError } from '@/policy/policy-violation.error';
@ -76,12 +77,12 @@ describe('rest-error-response', () => {
});
it('serializePublicApiError: 422 with issues when only credentials are unresolved', () => {
const issues = [
const issues: BlockingIssue[] = [
{
type: 'credential-unresolved' as const,
kind: 'not_found' as const,
sourceId: 'cred-1',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
];
const descriptor = classifyRestError(toImportBlockedError(issues));
@ -92,7 +93,7 @@ describe('rest-error-response', () => {
});
it('serializePublicApiError: 409 with issues when a workflow conflicts', () => {
const issues = [
const issues: BlockingIssue[] = [
{
type: 'workflow-conflict' as const,
sourceWorkflowId: 'wf-1',
@ -103,7 +104,7 @@ describe('rest-error-response', () => {
type: 'credential-unresolved' as const,
kind: 'not_found' as const,
sourceId: 'cred-1',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
];
const descriptor = classifyRestError(toImportBlockedError(issues));

View File

@ -246,7 +246,11 @@ describe('folder package export', () => {
expect(childEntry?.target).toBe(`workflows/external-child-${externalChild.id}`);
expect(entries.find((e) => e.name === `${childEntry!.target}/workflow.json`)).toBeDefined();
expect(manifest.requirements?.workflows).toEqual([
{ id: externalChild.id, name: externalChild.name, usedByWorkflows: [parent.id] },
{
id: externalChild.id,
name: externalChild.name,
usedBy: [{ kind: 'workflow', id: parent.id }],
},
]);
});
@ -470,12 +474,16 @@ describe('folder package export — with contained workflows', () => {
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
// Folder packages fold node type usage too (shared WorkflowExporter path).
nodeTypes: [
{ type: 'n8n-nodes-base.httpRequest', typeVersion: 1, usedByWorkflows: [workflow.id] },
{
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
});
expect(
@ -512,7 +520,7 @@ describe('folder package export — with contained workflows', () => {
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
expect(
entries.find((e) => e.name === `${manifest.variables![0].target}/variable.json`),

View File

@ -285,7 +285,7 @@ describe('project package export', () => {
expect(manifest.workflows!.map(({ id }) => id).sort()).toEqual([parent.id, child.id].sort());
expect(manifest.requirements?.workflows).toEqual([
{ id: child.id, name: child.name, usedByWorkflows: [parent.id] },
{ id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] },
]);
});
@ -431,14 +431,18 @@ describe('project package export — with folders / workflows', () => {
).toBeDefined();
expect(manifest.requirements).toEqual({
nodeTypes: [
{ type: 'n8n-nodes-base.httpRequest', typeVersion: 1, usedByWorkflows: [workflow.id] },
{
type: 'n8n-nodes-base.httpRequest',
typeVersion: 1,
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
credentials: [
{
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
});
@ -468,7 +472,7 @@ describe('project package export — with folders / workflows', () => {
expect(entries.find((e) => e.name === `${variableEntry.target}/variable.json`)).toBeDefined();
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
const events = emitSpy.mock.calls.filter(([name]) => name === 'n8n-package-exported');
@ -525,10 +529,14 @@ describe('project package export — with folders / workflows', () => {
expect(manifest.credentials![0].target).toMatch(
new RegExp(`^${projectAEntry.target}/credentials/[^/]+$`),
);
const usedByWorkflows = (manifest.requirements?.credentials ?? []).flatMap(
(c) => c.usedByWorkflows,
const usedBy = (manifest.requirements?.credentials ?? []).flatMap((c) => c.usedBy);
expect(usedBy).toHaveLength(2);
expect(usedBy).toEqual(
expect.arrayContaining([
{ kind: 'workflow', id: workflowA.id },
{ kind: 'workflow', id: workflowB.id },
]),
);
expect(usedByWorkflows.sort()).toEqual([workflowA.id, workflowB.id].sort());
});
it('exports a root workflow shared across two projects only under its owner', async () => {
@ -729,7 +737,10 @@ describe('project package export — workflow selection', () => {
expect(manifest.folders!.map(({ id }) => id).sort()).toEqual([inProgress.id, nested.id].sort());
expect(manifest.credentials!.map(({ id }) => id)).toEqual([selectedCredential.id]);
expect(manifest.requirements?.credentials).toEqual([
expect.objectContaining({ id: selectedCredential.id, usedByWorkflows: [selected.id] }),
expect.objectContaining({
id: selectedCredential.id,
usedBy: [{ kind: 'workflow', id: selected.id }],
}),
]);
const projectTarget = manifest.projects![0].target;
@ -773,7 +784,7 @@ describe('project package export — workflow selection', () => {
expect(manifest.workflows!.map(({ id }) => id)).toEqual([parent.id]);
expect(manifest.requirements?.workflows).toEqual([
expect.objectContaining({ id: child.id, usedByWorkflows: [parent.id] }),
expect.objectContaining({ id: child.id, usedBy: [{ kind: 'workflow', id: parent.id }] }),
]);
});

View File

@ -82,7 +82,7 @@ describe('workflow package export — with credentials', () => {
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
});
@ -198,8 +198,12 @@ describe('workflow package export — with credentials', () => {
expect(manifest.credentials![0].id).toBe(credential.id);
expect(manifest.requirements?.credentials).toHaveLength(1);
expect(manifest.requirements!.credentials![0].usedByWorkflows.sort()).toEqual(
[wfA.id, wfB.id].sort(),
expect(manifest.requirements!.credentials![0].usedBy).toHaveLength(2);
expect(manifest.requirements!.credentials![0].usedBy).toEqual(
expect.arrayContaining([
{ kind: 'workflow', id: wfA.id },
{ kind: 'workflow', id: wfB.id },
]),
);
const credentialFiles = entries.filter((e) => e.name.endsWith('/credential.json'));
@ -240,7 +244,7 @@ describe('workflow package export — with credentials', () => {
id: credential.id,
name: credential.name,
type: credential.type,
usedByWorkflows: [child.id],
usedBy: [{ kind: 'workflow', id: child.id }],
},
]);
expect(manifest.requirements).not.toHaveProperty('subWorkflows');
@ -269,7 +273,7 @@ describe('workflow package export — with credentials', () => {
id: 'does-not-exist',
name: 'Stale cred name',
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
});
@ -316,7 +320,7 @@ describe('workflow package export — with credentials', () => {
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
});

View File

@ -80,7 +80,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
});
@ -185,7 +185,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
expect(entries.map((e) => e.name)).toContain(
@ -221,7 +221,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [child.id],
usedBy: [{ kind: 'workflow', id: child.id }],
},
]);
expect(entries.map((e) => e.name)).toContain(
@ -252,8 +252,12 @@ describe('workflow package export — with data tables', () => {
const { manifest, entries } = await readExport(stream);
expect(manifest.requirements?.dataTables).toHaveLength(1);
expect(manifest.requirements!.dataTables![0].usedByWorkflows.sort()).toEqual(
[wfA.id, wfB.id].sort(),
expect(manifest.requirements!.dataTables![0].usedBy).toHaveLength(2);
expect(manifest.requirements!.dataTables![0].usedBy).toEqual(
expect.arrayContaining([
{ kind: 'workflow', id: wfA.id },
{ kind: 'workflow', id: wfB.id },
]),
);
expect(entries.filter((e) => e.name.endsWith('/data-table.json'))).toHaveLength(1);
});
@ -347,7 +351,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
expect(entries.map((e) => e.name)).toContain(
@ -379,7 +383,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
// Folder packages keep data tables top-level, unlike project packages.
@ -412,7 +416,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
});
@ -438,7 +442,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
});
@ -463,7 +467,7 @@ describe('workflow package export — with data tables', () => {
{
id: dataTable.id,
name: 'Customers',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
});

View File

@ -77,8 +77,8 @@ describe('workflow package export — with tags', () => {
{ id: beta.id, name: 'beta', target: `tags/beta-${beta.id}` },
]);
expect(manifest.requirements!.tags).toEqual([
{ id: alpha.id, name: 'alpha', usedByWorkflows: [workflow.id] },
{ id: beta.id, name: 'beta', usedByWorkflows: [workflow.id] },
{ id: alpha.id, name: 'alpha', usedBy: [{ kind: 'workflow', id: workflow.id }] },
{ id: beta.id, name: 'beta', usedBy: [{ kind: 'workflow', id: workflow.id }] },
]);
const serialized = workflowJson(entries, manifest.workflows![0].target);
@ -117,7 +117,14 @@ describe('workflow package export — with tags', () => {
]);
expect(tagFiles(entries)).toHaveLength(1);
expect(manifest.requirements!.tags).toEqual([
{ id: tag.id, name: 'shared', usedByWorkflows: [wfA.id, wfB.id] },
{
id: tag.id,
name: 'shared',
usedBy: [
{ kind: 'workflow', id: wfA.id },
{ kind: 'workflow', id: wfB.id },
],
},
]);
for (const entry of manifest.workflows!) {
@ -198,7 +205,7 @@ describe('workflow package export — with tags', () => {
expect(manifest.tags).toEqual([{ id: tag.id, name: 'prod', target: `tags/prod-${tag.id}` }]);
expect(manifest.requirements!.tags).toEqual([
{ id: tag.id, name: 'prod', usedByWorkflows: [sub.id] },
{ id: tag.id, name: 'prod', usedBy: [{ kind: 'workflow', id: sub.id }] },
]);
const subEntry = manifest.workflows!.find((entry) => entry.id === sub.id)!;

View File

@ -73,7 +73,7 @@ describe('workflow package export — with variables', () => {
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
const files = variableFiles(entries);
@ -117,7 +117,7 @@ describe('workflow package export — with variables', () => {
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
const files = variableFiles(entries);
@ -150,7 +150,7 @@ describe('workflow package export — with variables', () => {
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
expect(variableFiles(entries)).toHaveLength(1);
});
@ -183,7 +183,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
expect(variableFiles(entries)).toEqual([]);
expect(JSON.stringify(manifest)).not.toContain('example.com');
@ -216,7 +216,7 @@ describe('workflow package export — with variables', () => {
},
]);
expect(manifest.requirements!.variables).toEqual([
{ name: 'API_URL', usedByWorkflows: [workflow.id] },
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] },
]);
const files = variableFiles(entries);
@ -251,8 +251,12 @@ describe('workflow package export — with variables', () => {
expect(manifest.variables).toHaveLength(1);
expect(manifest.requirements!.variables).toHaveLength(1);
expect(manifest.requirements!.variables![0].usedByWorkflows.sort()).toEqual(
[wfA.id, wfB.id].sort(),
expect(manifest.requirements!.variables![0].usedBy).toHaveLength(2);
expect(manifest.requirements!.variables![0].usedBy).toEqual(
expect.arrayContaining([
{ kind: 'workflow', id: wfA.id },
{ kind: 'workflow', id: wfB.id },
]),
);
expect(variableFiles(entries)).toHaveLength(1);
});
@ -279,7 +283,7 @@ describe('workflow package export — with variables', () => {
]);
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'legacy-key', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'legacy-key', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
expect(variableFiles(entries)).toHaveLength(1);
});
@ -299,7 +303,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'DOES_NOT_EXIST', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'DOES_NOT_EXIST', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
expect(variableFiles(entries)).toEqual([]);
});
@ -326,7 +330,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
expect(variableFiles(entries)).toEqual([]);
expect(JSON.stringify(manifest)).not.toContain('example.com');
@ -441,7 +445,7 @@ describe('workflow package export — with variables', () => {
expect(manifest).not.toHaveProperty('variables');
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'PRIVATE_VAR', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'PRIVATE_VAR', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
expect(variableFiles(entries)).toEqual([]);
});
@ -506,7 +510,7 @@ describe('workflow package export — with variables', () => {
expect(manifest.requirements).toEqual({
nodeTypes: expect.any(Array),
variables: [{ name: 'API_URL', usedByWorkflows: [workflow.id] }],
variables: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: workflow.id }] }],
});
// Stubs still travel (name/type only) — the scope gate is value-only.
const files = variableFiles(entries);

View File

@ -253,9 +253,16 @@ describe('workflow package export', () => {
{
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
usedByWorkflows: [wfA.id, wfB.id],
usedBy: [
{ kind: 'workflow', id: wfA.id },
{ kind: 'workflow', id: wfB.id },
],
},
{
type: 'n8n-nodes-base.set',
typeVersion: 3.4,
usedBy: [{ kind: 'workflow', id: wfB.id }],
},
{ type: 'n8n-nodes-base.set', typeVersion: 3.4, usedByWorkflows: [wfB.id] },
],
});
});
@ -328,8 +335,16 @@ describe('workflow package export', () => {
expect(manifest.requirements?.workflows).toHaveLength(2);
expect(manifest.requirements?.workflows).toEqual(
expect.arrayContaining([
{ id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] },
{ id: workflowC.id, name: workflowC.name, usedByWorkflows: [workflowB.id] },
{
id: workflowB.id,
name: workflowB.name,
usedBy: [{ kind: 'workflow', id: workflowA.id }],
},
{
id: workflowC.id,
name: workflowC.name,
usedBy: [{ kind: 'workflow', id: workflowB.id }],
},
]),
);
});
@ -374,12 +389,14 @@ describe('workflow package export', () => {
const { manifest } = await readExport(stream);
expect(manifest.requirements).toEqual({
workflows: [{ id: child.id, name: child.name, usedByWorkflows: [parent.id] }],
workflows: [
{ id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] },
],
nodeTypes: [
{
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1,
usedByWorkflows: [parent.id],
usedBy: [{ kind: 'workflow', id: parent.id }],
},
],
});
@ -426,12 +443,14 @@ describe('workflow package export', () => {
const { manifest } = await readExport(stream);
expect(manifest.requirements).toEqual({
workflows: [{ id: child.id, name: child.name, usedByWorkflows: [parent.id] }],
workflows: [
{ id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] },
],
nodeTypes: [
{
type: '@n8n/n8n-nodes-langchain.toolWorkflow',
typeVersion: 2.2,
usedByWorkflows: [parent.id],
usedBy: [{ kind: 'workflow', id: parent.id }],
},
],
});
@ -464,7 +483,13 @@ describe('workflow package export', () => {
const { manifest } = await readExport(stream);
expect(manifest.requirements).toEqual({
workflows: [{ id: errorHandler.id, name: errorHandler.name, usedByWorkflows: [parent.id] }],
workflows: [
{
id: errorHandler.id,
name: errorHandler.name,
usedBy: [{ kind: 'workflow', id: parent.id }],
},
],
});
});
@ -491,17 +516,21 @@ describe('workflow package export', () => {
workflowIds: [parentA.id, parentB.id, child.id],
});
const { manifest } = await readExport(stream);
const expectedUsedByWorkflows = manifest
const expectedUsedBy = manifest
.workflows!.map(({ id }) => id)
.filter((id) => id === parentA.id || id === parentB.id);
.filter((id) => id === parentA.id || id === parentB.id)
.map((id) => ({ kind: 'workflow', id }));
expect(manifest.requirements).toEqual({
workflows: [{ id: child.id, name: child.name, usedByWorkflows: expectedUsedByWorkflows }],
workflows: [{ id: child.id, name: child.name, usedBy: expectedUsedBy }],
nodeTypes: [
{
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1,
usedByWorkflows: [parentA.id, parentB.id],
usedBy: [
{ kind: 'workflow', id: parentA.id },
{ kind: 'workflow', id: parentB.id },
],
},
],
});
@ -568,12 +597,20 @@ describe('workflow package export', () => {
id: credential.id,
name: credential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflowC.id],
usedBy: [{ kind: 'workflow', id: workflowC.id }],
},
]);
expect(manifest.requirements?.workflows).toEqual([
{ id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] },
{ id: workflowC.id, name: workflowC.name, usedByWorkflows: [workflowB.id] },
{
id: workflowB.id,
name: workflowB.name,
usedBy: [{ kind: 'workflow', id: workflowA.id }],
},
{
id: workflowC.id,
name: workflowC.name,
usedBy: [{ kind: 'workflow', id: workflowB.id }],
},
]);
});
@ -677,8 +714,16 @@ describe('workflow package export', () => {
[workflowA.id, workflowB.id].sort(),
);
expect(manifest.requirements?.workflows).toEqual([
{ id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] },
{ id: workflowA.id, name: workflowA.name, usedByWorkflows: [workflowB.id] },
{
id: workflowB.id,
name: workflowB.name,
usedBy: [{ kind: 'workflow', id: workflowA.id }],
},
{
id: workflowA.id,
name: workflowA.name,
usedBy: [{ kind: 'workflow', id: workflowB.id }],
},
]);
});
});
@ -709,7 +754,7 @@ describe('workflow package export', () => {
{ id: parent.id, name: 'Parent', target: expect.any(String) },
]);
expect(manifest.requirements?.workflows).toEqual([
{ id: child.id, name: child.name, usedByWorkflows: [parent.id] },
{ id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] },
]);
// Only the parent's workflow.json travels; the referenced child stays out.
expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(1);
@ -739,7 +784,7 @@ describe('workflow package export', () => {
expect(manifest.requirements?.credentials).toBeUndefined();
expect(entries.some((e) => e.name.includes('credential'))).toBe(false);
expect(manifest.requirements?.workflows).toEqual([
{ id: child.id, name: child.name, usedByWorkflows: [parent.id] },
{ id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] },
]);
});
@ -784,11 +829,11 @@ describe('workflow package export', () => {
id: credential.id,
name: credential.name,
type: credential.type,
usedByWorkflows: [parent.id],
usedBy: [{ kind: 'workflow', id: parent.id }],
},
]);
expect(manifest.requirements?.workflows).toEqual([
{ id: child.id, name: child.name, usedByWorkflows: [parent.id] },
{ id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] },
]);
});
@ -810,7 +855,7 @@ describe('workflow package export', () => {
{ id: cheddar.id, name: 'CHEDDAR', target: expect.any(String) },
]);
expect(manifest.requirements?.workflows).toEqual([
{ id: brie.id, name: 'BRIE', usedByWorkflows: [cheddar.id] },
{ id: brie.id, name: 'BRIE', usedBy: [{ kind: 'workflow', id: cheddar.id }] },
]);
expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(1);
});
@ -836,7 +881,11 @@ describe('workflow package export', () => {
const { manifest } = await exportReferenceOnly(owner, [workflowA.id]);
expect(manifest.requirements?.workflows).toEqual([
{ id: workflowB.id, name: workflowB.name, usedByWorkflows: [workflowA.id] },
{
id: workflowB.id,
name: workflowB.name,
usedBy: [{ kind: 'workflow', id: workflowA.id }],
},
]);
});
@ -860,7 +909,7 @@ describe('workflow package export', () => {
const { manifest } = await exportReferenceOnly(member, [parent.id]);
expect(manifest.requirements?.workflows).toEqual([
{ id: foreignWorkflow.id, usedByWorkflows: [parent.id] },
{ id: foreignWorkflow.id, usedBy: [{ kind: 'workflow', id: parent.id }] },
]);
expect(manifest.requirements?.workflows?.[0]).not.toHaveProperty('name');
});
@ -880,7 +929,7 @@ describe('workflow package export', () => {
expect(manifest.workflows!.map(({ id }) => id).sort()).toEqual([parent.id, child.id].sort());
expect(entries.filter((e) => e.name.endsWith('/workflow.json'))).toHaveLength(2);
expect(manifest.requirements?.workflows).toEqual([
{ id: child.id, name: child.name, usedByWorkflows: [parent.id] },
{ id: child.id, name: child.name, usedBy: [{ kind: 'workflow', id: parent.id }] },
]);
});
@ -903,7 +952,11 @@ describe('workflow package export', () => {
{ id: parent.id, name: 'Parent', target: expect.any(String) },
]);
expect(manifest.requirements?.workflows).toEqual([
{ id: errorHandler.id, name: errorHandler.name, usedByWorkflows: [parent.id] },
{
id: errorHandler.id,
name: errorHandler.name,
usedBy: [{ kind: 'workflow', id: parent.id }],
},
]);
});
});
@ -1113,7 +1166,7 @@ describe('workflow package export', () => {
id: publishedCredential.id,
name: publishedCredential.name,
type: 'httpHeaderAuth',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
});

View File

@ -151,13 +151,15 @@ export function workflowRequirementsFromWorkflows(
if (!referencedId) return;
const existing = byId.get(referencedId);
if (existing) {
if (!existing.usedByWorkflows.includes(workflowId)) existing.usedByWorkflows.push(workflowId);
if (!existing.usedBy.some(({ id }) => id === workflowId)) {
existing.usedBy.push({ kind: 'workflow', id: workflowId });
}
return;
}
byId.set(referencedId, {
id: referencedId,
name: nameById.get(referencedId) ?? referencedId,
usedByWorkflows: [workflowId],
usedBy: [{ kind: 'workflow', id: workflowId }],
});
};
@ -206,8 +208,8 @@ export function credentialRequirementsFromWorkflows(
const existing = byId.get(details.id);
if (existing) {
if (!existing.usedByWorkflows.includes(workflow.id)) {
existing.usedByWorkflows.push(workflow.id);
if (!existing.usedBy.some(({ id }) => id === workflow.id)) {
existing.usedBy.push({ kind: 'workflow', id: workflow.id });
}
continue;
}
@ -216,7 +218,7 @@ export function credentialRequirementsFromWorkflows(
id: details.id,
name: details.name,
type: credentialType,
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
});
}
}
@ -321,7 +323,11 @@ export function dataTableRequirement(
table: SerializedDataTable,
usedByWorkflows: string[],
): PackageDataTableRequirement {
return { id: table.id, name: table.name, usedByWorkflows };
return {
id: table.id,
name: table.name,
usedBy: usedByWorkflows.map((id) => ({ kind: 'workflow', id })),
};
}
export function serializedFolder(overrides: Partial<SerializedFolder> = {}): SerializedFolder {

View File

@ -383,7 +383,7 @@ describe('workflow package import — with data tables', () => {
overwriteChanges: [
{ kind: 'add-column', column: 'signed_up_at', type: 'date', destructive: false },
],
usedByWorkflows: ['wf-0'],
usedBy: [{ kind: 'workflow', id: 'wf-0' }],
});
expect(await workflowRepository.count()).toBe(0);
@ -533,7 +533,7 @@ describe('workflow package import — with data tables', () => {
kind: 'missing',
sourceId: table.id,
name: table.name,
usedByWorkflows: ['wf-0'],
usedBy: [{ kind: 'workflow', id: 'wf-0' }],
},
);
@ -551,6 +551,7 @@ describe('workflow package import — with data tables', () => {
await expectBlocked(importPackage({ user: owner, projectId: project.id, packageBuffer }), {
type: 'data-table-unresolved',
kind: 'module-disabled',
usedBy: [{ kind: 'workflow', id: 'wf-0' }],
});
} finally {
isActive.mockRestore();
@ -882,6 +883,7 @@ describe('workflow package import — with data tables', () => {
type: 'data-table-unresolved',
kind: 'permission-denied',
missingScope: 'dataTable:update',
usedBy: [{ kind: 'workflow', id: 'wf-0' }],
});
expect(await columnsOf(table.id)).toEqual([{ name: 'email', type: 'string', index: 0 }]);

View File

@ -2829,13 +2829,16 @@ describe('Package import missing node type mode', () => {
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['wf-alpha', 'wf-beta'],
usedBy: [
{ kind: 'workflow', id: 'wf-alpha' },
{ kind: 'workflow', id: 'wf-beta' },
],
},
{
type: 'missing-node-type',
nodeType: 'n8n-nodes-base.manualTrigger',
typeVersion: 9,
usedByWorkflows: ['wf-gamma'],
usedBy: [{ kind: 'workflow', id: 'wf-gamma' }],
},
]),
},

View File

@ -34,6 +34,7 @@ import { LicenseMocker } from '@test-integration/license';
import { initNodeTypes } from '@test-integration/utils';
import { N8nPackagesService } from '../n8n-packages.service';
import type { PackageVariableRequirement } from '../spec/requirements.schema';
import { importPackageRequest } from './fixtures/import-request';
import type {
ImportPackageRequest,
@ -374,7 +375,9 @@ describe('project shell import', () => {
},
],
manifestExtras: {
requirements: { workflows: [{ id: sub.id, name: 'Sub', usedByWorkflows: ['WF'] }] },
requirements: {
workflows: [{ id: sub.id, name: 'Sub', usedBy: [{ kind: 'workflow', id: 'WF' }] }],
},
},
});
@ -694,7 +697,16 @@ describe('project shell import', () => {
],
manifestExtras: {
requirements: {
tags: [{ id: 'TAG1', name: 'shared', usedByWorkflows: ['WFA', 'WFB'] }],
tags: [
{
id: 'TAG1',
name: 'shared',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
},
},
});
@ -738,7 +750,16 @@ describe('project shell import', () => {
],
manifestExtras: {
requirements: {
tags: [{ id: 'TAG1', name: 'prod', usedByWorkflows: ['WFA', 'WFB'] }],
tags: [
{
id: 'TAG1',
name: 'prod',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
},
},
});
@ -790,8 +811,8 @@ describe('project shell import', () => {
manifestExtras: {
requirements: {
tags: [
{ id: 'X', name: 'prod', usedByWorkflows: ['WFA'] },
{ id: 'H', name: 'staging', usedByWorkflows: ['WFB'] },
{ id: 'X', name: 'prod', usedBy: [{ kind: 'workflow', id: 'WFA' }] },
{ id: 'H', name: 'staging', usedBy: [{ kind: 'workflow', id: 'WFB' }] },
],
},
},
@ -812,7 +833,7 @@ describe('project shell import', () => {
sourceId: 'X',
name: 'prod',
existingTagId: 'H',
usedByWorkflows: ['WFA'],
usedBy: [{ kind: 'workflow', id: 'WFA' }],
}),
],
},
@ -983,13 +1004,13 @@ describe('project shell import', () => {
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['WFA'],
usedBy: [{ kind: 'workflow', id: 'WFA' }],
},
{
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['WFB'],
usedBy: [{ kind: 'workflow', id: 'WFB' }],
},
],
},
@ -1039,13 +1060,13 @@ describe('project shell import', () => {
* Two team projects — brie (P1, workflow WFA) and stilton (P2, workflow WFB) — with an
* optional bundled variable `catalog` and the variable `requirements` its workflows declare.
* A catalog entry's `target` encodes scope: `variables/x` is global, `projects/<name>/variables/x`
* is that project's. Requirements are what the workflows reference (by `usedByWorkflows`).
* is that project's. Requirements use `usedBy` to identify their workflow consumers.
*/
const twoProjectPackage = async (
opts: {
catalog?: Array<{ id: string; name: string; target: string }>;
variables?: PackageVariableEntry[];
requirements?: Array<{ name: string; usedByWorkflows: string[] }>;
requirements?: PackageVariableRequirement[];
} = {},
) =>
await buildEntityPackageBuffer({
@ -1073,7 +1094,15 @@ describe('project shell import', () => {
/** Both projects' workflows reference `API_URL` as a name-only requirement (nothing bundled). */
const apiUrlPackage = async () =>
await twoProjectPackage({
requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
/** Single team project brie (P1, workflow WFA) whose workflow needs a top-level (global) variable. */
@ -1091,7 +1120,7 @@ describe('project shell import', () => {
manifestExtras: {
variables: [{ id: 'v1', name: 'GLOBAL_VAR', target: 'variables/global_var' }],
requirements: {
variables: [{ name: 'GLOBAL_VAR', usedByWorkflows: ['WFA'] }],
variables: [{ name: 'GLOBAL_VAR', usedBy: [{ kind: 'workflow', id: 'WFA' }] }],
},
},
});
@ -1101,8 +1130,20 @@ describe('project shell import', () => {
await createVariable('GLOBAL_URL', 'https://global.example.com');
const packageBuffer = await twoProjectPackage({
requirements: [
{ name: 'GLOBAL_URL', usedByWorkflows: ['WFA', 'WFB'] },
{ name: 'ABSENT_VAR', usedByWorkflows: ['WFA', 'WFB'] },
{
name: 'GLOBAL_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
{
name: 'ABSENT_VAR',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
@ -1145,8 +1186,20 @@ describe('project shell import', () => {
await createVariable('GLOBAL_URL', 'https://global.example.com');
const packageBuffer = await twoProjectPackage({
requirements: [
{ name: 'GLOBAL_URL', usedByWorkflows: ['WFA', 'WFB'] },
{ name: 'ABSENT_VAR', usedByWorkflows: ['WFA', 'WFB'] },
{
name: 'GLOBAL_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
{
name: 'ABSENT_VAR',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
@ -1158,8 +1211,16 @@ describe('project shell import', () => {
expect((error as UnprocessableRequestError).message).toMatch(/Import blocked/);
// One issue per consuming workflow of the unresolved name.
expect((error as UnprocessableRequestError).meta?.issues).toEqual([
{ type: 'variable-unresolved', name: 'ABSENT_VAR', usedByWorkflows: ['WFA'] },
{ type: 'variable-unresolved', name: 'ABSENT_VAR', usedByWorkflows: ['WFB'] },
{
type: 'variable-unresolved',
name: 'ABSENT_VAR',
usedBy: [{ kind: 'workflow', id: 'WFA' }],
},
{
type: 'variable-unresolved',
name: 'ABSENT_VAR',
usedBy: [{ kind: 'workflow', id: 'WFB' }],
},
]);
expect(await findProject('P1')).toBeNull();
@ -1190,7 +1251,11 @@ describe('project shell import', () => {
expect(error).toBeInstanceOf(UnprocessableRequestError);
// API_URL resolves in P1 (seeded above) but not P2, so only WFB blocks.
expect((error as UnprocessableRequestError).meta?.issues).toEqual([
{ type: 'variable-unresolved', name: 'API_URL', usedByWorkflows: ['WFB'] },
{
type: 'variable-unresolved',
name: 'API_URL',
usedBy: [{ kind: 'workflow', id: 'WFB' }],
},
]);
expect(await workflowStates()).toEqual(workflowsBefore);
});
@ -1209,8 +1274,8 @@ describe('project shell import', () => {
{ id: 'v2', name: 'GLOBAL_VAR', target: 'variables/global_var' },
],
requirements: [
{ name: 'PROJECT_VAR', usedByWorkflows: ['WFA'] },
{ name: 'GLOBAL_VAR', usedByWorkflows: ['WFB'] },
{ name: 'PROJECT_VAR', usedBy: [{ kind: 'workflow', id: 'WFA' }] },
{ name: 'GLOBAL_VAR', usedBy: [{ kind: 'workflow', id: 'WFB' }] },
],
});
@ -1324,7 +1389,15 @@ describe('project shell import', () => {
{ id: 'v1', name: 'API_URL', target: 'projects/brie/variables/api_url' },
{ id: 'v2', name: 'API_URL', target: 'projects/stilton/variables/api_url' },
],
requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
const result = await importProjects(owner, packageBuffer, undefined, {
@ -1373,7 +1446,7 @@ describe('project shell import', () => {
manifestExtras: {
variables: [{ id: 'v1', name: 'THEIRS', target: 'projects/brie/variables/theirs' }],
requirements: {
variables: [{ name: 'THEIRS', usedByWorkflows: ['WFB'] }],
variables: [{ name: 'THEIRS', usedBy: [{ kind: 'workflow', id: 'WFB' }] }],
},
},
});
@ -1407,8 +1480,20 @@ describe('project shell import', () => {
{ id: 'v3', name: 'API_URL', target: 'projects/stilton/variables/api_url' },
],
requirements: [
{ name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] },
{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] },
{
name: 'SHARED_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
@ -1448,7 +1533,15 @@ describe('project shell import', () => {
licenseMocker.setQuota('quota:maxVariables', 1);
const packageBuffer = await twoProjectPackage({
catalog: [{ id: 'v1', name: 'SHARED_URL', target: 'variables/shared_url' }],
requirements: [{ name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'SHARED_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
const result = await importProjects(owner, packageBuffer, undefined, {
@ -1549,8 +1642,8 @@ describe('project shell import', () => {
{ id: 'v2', name: 'VAR_B', target: 'projects/stilton/variables/var_b' },
],
requirements: [
{ name: 'VAR_A', usedByWorkflows: ['WFA'] },
{ name: 'VAR_B', usedByWorkflows: ['WFB'] },
{ name: 'VAR_A', usedBy: [{ kind: 'workflow', id: 'WFA' }] },
{ name: 'VAR_B', usedBy: [{ kind: 'workflow', id: 'WFB' }] },
],
});
@ -1567,7 +1660,10 @@ describe('project shell import', () => {
remaining: 1,
requested: 2,
names: ['VAR_A', 'VAR_B'],
usedByWorkflows: ['WFA', 'WFB'],
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
]);
expect(await findProject('P1')).toBeNull();
@ -1595,7 +1691,15 @@ describe('project shell import', () => {
variable: { name: 'API_URL', type: 'string', value: 'https://stilton.example.com' },
},
],
requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
const result = await importProjects(owner, packageBuffer, undefined, {
@ -1632,7 +1736,15 @@ describe('project shell import', () => {
variable: { name: 'SHARED_URL', type: 'string', value: 'https://shared.example.com' },
},
],
requirements: [{ name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'SHARED_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
const result = await importProjects(owner, packageBuffer, undefined, {
@ -1657,7 +1769,15 @@ describe('project shell import', () => {
variable: { name: 'API_URL', type: 'string', value: 'https://brie.example.com' },
},
],
requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
const result = await importProjects(owner, packageBuffer, undefined, {
@ -1688,7 +1808,15 @@ describe('project shell import', () => {
variable: { name: 'SHARED_URL', type: 'string', value },
},
],
requirements: [{ name: 'SHARED_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'SHARED_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
const perProjectPackage = async (brie: string, stilton: string) =>
@ -1705,7 +1833,15 @@ describe('project shell import', () => {
variable: { name: 'API_URL', type: 'string', value: stilton },
},
],
requirements: [{ name: 'API_URL', usedByWorkflows: ['WFA', 'WFB'] }],
requirements: [
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'WFA' },
{ kind: 'workflow', id: 'WFB' },
],
},
],
});
it('reports one update when both projects resolve the same global variable', async () => {
@ -1753,8 +1889,16 @@ describe('project shell import', () => {
expect(error).toBeInstanceOf(ConflictError);
expect((error as ConflictError).meta?.issues).toEqual([
{ type: 'variable-conflict', name: 'SHARED_URL', usedByWorkflows: ['WFA'] },
{ type: 'variable-conflict', name: 'SHARED_URL', usedByWorkflows: ['WFB'] },
{
type: 'variable-conflict',
name: 'SHARED_URL',
usedBy: [{ kind: 'workflow', id: 'WFA' }],
},
{
type: 'variable-conflict',
name: 'SHARED_URL',
usedBy: [{ kind: 'workflow', id: 'WFB' }],
},
]);
expect(await findProject('P1')).toBeNull();
expect(await findProject('P2')).toBeNull();
@ -1778,8 +1922,8 @@ describe('project shell import', () => {
expect(error).toBeInstanceOf(ConflictError);
// One issue per scope, as the `fail` policy reports the same collision.
expect((error as ConflictError).meta?.issues).toEqual([
{ type: 'variable-conflict', name: 'API_URL', usedByWorkflows: ['WFA'] },
{ type: 'variable-conflict', name: 'API_URL', usedByWorkflows: ['WFB'] },
{ type: 'variable-conflict', name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'WFA' }] },
{ type: 'variable-conflict', name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'WFB' }] },
]);
expect(await findProject('P1')).toBeNull();
expect(await findProject('P2')).toBeNull();

View File

@ -251,7 +251,7 @@ describe('workflow package import — with tags', () => {
sourceId: tag.id,
name: 'prod',
existingName: 'production',
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
}),
],
},
@ -411,8 +411,8 @@ describe('workflow package import — with tags', () => {
manifestExtras: {
requirements: {
tags: [
{ id: 'tag-a', name: 'prod', usedByWorkflows: ['wf-0'] },
{ id: 'tag-b', name: ' prod ', usedByWorkflows: ['wf-0'] },
{ id: 'tag-a', name: 'prod', usedBy: [{ kind: 'workflow', id: 'wf-0' }] },
{ id: 'tag-b', name: ' prod ', usedBy: [{ kind: 'workflow', id: 'wf-0' }] },
],
},
},
@ -433,14 +433,14 @@ describe('workflow package import — with tags', () => {
kind: 'name-collision',
sourceId: 'tag-a',
name: 'prod',
usedByWorkflows: ['wf-0'],
usedBy: [{ kind: 'workflow', id: 'wf-0' }],
}),
expect.objectContaining({
type: 'tag-unresolved',
kind: 'name-collision',
sourceId: 'tag-b',
name: 'prod',
usedByWorkflows: ['wf-0'],
usedBy: [{ kind: 'workflow', id: 'wf-0' }],
}),
],
},
@ -651,7 +651,9 @@ describe('workflow package import — with tags', () => {
const packageBuffer = await buildEntityPackageBuffer({
workflows: [{ target: 'workflows/wf-0', workflow }],
manifestExtras: {
requirements: { tags: [{ id: 'tag-1', name: 'prod', usedByWorkflows: ['wf-0'] }] },
requirements: {
tags: [{ id: 'tag-1', name: 'prod', usedBy: [{ kind: 'workflow', id: 'wf-0' }] }],
},
},
});
const targetProject = await createTeamProject('Target', owner);

View File

@ -712,7 +712,7 @@ describe('workflow package import — with variables', () => {
remaining: 0,
requested: 1,
names: ['API_URL'],
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
},
@ -1341,7 +1341,7 @@ describe('workflow package import — with variables', () => {
type: 'variable-conflict',
name: 'API_URL',
projectId: targetProject.id,
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
],
},
@ -1365,7 +1365,11 @@ describe('workflow package import — with variables', () => {
}).catch((e: unknown) => e);
expect((error as ConflictError).meta?.issues).toEqual([
{ type: 'variable-conflict', name: 'API_URL', usedByWorkflows: [workflow.id] },
{
type: 'variable-conflict',
name: 'API_URL',
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
});
@ -1450,7 +1454,7 @@ describe('workflow package import — with variables', () => {
type: 'variable-conflict',
name: 'API_URL',
projectId: targetProject.id,
usedByWorkflows: [workflow.id],
usedBy: [{ kind: 'workflow', id: workflow.id }],
},
]);
expect((await variablesInProject(targetProject.id))[0].value).toBe('');

View File

@ -23,13 +23,13 @@ const credentialUnresolved: BlockingIssue = {
type: 'credential-unresolved',
kind: 'not_found',
sourceId: 'c1',
usedByWorkflows: ['w1'],
usedBy: [{ kind: 'workflow', id: 'w1' }],
};
const variableUnresolved: BlockingIssue = {
type: 'variable-unresolved',
name: 'API_URL',
usedByWorkflows: ['w1'],
usedBy: [{ kind: 'workflow', id: 'w1' }],
};
const workflowLineageConflict: BlockingIssue = {
@ -49,7 +49,7 @@ const tagUnresolved = (
kind,
sourceId: 't1',
name: 'prod',
usedByWorkflows: ['w1'],
usedBy: [{ kind: 'workflow', id: 'w1' }],
});
describe('toImportBlockedError', () => {

View File

@ -11,7 +11,7 @@ const requirement = (id: string, usedByWorkflows: string[]): PackageCredentialRe
id,
name: id,
type: 'githubApi',
usedByWorkflows,
usedBy: usedByWorkflows.map((workflowId) => ({ kind: 'workflow', id: workflowId })),
});
const prepared = (sourceWorkflowId: string): PreparedWorkflow =>
@ -27,7 +27,6 @@ describe('identifyRequirements', () => {
const scoped = identifyRequirements(requirements, [prepared('W1')]);
// credA stays (W1 is in scope) but with W2 trimmed off; credB drops entirely (W3 is out of scope).
expect(scoped).toEqual([requirement('credA', ['W1'])]);
});
});

View File

@ -33,7 +33,7 @@ const requirement = (id: string): PackageCredentialRequirement => ({
id,
name: id,
type: 'githubApi',
usedByWorkflows: ['ignored'],
usedBy: [{ kind: 'workflow', id: 'ignored' }],
});
const scope = (input: {
@ -106,16 +106,15 @@ const scope = (input: {
...Array.from({ length: vars.matched }, (_, i) => `matched-var-${i}`),
...overwrittenVariableNames,
],
missing: missingVariableNames.map((name) => ({ name, usedByWorkflows: [] })),
missing: missingVariableNames.map((name) => ({ name })),
creations: [...createdVariableNames, ...stubbedVariableNames, ...existingVariableNames].map(
(name) => ({ name, usedByWorkflows: [] }),
(name) => ({ name }),
),
conflicts: overwrittenVariableNames.map((name) => ({ name, usedByWorkflows: [] })),
conflicts: overwrittenVariableNames.map((name) => ({ name })),
overwrites: overwrittenVariableNames.map((name) => ({
variableId: `id-of-${name}`,
name,
value: 'from-package',
usedByWorkflows: [],
})),
},
variableResult: {
@ -158,7 +157,7 @@ const scope = (input: {
requirements: (tags.requirementIds ?? []).map((id) => ({
id,
name: `name-of-${id}`,
usedByWorkflows: ['ignored'],
usedBy: [{ kind: 'workflow', id: 'ignored' }],
})),
missingMode: 'create',
conflictPolicy: 'skip',

View File

@ -65,7 +65,7 @@ describe('package-layout', () => {
bundledVariables?: Map<string, SerializedVariable>,
) =>
placeByLayout({
requirements: [{ name: 'API_URL', usedByWorkflows: ['wf-1'] }],
requirements: [{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }],
manifestVariables,
scopePrefix,
bundledVariables,

View File

@ -15,6 +15,7 @@ import { DataTableImporter } from '../entities/data-table/data-table-importer';
import type {
DataTableImportPlan,
DataTableImportRequest,
DataTableResolutionFailure,
} from '../entities/data-table/data-table.types';
import type {
FolderImportContext,
@ -27,7 +28,11 @@ import type { FolderRemovalPlan } from '../entities/folder/folder-removal.types'
import { FolderRemover } from '../entities/folder/folder-remover';
import { TagImporter } from '../entities/tag/tag-importer';
import { contestedReconcileTargetFailures, droppedTagIds } from '../entities/tag/tag.types';
import type { TagImportPlan, TagImportRequest } from '../entities/tag/tag.types';
import type {
TagImportPlan,
TagImportRequest,
TagResolutionFailure,
} from '../entities/tag/tag.types';
import { VariableImporter } from '../entities/variable/variable-importer';
import { divergentOverwrites } from '../entities/variable/variable.types';
import type {
@ -65,7 +70,11 @@ import type {
RemovedWorkflowSummary,
ResolvedImportFolderProperties,
} from '../n8n-packages.types';
import type { PackageWorkflowRequirement } from '../spec/requirements.schema';
import { mergeRequirementConsumers } from '../spec/requirement-consumers';
import type {
PackageRequirementConsumer,
PackageWorkflowRequirement,
} from '../spec/requirements.schema';
import { ContentImportPolicyGate, contentImportTransport } from './content-import-policy';
import { toImportBlockedError } from './import-blocked.error';
import { assertDataTableWritesAllowed, assertVariableWritesAllowed } from './import-gates';
@ -176,25 +185,54 @@ export class ImportOrchestrator {
}
}
const issues = await this.collectPackageBlockingIssues(plans);
if (issues.length > 0) throw toImportBlockedError(issues);
}
private async collectPackageBlockingIssues(plans: ImportPlan[]): Promise<BlockingIssue[]> {
const issues = plans.flatMap((plan) => plan.blockingIssues);
issues.push(
...contestedReconcileTargetFailures(
plans.map((plan) => ({
tagPlan: plan.tagPlan,
workflows: plan.workflowPlan.items.filter((item) => item.action !== 'skip'),
})),
).map((failure): BlockingIssue => ({ type: 'tag-unresolved', ...failure })),
...contestedReconcileTargetFailures(plans.map((plan) => plan.tagPlan)).map((failure) =>
toTagBlockingIssue(failure, plans),
),
);
for (const conflict of divergentOverwrites(overwrites)) {
issues.push({ type: 'variable-conflict', ...conflict });
const divergent = new Set(
divergentOverwrites(plans.flatMap((plan) => plan.variablePlan.overwrites)),
);
for (const { input, variablePlan } of plans) {
issues.push(
...variablePlan.overwrites
.filter((overwrite) => divergent.has(overwrite))
.map(
({ variableId, value, ...conflict }): BlockingIssue => ({
type: 'variable-conflict',
...conflict,
usedBy: variableConsumers(input.variableRequest, conflict.name),
}),
),
);
}
const quotaFailure = await this.variableImporter.quotaFailure(creations);
if (quotaFailure) issues.push({ type: 'variable-limit-exceeded', ...quotaFailure });
const quotaFailure = await this.variableImporter.quotaFailure(
plans.flatMap((plan) => plan.variablePlan.creations),
);
if (quotaFailure) {
const consumers = plans.flatMap(({ input, variablePlan }) => {
const createdNames = new Set(variablePlan.creations.map(({ name }) => name));
return (input.variableRequest.requirements ?? [])
.filter(({ name }) => createdNames.has(name))
.flatMap(({ usedBy }) => usedBy);
});
issues.push({
type: 'variable-limit-exceeded',
...quotaFailure,
usedBy: mergeRequirementConsumers(consumers),
});
}
if (issues.length > 0) throw toImportBlockedError(issues);
return issues;
}
async plan(input: ImportOrchestrationInput): Promise<ImportPlan> {
@ -269,6 +307,7 @@ export class ImportOrchestrator {
credentialPlan,
credentialRequest,
folderPlan,
dataTableRequest,
dataTablePlan,
variableRequest,
variablePlan,
@ -389,6 +428,7 @@ export class ImportOrchestrator {
credentialPlan,
credentialRequest,
folderPlan,
dataTableRequest,
dataTablePlan,
variableRequest,
variablePlan,
@ -402,6 +442,7 @@ export class ImportOrchestrator {
credentialPlan: CredentialResolution;
credentialRequest: CredentialBindingRequest;
folderPlan: FolderImportPlan;
dataTableRequest: DataTableImportRequest;
dataTablePlan: DataTableImportPlan;
variableRequest: VariableImportRequest;
variablePlan: VariableImportPlan;
@ -439,25 +480,35 @@ export class ImportOrchestrator {
...folderRemovalPlan.failures.map(
(failure): BlockingIssue => ({ type: 'folder-removal-forbidden', ...failure }),
),
...dataTablePlan.failures.map(
(failure): BlockingIssue => ({ type: 'data-table-unresolved', ...failure }),
...dataTablePlan.failures.map((failure) =>
toDataTableBlockingIssue(failure, dataTableRequest, dataTablePlan),
),
...tagPlan.failures.map((failure) =>
toTagBlockingIssue(failure, [{ tagPlan, workflowPlan }]),
),
...tagPlan.failures.map((failure): BlockingIssue => ({ type: 'tag-unresolved', ...failure })),
...this.credentialImporter
.blockingFailures(credentialRequest, credentialPlan)
.map(toCredentialBlockingIssue),
...this.variableImporter
.blockingFailures(variableRequest, variablePlan)
.map((failure): BlockingIssue => ({ type: 'variable-unresolved', ...failure })),
...this.variableImporter
.blockingConflicts(variableRequest, variablePlan)
.map((conflict): BlockingIssue => ({ type: 'variable-conflict', ...conflict })),
.map((failure) => toCredentialBlockingIssue(failure, credentialRequest)),
...this.variableImporter.blockingFailures(variableRequest, variablePlan).map(
(failure): BlockingIssue => ({
type: 'variable-unresolved',
...failure,
usedBy: variableConsumers(variableRequest, failure.name),
}),
),
...this.variableImporter.blockingConflicts(variableRequest, variablePlan).map(
(conflict): BlockingIssue => ({
type: 'variable-conflict',
...conflict,
usedBy: variableConsumers(variableRequest, conflict.name),
}),
),
...missingNodeTypeBlockingFailures(missingNodeTypeMode, missingNodeTypes).map(
({ type, typeVersion, usedByWorkflows }): BlockingIssue => ({
(requirement): BlockingIssue => ({
type: 'missing-node-type',
nodeType: type,
typeVersion,
usedByWorkflows,
nodeType: requirement.type,
typeVersion: requirement.typeVersion,
usedBy: requirement.usedBy,
}),
),
];
@ -472,8 +523,11 @@ function withBlockedFromPublish(
return { ...outcome, blockedFromPublish };
}
function toCredentialBlockingIssue(failure: CredentialResolutionFailure): BlockingIssue {
const { kind, sourceId, targetId, expectedType, actualType, usedByWorkflows } = failure;
function toCredentialBlockingIssue(
failure: CredentialResolutionFailure,
request: CredentialBindingRequest,
): BlockingIssue {
const { kind, sourceId, targetId, expectedType, actualType } = failure;
return {
type: 'credential-unresolved',
kind,
@ -481,6 +535,68 @@ function toCredentialBlockingIssue(failure: CredentialResolutionFailure): Blocki
...(targetId ? { targetId } : {}),
...(expectedType ? { expectedType } : {}),
...(actualType ? { actualType } : {}),
usedByWorkflows,
usedBy: mergeRequirementConsumers(
request.requirements?.find(({ id }) => id === sourceId)?.usedBy ?? [],
),
};
}
function toDataTableBlockingIssue(
failure: DataTableResolutionFailure,
request: DataTableImportRequest,
plan: DataTableImportPlan,
): BlockingIssue {
let requirements = request.requirements ?? [];
if (failure.sourceId) {
requirements = requirements.filter(({ id }) => id === failure.sourceId);
} else if (failure.kind === 'permission-denied') {
const writtenIds = new Set(
failure.missingScope === 'dataTable:create'
? plan.creations.map(({ id }) => id)
: plan.updates.map(({ table }) => table.id),
);
requirements = requirements.filter(({ id }) => writtenIds.has(id));
}
return {
type: 'data-table-unresolved',
...failure,
usedBy: mergeRequirementConsumers(requirements.flatMap(({ usedBy }) => usedBy)),
};
}
function toTagBlockingIssue(
failure: TagResolutionFailure,
plans: Array<Pick<ImportPlan, 'tagPlan' | 'workflowPlan'>>,
): BlockingIssue {
const consumers = plans.flatMap(({ tagPlan, workflowPlan }) => {
let tagIds: string[];
if (failure.sourceId) {
tagIds = [failure.sourceId];
} else if (failure.missingScope === 'tag:create') {
tagIds = tagPlan.creations.map(({ id }) => id);
} else {
tagIds = [...tagPlan.renames, ...tagPlan.reconciles].map(({ id }) => id);
}
return workflowPlan.items
.filter((item) => item.action !== 'skip' && item.tagIds?.some((id) => tagIds.includes(id)))
.map(
({ sourceWorkflowId }): PackageRequirementConsumer => ({
kind: 'workflow',
id: sourceWorkflowId,
}),
);
});
return { type: 'tag-unresolved', ...failure, usedBy: mergeRequirementConsumers(consumers) };
}
function variableConsumers(
request: VariableImportRequest,
name: string,
): PackageRequirementConsumer[] {
return mergeRequirementConsumers(
request.requirements?.find((requirement) => requirement.name === name)?.usedBy ?? [],
);
}

View File

@ -22,7 +22,10 @@ import type {
PackageImportBindings,
} from '../n8n-packages.types';
import type { PackageManifest } from '../spec/manifest.schema';
import type { PackageCredentialRequirement } from '../spec/requirements.schema';
import type {
PackageCredentialRequirement,
PackageRequirementConsumer,
} from '../spec/requirements.schema';
export function toPackageSummary(manifest: PackageManifest): ImportPackageSummary {
return {
@ -161,9 +164,9 @@ export function unionTagSummaries(summaries: ImportTagSummary[]): ImportTagSumma
}
/**
* Keeps only the requirements used by the imported workflows, trimming `usedByWorkflows` to match.
* Keep only requirements and consumers for the imported workflows.
*/
export function identifyRequirements<T extends { usedByWorkflows: string[] }>(
export function identifyRequirements<T extends { usedBy: PackageRequirementConsumer[] }>(
requirements: T[] | undefined,
workflows: PreparedWorkflow[],
): T[] | undefined {
@ -173,9 +176,9 @@ export function identifyRequirements<T extends { usedByWorkflows: string[] }>(
return requirements
.map((requirement) => ({
...requirement,
usedByWorkflows: requirement.usedByWorkflows.filter((id) => importedIds.has(id)),
usedBy: requirement.usedBy.filter(({ id }) => importedIds.has(id)),
}))
.filter((requirement) => requirement.usedByWorkflows.length > 0);
.filter((requirement) => requirement.usedBy.length > 0);
}
/**

View File

@ -51,7 +51,7 @@ describe('CredentialImporter', () => {
): CredentialRequirement => ({
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
...overrides,
});
@ -63,7 +63,6 @@ describe('CredentialImporter', () => {
sourceId: credential.id,
name: credential.name,
type: credential.type,
usedByWorkflows: credential.usedByWorkflows,
...overrides,
});
@ -95,7 +94,7 @@ describe('CredentialImporter', () => {
id: 'cred-manifest',
name: 'Manifest GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
]);
const credentialResolution = await importer.plan(context, request);
@ -109,7 +108,12 @@ describe('CredentialImporter', () => {
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]);
const request = bindingRequest([
{ id: 'cred-missing', name: 'Missing', type: 'githubApi', usedByWorkflows: ['wf-1'] },
{
id: 'cred-missing',
name: 'Missing',
type: 'githubApi',
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
]);
const credentialResolution = await importer.plan(context, request);
@ -120,7 +124,6 @@ describe('CredentialImporter', () => {
sourceId: 'cred-missing',
name: 'Missing',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
},
]);
expect(importer.blockingFailures(request, credentialResolution)).toEqual([
@ -129,7 +132,6 @@ describe('CredentialImporter', () => {
sourceId: 'cred-missing',
name: 'Missing',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
},
]);
});
@ -145,7 +147,7 @@ describe('CredentialImporter', () => {
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
],
{ credentialBindings: new Map([['source-cred', 'target-cred']]) },
@ -172,7 +174,6 @@ describe('CredentialImporter', () => {
kind: 'source_not_found',
sourceId: 'missing-source',
targetId: 'target-cred',
usedByWorkflows: [],
},
]);
});
@ -188,7 +189,7 @@ describe('CredentialImporter', () => {
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
],
{ credentialBindings: new Map([['source-cred', 'target-missing']]) },
@ -203,7 +204,6 @@ describe('CredentialImporter', () => {
name: 'Source GitHub',
type: 'githubApi',
targetId: 'target-missing',
usedByWorkflows: ['wf-1'],
},
]);
});
@ -215,7 +215,10 @@ describe('CredentialImporter', () => {
const missingCredential = packageCredential({
id: 'missing-cred',
name: 'Missing GitHub',
usedByWorkflows: ['wf-1', 'wf-2'],
usedBy: [
{ kind: 'workflow', id: 'wf-1' },
{ kind: 'workflow', id: 'wf-2' },
],
});
const request = bindingRequest([missingCredential], { missingMode: 'create-stub' });
const resolution = {

View File

@ -6,6 +6,7 @@ import type { CredentialTypes } from '@/credential-types';
import type { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError } from '@n8n/errors';
import type { PackageCredentialRequirement } from '../../../spec/requirements.schema';
import type { CredentialMatcherContext, UsableCredential } from '../credential-matcher';
import { CredentialMatcherFactory } from '../credential-matcher-factory';
import { createFailure } from '../credential.types';
@ -66,11 +67,11 @@ describe('IdBasedCredentialMatcher', () => {
});
it('reports a missing credential when the user cannot use it in the target project', async () => {
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'cred-global',
name: 'Global',
type: 'httpBasicAuth',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], context);
@ -85,11 +86,11 @@ describe('IdBasedCredentialMatcher', () => {
it('does not query the database for unknown credential types', async () => {
credentialTypes.recognizes.mockReturnValue(false);
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'cred-x',
name: 'Unknown',
type: 'unknownCredentialType',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], context);
@ -109,7 +110,7 @@ describe('IdBasedCredentialMatcher', () => {
id: 'cred-manifest',
name: 'Manifest GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
],
context,
@ -130,7 +131,7 @@ describe('IdBasedCredentialMatcher', () => {
id: 'cred-shared',
name: 'Shared',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
],
context,
@ -151,7 +152,7 @@ describe('IdBasedCredentialMatcher', () => {
id: 'cred-global',
name: 'Global',
type: 'httpBasicAuth',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
],
context,
@ -166,11 +167,11 @@ describe('IdBasedCredentialMatcher', () => {
// custom role granting workflow:import without credential:read yields an empty result.
credentialsService.getCredentialsAUserCanUseInAWorkflow.mockResolvedValue([]);
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'cred-manifest',
name: 'Manifest GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], context);
@ -191,7 +192,7 @@ describe('IdBasedCredentialMatcher', () => {
id: 'cred-manifest',
name: 'Manifest GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
],
context,
@ -206,11 +207,11 @@ describe('IdBasedCredentialMatcher', () => {
usable('target-cred'),
]);
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], {
@ -227,11 +228,11 @@ describe('IdBasedCredentialMatcher', () => {
usable('source-cred'),
]);
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], {
@ -250,11 +251,11 @@ describe('IdBasedCredentialMatcher', () => {
usable('target-cred', 'slackApi'),
]);
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'source-cred',
name: 'Source GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
const result = await matcherFactory.getMatcher('id-only').match([requirement], {
@ -278,11 +279,11 @@ describe('NameAndTypeCredentialMatcher', () => {
let context: CredentialMatcherContext;
let matcherFactory: CredentialMatcherFactory;
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'source-cred',
name: 'Production GitHub',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
beforeEach(() => {
@ -552,11 +553,11 @@ describe('TypeOnlyCredentialMatcher', () => {
let context: CredentialMatcherContext;
let matcherFactory: CredentialMatcherFactory;
const requirement = {
const requirement: PackageCredentialRequirement = {
id: 'source-cred',
name: 'Irrelevant name',
type: 'githubApi',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
};
beforeEach(() => {

View File

@ -14,7 +14,7 @@ describe('credentialBlockingFailures', () => {
it('treats every unresolved reference as blocking', () => {
const failure = createFailure(
{ id: 'cred-1', name: 'X', type: 'githubApi', usedByWorkflows: ['wf-1'] },
{ id: 'cred-1', name: 'X', type: 'githubApi', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
'not_found',
);
@ -27,7 +27,7 @@ describe('credentialBlockingFailures', () => {
describe('create-stub', () => {
it('treats not_found as non-blocking', () => {
const failure = createFailure(
{ id: 'cred-1', name: 'X', type: 'githubApi', usedByWorkflows: ['wf-1'] },
{ id: 'cred-1', name: 'X', type: 'githubApi', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
'not_found',
);
@ -39,7 +39,12 @@ describe('credentialBlockingFailures', () => {
it('still blocks not_found when an explicit binding target is missing', () => {
const failure = {
...createFailure(
{ id: 'cred-1', name: 'X', type: 'githubApi', usedByWorkflows: ['wf-1'] },
{
id: 'cred-1',
name: 'X',
type: 'githubApi',
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
'not_found',
),
targetId: 'target-missing',
@ -52,11 +57,11 @@ describe('credentialBlockingFailures', () => {
it('still blocks unknown_type and source_not_found failures', () => {
const unknownType = createFailure(
{ id: 'cred-1', name: 'X', type: 'bad', usedByWorkflows: ['wf-1'] },
{ id: 'cred-1', name: 'X', type: 'bad', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
'unknown_type',
);
const sourceNotFound = createFailure(
{ id: 'cred-2', name: 'Y', type: 'githubApi', usedByWorkflows: ['wf-2'] },
{ id: 'cred-2', name: 'Y', type: 'githubApi', usedBy: [{ kind: 'workflow', id: 'wf-2' }] },
'source_not_found',
);

View File

@ -103,7 +103,7 @@ describe('CredentialExporter', () => {
id: 'cred_1',
name: 'My Credential',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
]);
@ -119,7 +119,7 @@ describe('CredentialExporter', () => {
});
});
it('dedupes by credential id and aggregates usedByWorkflows when requirements come from multiple workflows', async () => {
it('dedupes by credential id and aggregates workflow consumers when requirements come from multiple workflows', async () => {
const { exporter, finder } = makeExporter();
finder.findCredentialForUser.mockResolvedValue(makeCredential());
const writer = new CapturingWriter();
@ -143,7 +143,10 @@ describe('CredentialExporter', () => {
id: 'cred_1',
name: 'My Credential',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-a', 'wf-b'],
usedBy: [
{ kind: 'workflow', id: 'wf-a' },
{ kind: 'workflow', id: 'wf-b' },
],
},
]);
expect(writer.files).toHaveLength(1);
@ -208,7 +211,7 @@ describe('CredentialExporter', () => {
id: 'cred_unavailable',
name: 'Stale node name',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
]);
expect(writer.files).toEqual([]);
@ -246,13 +249,13 @@ describe('CredentialExporter', () => {
id: 'cred_1',
name: 'My Credential',
type: 'httpHeaderAuth',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
{
id: 'cred_unavailable',
name: 'Unavailable',
type: 'slackOAuth2Api',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
]);
expect(writer.files).toHaveLength(1);

View File

@ -208,7 +208,6 @@ function orphanBindingFailures(
kind: 'source_not_found',
sourceId,
targetId,
usedByWorkflows: [],
});
}
}

View File

@ -1,5 +1,6 @@
import type { CredentialResolution, CredentialResolutionFailure } from './credential.types';
import type { CredentialMissingMode } from '../../n8n-packages.types';
import { getWorkflowConsumerIds } from '../../spec/requirement-consumers';
import type { PackageCredentialRequirement } from '../../spec/requirements.schema';
export function canStubNotFoundFailure(failure: CredentialResolutionFailure): boolean {
@ -38,7 +39,7 @@ export function workflowsBlockedFromPublish(
for (const requirement of requirements ?? []) {
if (!stubbedSourceIds.has(requirement.id)) continue;
for (const sourceWorkflowId of requirement.usedByWorkflows) {
for (const sourceWorkflowId of getWorkflowConsumerIds(requirement)) {
blocked.add(sourceWorkflowId);
}
}

View File

@ -85,7 +85,12 @@ export class CredentialExporter {
);
}
requirements.push({ id, name, type, usedByWorkflows });
requirements.push({
id,
name,
type,
usedBy: usedByWorkflows.map((workflowId) => ({ kind: 'workflow', id: workflowId })),
});
}
return { entries, requirements };

View File

@ -28,7 +28,6 @@ export type CredentialResolutionFailure = {
expectedType?: string;
/** For `type_mismatch`: the actual type of the resolved target credential. */
actualType?: string;
usedByWorkflows: string[];
};
export interface CredentialResolution {
@ -58,6 +57,5 @@ export function createFailure(
sourceId: reference.id,
name: reference.name,
type: reference.type,
usedByWorkflows: [...reference.usedByWorkflows].sort(),
};
}

View File

@ -33,7 +33,7 @@ function makeImporter() {
function makeRequest(overrides: Partial<DataTableImportRequest> = {}): DataTableImportRequest {
return {
requirements: [{ id: 'dt1', name: 'Customers', usedByWorkflows: ['wf-1'] }],
requirements: [{ id: 'dt1', name: 'Customers', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }],
packageDataTables: [
{ id: 'dt1', name: 'Customers', columns: [{ name: 'email', type: 'string', index: 0 }] },
],
@ -66,7 +66,7 @@ describe('DataTableImporter.plan', () => {
const plan = await importer.plan(context, makeRequest());
expect(plan.creations).toEqual([]);
expect(plan.failures).toEqual([{ kind: 'module-disabled', usedByWorkflows: ['wf-1'] }]);
expect(plan.failures).toEqual([{ kind: 'module-disabled' }]);
});
it('fails with permission-denied when the user cannot create tables in the target project', async () => {
@ -76,7 +76,7 @@ describe('DataTableImporter.plan', () => {
const plan = await importer.plan(context, makeRequest());
expect(plan.failures).toEqual([
{ kind: 'permission-denied', missingScope: 'dataTable:create', usedByWorkflows: ['wf-1'] },
{ kind: 'permission-denied', missingScope: 'dataTable:create' },
]);
expect(userHasScopesMock).toHaveBeenCalledWith(context.user, ['dataTable:create'], false, {
projectId: context.projectId,

View File

@ -100,7 +100,7 @@ describe('DataTableExporter', () => {
{
id: 'dt1',
name: 'Customers',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
]);
@ -116,7 +116,7 @@ describe('DataTableExporter', () => {
});
});
it('dedupes by table id and aggregates usedByWorkflows when requirements come from multiple workflows', async () => {
it('dedupes by table id and aggregates workflow consumers when requirements come from multiple workflows', async () => {
const { exporter, dataTableService } = makeExporter();
dataTableService.findDataTablesByIdsForUser.mockResolvedValue([makeDataTable()]);
const writer = new CapturingWriter();
@ -140,7 +140,10 @@ describe('DataTableExporter', () => {
{
id: 'dt1',
name: 'Customers',
usedByWorkflows: ['wf-a', 'wf-b'],
usedBy: [
{ kind: 'workflow', id: 'wf-a' },
{ kind: 'workflow', id: 'wf-b' },
],
},
]);
expect(writer.files).toHaveLength(1);

View File

@ -63,7 +63,7 @@ export class DataTableImporter {
return {
creations: [],
updates: [],
failures: [{ kind: 'module-disabled', usedByWorkflows: workflowsUsing(requirements) }],
failures: [{ kind: 'module-disabled' }],
matchedCount: 0,
};
}
@ -200,7 +200,6 @@ export class DataTableImporter {
failures.push({
kind: 'permission-denied',
missingScope: 'dataTable:create',
usedByWorkflows: workflowsUsing(creations.map(({ requirement }) => requirement)),
});
}
@ -208,7 +207,6 @@ export class DataTableImporter {
failures.push({
kind: 'permission-denied',
missingScope: 'dataTable:update',
usedByWorkflows: workflowsUsing(updates.map(({ requirement }) => requirement)),
});
}
@ -324,8 +322,3 @@ async function hasProjectScope(
): Promise<boolean> {
return await userHasScopes(context.user, [scope], false, { projectId: context.projectId });
}
/** Sorted unique workflow ids referencing the given requirements. */
function workflowsUsing(requirements: PackageDataTableRequirement[]): string[] {
return [...new Set(requirements.flatMap(({ usedByWorkflows }) => usedByWorkflows))].sort();
}

View File

@ -70,7 +70,10 @@ export class DataTableExporter {
requirements.push({
id: dataTable.id,
name: dataTable.name,
usedByWorkflows: usedByWorkflowsById.get(dataTable.id) ?? [],
usedBy: (usedByWorkflowsById.get(dataTable.id) ?? []).map((id) => ({
kind: 'workflow',
id,
})),
});
}

View File

@ -48,7 +48,6 @@ export type DataTableResolutionFailure = {
conflictingTableId?: string;
/** For a rename `name-conflict`: the matched table's current name. */
currentName?: string;
usedByWorkflows: string[];
};
export function createFailure(
@ -72,7 +71,6 @@ export function createFailure(
kind,
sourceId: requirement.id,
name: requirement.name,
usedByWorkflows: [...new Set(requirement.usedByWorkflows)].sort(),
...details,
};
}

View File

@ -27,7 +27,7 @@ function makeImporter() {
}
const request: TagImportRequest = {
requirements: [{ id: 'tag-1', name: 'prod', usedByWorkflows: ['wf-1'] }],
requirements: [{ id: 'tag-1', name: 'prod', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }],
missingMode: 'create',
conflictPolicy: 'skip',
};
@ -40,9 +40,7 @@ describe('TagImporter.plan', () => {
const plan = await importer.plan(contextFor([]), request, appliedWorkflows);
expect(plan.creations).toEqual([{ id: 'tag-1', name: 'prod' }]);
expect(plan.failures).toEqual([
{ kind: 'permission-denied', missingScope: 'tag:create', usedByWorkflows: ['wf-1'] },
]);
expect(plan.failures).toEqual([{ kind: 'permission-denied', missingScope: 'tag:create' }]);
});
it('fails with permission-denied when the user lacks tag:update for a planned rename', async () => {
@ -56,9 +54,7 @@ describe('TagImporter.plan', () => {
);
expect(plan.renames).toEqual([{ id: 'tag-1', from: 'production', to: 'prod' }]);
expect(plan.failures).toEqual([
{ kind: 'permission-denied', missingScope: 'tag:update', usedByWorkflows: ['wf-1'] },
]);
expect(plan.failures).toEqual([{ kind: 'permission-denied', missingScope: 'tag:update' }]);
});
it('fails with permission-denied when the user lacks tag:update for a planned reconcile', async () => {
@ -72,9 +68,7 @@ describe('TagImporter.plan', () => {
);
expect(plan.reconciles).toEqual([{ id: 'tag-1', name: 'prod', oldId: 'tag-9' }]);
expect(plan.failures).toEqual([
{ kind: 'permission-denied', missingScope: 'tag:update', usedByWorkflows: ['wf-1'] },
]);
expect(plan.failures).toEqual([{ kind: 'permission-denied', missingScope: 'tag:update' }]);
});
});

View File

@ -7,11 +7,7 @@ import type {
} from './tag.types';
import { TagConflictPolicy, TagMissingMode } from '../../n8n-packages.types';
/** A per-requirement failure; `TagImporter.plan` fills in `usedByWorkflows`. */
export type TagDecisionFailure = Omit<
TagResolutionFailure,
'kind' | 'sourceId' | 'name' | 'usedByWorkflows'
> & {
export type TagDecisionFailure = Omit<TagResolutionFailure, 'kind' | 'sourceId' | 'name'> & {
kind: Exclude<TagResolutionFailureKind, 'permission-denied'>;
sourceId: string;
name: string;

View File

@ -8,14 +8,7 @@ import { TagService } from '@/services/tag.service';
import { decideTagImportAction } from './tag-import-decision';
import type { TagDecisionFailure } from './tag-import-decision';
import { sortedUnique } from './tag.types';
import type {
ReferencingWorkflow,
TagImportPlan,
TagImportRequest,
TagRef,
TagResolutionFailure,
} from './tag.types';
import type { ReferencingWorkflow, TagImportPlan, TagImportRequest, TagRef } from './tag.types';
import type { ImportContext } from '../../n8n-packages.types';
@Service()
@ -91,32 +84,18 @@ export class TagImporter {
decisionFailures.push(...duplicateWrittenNameFailures(plan));
const sourceWorkflowIdsReferencing = (tagId: string) =>
appliedWorkflows
.filter(({ tagIds }) => tagIds?.includes(tagId))
.map(({ sourceWorkflowId }) => sourceWorkflowId);
plan.failures = decisionFailures.map((failure) => ({
...failure,
usedByWorkflows: sortedUnique(sourceWorkflowIdsReferencing(failure.sourceId)),
}));
plan.failures = decisionFailures;
// Tags are global entities, so gate on the user's global scopes: project-level
// workflow:import alone must not grant instance-wide tag writes.
if (plan.creations.length > 0 && !hasGlobalScope(context.user, 'tag:create')) {
plan.failures.push(
permissionFailure(
'tag:create',
plan.creations.map(({ id }) => id),
sourceWorkflowIdsReferencing,
),
);
plan.failures.push({ kind: 'permission-denied', missingScope: 'tag:create' });
}
const updatedTagIds = [...plan.renames, ...plan.reconciles].map(({ id }) => id);
if (updatedTagIds.length > 0 && !hasGlobalScope(context.user, 'tag:update')) {
plan.failures.push(
permissionFailure('tag:update', updatedTagIds, sourceWorkflowIdsReferencing),
);
if (
(plan.renames.length > 0 || plan.reconciles.length > 0) &&
!hasGlobalScope(context.user, 'tag:update')
) {
plan.failures.push({ kind: 'permission-denied', missingScope: 'tag:update' });
}
return plan;
@ -207,15 +186,3 @@ function duplicateWrittenNameFailures(plan: TagImportPlan): TagDecisionFailure[]
),
];
}
function permissionFailure(
missingScope: 'tag:create' | 'tag:update',
tagIds: string[],
sourceWorkflowIdsReferencing: (tagId: string) => string[],
): TagResolutionFailure {
return {
kind: 'permission-denied',
missingScope,
usedByWorkflows: sortedUnique(tagIds.flatMap(sourceWorkflowIdsReferencing)),
};
}

View File

@ -41,13 +41,13 @@ export class TagExporter {
const requirementsByTagId = new Map<string, PackageTagRequirement>();
for (const { workflowId, tag } of request.usages) {
const requirement = requirementsByTagId.get(tag.id) ?? {
const requirement: PackageTagRequirement = requirementsByTagId.get(tag.id) ?? {
id: tag.id,
name: tag.name,
usedByWorkflows: [],
usedBy: [],
};
if (!requirement.usedByWorkflows.includes(workflowId)) {
requirement.usedByWorkflows.push(workflowId);
if (!requirement.usedBy.some(({ id }) => id === workflowId)) {
requirement.usedBy.push({ kind: 'workflow', id: workflowId });
}
requirementsByTagId.set(tag.id, requirement);
}

View File

@ -51,7 +51,6 @@ export type TagResolutionFailure = {
existingTagId?: string;
/** For `rename-drift`: the current name of the same-id target tag. */
existingName?: string;
usedByWorkflows: string[];
};
export interface TagImportRequest {
@ -81,10 +80,6 @@ export function droppedTagIds(plan: TagImportPlan): ReadonlySet<string> {
return new Set(plan.dropped.map(({ id }) => id));
}
export function sortedUnique(values: string[]): string[] {
return [...new Set(values)].sort();
}
/**
* Gates package tags that lay contradictory claims on one target tag row.
* Example: the package carries tag X `prod` (project A) and tag H `staging`
@ -97,11 +92,9 @@ export function sortedUnique(values: string[]): string[] {
* gate. Identical reconciles in several scopes are the multi-scope union case
* and stay allowed.
*/
export function contestedReconcileTargetFailures(
scopes: Array<{ tagPlan: TagImportPlan; workflows: ReferencingWorkflow[] }>,
): TagResolutionFailure[] {
export function contestedReconcileTargetFailures(plans: TagImportPlan[]): TagResolutionFailure[] {
const reconcilesByKey = new Map<string, TagReconcile>();
for (const { tagPlan } of scopes) {
for (const tagPlan of plans) {
for (const reconcile of tagPlan.reconciles) {
reconcilesByKey.set(JSON.stringify([reconcile.id, reconcile.oldId]), reconcile);
}
@ -110,7 +103,7 @@ export function contestedReconcileTargetFailures(
const reconciles = [...reconcilesByKey.values()];
const claimedTargetIds = new Set(
scopes.flatMap(({ tagPlan }) => [
plans.flatMap((tagPlan) => [
...tagPlan.matched.map(({ id }) => id),
...tagPlan.renames.map(({ id }) => id),
]),
@ -120,13 +113,6 @@ export function contestedReconcileTargetFailures(
oldIdCounts.set(oldId, (oldIdCounts.get(oldId) ?? 0) + 1);
}
const sourceWorkflowIdsReferencing = (tagId: string) =>
scopes.flatMap(({ workflows }) =>
workflows
.filter(({ tagIds }) => tagIds?.includes(tagId))
.map(({ sourceWorkflowId }) => sourceWorkflowId),
);
return reconciles
.filter(({ oldId }) => claimedTargetIds.has(oldId) || (oldIdCounts.get(oldId) ?? 0) > 1)
.map(({ id, name, oldId }) => ({
@ -134,6 +120,5 @@ export function contestedReconcileTargetFailures(
sourceId: id,
name,
existingTagId: oldId,
usedByWorkflows: sortedUnique(sourceWorkflowIdsReferencing(id)),
}));
}

View File

@ -47,8 +47,8 @@ afterEach(() => {
describe('VariableImporter.apply', () => {
const projectCreationPlan: VariableImportPlan = {
matched: [],
missing: [{ name: 'API_KEY', usedByWorkflows: ['wf-1'] }],
creations: [{ name: 'API_KEY', projectId: 'proj-target', usedByWorkflows: ['wf-1'] }],
missing: [{ name: 'API_KEY' }],
creations: [{ name: 'API_KEY', projectId: 'proj-target' }],
conflicts: [],
overwrites: [],
};

View File

@ -109,7 +109,9 @@ describe('VariableExporter', () => {
expect(result.entries).toEqual([
{ id: 'var_url', name: 'API_URL', target: 'variables/apiurl-var_url' },
]);
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
expect(writer.directories).toEqual(['variables/apiurl-var_url']);
expect(writer.files).toHaveLength(1);
expect(writer.files[0].path).toBe('variables/apiurl-var_url/variable.json');
@ -168,7 +170,9 @@ describe('VariableExporter', () => {
expect(result.entries).toEqual([
{ id: 'var_p', name: 'API_URL', target: 'variables/apiurl-var_p' },
]);
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
});
it('bundles a global referenced from a member personal-project workflow', async () => {
@ -190,7 +194,9 @@ describe('VariableExporter', () => {
expect(result.entries).toEqual([
{ id: 'var_g', name: 'API_URL', target: 'variables/apiurl-var_g' },
]);
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
});
});
@ -218,7 +224,9 @@ describe('VariableExporter', () => {
expect(result.entries).toEqual([
{ id: 'var_g', name: 'API_URL', target: 'variables/apiurl-var_g' },
]);
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
});
});
@ -244,7 +252,9 @@ describe('VariableExporter', () => {
});
expect(result.entries).toEqual([]);
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
expect(writer.files).toEqual([]);
});
@ -274,7 +284,15 @@ describe('VariableExporter', () => {
expect(result.entries).toEqual([
{ id: 'var_shared', name: 'API_URL', target: 'variables/apiurl-var_shared' },
]);
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-a', 'wf-b'] }]);
expect(result.requirements).toEqual([
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'wf-a' },
{ kind: 'workflow', id: 'wf-b' },
],
},
]);
});
});
@ -298,7 +316,9 @@ describe('VariableExporter', () => {
expect(result.entries).toEqual([
{ id: 'var_url', name: 'API_URL', target: 'variables/apiurl-var_url' },
]);
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
expect(writer.files).toHaveLength(1);
expect(writer.files[0].path).toBe('variables/apiurl-var_url/variable.json');
expect(jsonParse(writer.files[0].content)).toEqual({ name: 'API_URL', type: 'string' });
@ -306,7 +326,7 @@ describe('VariableExporter', () => {
});
describe('requirement grouping', () => {
it('collapses duplicate (workflow, name) requirements into a single usedByWorkflows entry', async () => {
it('collapses duplicate (workflow, name) requirements into a single workflow consumer', async () => {
const deps = makeExporter();
const variable = makeVariable({ id: 'var_url' });
wireVariables(deps, {
@ -322,7 +342,9 @@ describe('VariableExporter', () => {
includeVariableValues: true,
});
expect(result.requirements).toEqual([{ name: 'API_URL', usedByWorkflows: ['wf-1'] }]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
expect(result.entries).toHaveLength(1);
expect(writer.files).toHaveLength(1);
});
@ -382,7 +404,13 @@ describe('VariableExporter', () => {
'variables/apiurl-var_g',
]);
expect(result.requirements).toEqual([
{ name: 'API_URL', usedByWorkflows: ['wf-global', 'wf-scoped'] },
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'wf-global' },
{ kind: 'workflow', id: 'wf-scoped' },
],
},
]);
});
});

View File

@ -1,5 +1,6 @@
import { Service } from '@n8n/di';
import { hasGlobalScope } from '@n8n/permissions';
import uniqBy from 'lodash/uniqBy';
import { pickVariableForProject } from 'n8n-workflow';
import { VariablesService } from '@/environments.ee/variables/variables.service.ee';
@ -12,12 +13,7 @@ import {
variableMissingModeCreates,
variableMissingModeUsesPackageValue,
} from './variable-missing-mode';
import {
computeVariableLimitFailure,
createFailure,
dedupeCreationsByDestination,
destinationKey,
} from './variable.types';
import { computeVariableLimitFailure, destinationKey } from './variable.types';
import type {
VariableApplyResult,
VariableConflict,
@ -76,27 +72,24 @@ export class VariableImporter {
}
const scope = picked.project ? { projectId: picked.project.id } : {};
const usedByWorkflows = [...new Set(requirement.usedByWorkflows)].sort();
conflicts.push({ name: requirement.name, ...scope, usedByWorkflows });
conflicts.push({ name: requirement.name, ...scope });
if (overwritesConflicts) {
overwrites.push({
variableId: picked.id,
name: requirement.name,
...scope,
value: packageValue,
usedByWorkflows,
});
}
continue;
}
missing.push(createFailure(requirement));
missing.push({ name: requirement.name });
if (createsMissing) {
const value = usesPackageValue ? requirement.packageValue : undefined;
creations.push({
name: requirement.name,
...(requirement.globalPlacement ? {} : { projectId: context.projectId }),
...(value !== undefined ? { value } : {}),
usedByWorkflows: [...new Set(requirement.usedByWorkflows)].sort(),
});
}
}
@ -109,7 +102,7 @@ export class VariableImporter {
if (creations.length === 0) return undefined;
return computeVariableLimitFailure(
dedupeCreationsByDestination(creations),
uniqBy(creations, destinationKey),
await this.variablesService.getRemainingVariableQuota(),
);
}

View File

@ -89,7 +89,10 @@ export class VariableExporter {
);
}
requirements.push({ name, usedByWorkflows });
requirements.push({
name,
usedBy: usedByWorkflows.map((id) => ({ kind: 'workflow', id })),
});
}
return { entries, requirements };

View File

@ -37,20 +37,17 @@ export interface VariableImportRequest {
export interface VariableResolutionFailure {
name: string;
usedByWorkflows: string[];
}
export interface VariableCreation {
name: string;
projectId?: string;
value?: string;
usedByWorkflows: string[];
}
export interface VariableConflict {
name: string;
projectId?: string;
usedByWorkflows: string[];
}
export interface VariableOverwrite {
@ -58,7 +55,6 @@ export interface VariableOverwrite {
name: string;
projectId?: string;
value: string;
usedByWorkflows: string[];
}
export interface VariableLimitFailure {
@ -66,14 +62,6 @@ export interface VariableLimitFailure {
remaining: number;
requested: number;
names: string[];
usedByWorkflows: string[];
}
export function createFailure(requirement: PackageVariableRequirement): VariableResolutionFailure {
return {
name: requirement.name,
usedByWorkflows: [...new Set(requirement.usedByWorkflows)].sort(),
};
}
export function destinationKey(destination: { name: string; projectId?: string }): string {
@ -82,28 +70,11 @@ export function destinationKey(destination: { name: string; projectId?: string }
: `global:${destination.name}`;
}
export function dedupeCreationsByDestination(creations: VariableCreation[]): VariableCreation[] {
const byDestination = new Map<string, VariableCreation>();
for (const creation of creations) {
const key = destinationKey(creation);
const existing = byDestination.get(key);
if (!existing) {
// Copied because the caller goes on to apply these creations.
byDestination.set(key, { ...creation, usedByWorkflows: [...creation.usedByWorkflows] });
continue;
}
existing.usedByWorkflows = [
...new Set([...existing.usedByWorkflows, ...creation.usedByWorkflows]),
].sort();
}
return [...byDestination.values()];
}
/**
* Scopes resolve independently, so two can land on one row — a global neither shadows — and disagree
* about its value, where the last write would silently win.
*/
export function divergentOverwrites(overwrites: VariableOverwrite[]): VariableConflict[] {
export function divergentOverwrites(overwrites: VariableOverwrite[]): VariableOverwrite[] {
const firstValueByRow = new Map<string, string>();
const divergent = new Set<string>();
for (const { variableId, value } of overwrites) {
@ -112,9 +83,7 @@ export function divergentOverwrites(overwrites: VariableOverwrite[]): VariableCo
else if (seen !== value) divergent.add(variableId);
}
return overwrites
.filter(({ variableId }) => divergent.has(variableId))
.map(({ variableId, value, ...conflict }) => conflict);
return overwrites.filter(({ variableId }) => divergent.has(variableId));
}
/** Reports the planned creations that do not fit the remaining quota. `quota` of `null` means unlimited. */
@ -129,7 +98,6 @@ export function computeVariableLimitFailure(
remaining: quota.remaining,
requested: creations.length,
names: [...new Set(creations.map((creation) => creation.name))].sort(),
usedByWorkflows: [...new Set(creations.flatMap((creation) => creation.usedByWorkflows))].sort(),
};
}

View File

@ -1,3 +1,4 @@
import type { MissingNodeTypeRequirement } from '../missing-node-type-mode';
import type { WorkflowEntity } from '@n8n/db';
import type { INode } from 'n8n-workflow';
@ -56,7 +57,11 @@ describe('collectMissingNodeTypes', () => {
const workflows = [prepared('wf-1', [{ type: 'n8n-nodes-base.unknown', typeVersion: 2 }])];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.unknown', typeVersion: 2, usedByWorkflows: ['wf-1'] },
{
type: 'n8n-nodes-base.unknown',
typeVersion: 2,
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
},
]);
});
@ -64,7 +69,7 @@ describe('collectMissingNodeTypes', () => {
const workflows = [prepared('wf-1', [{ type: 'n8n-nodes-base.set', typeVersion: 4 }])];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.set', typeVersion: 4, usedByWorkflows: ['wf-1'] },
{ type: 'n8n-nodes-base.set', typeVersion: 4, usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
]);
});
@ -82,8 +87,19 @@ describe('collectMissingNodeTypes', () => {
];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] },
{ type: 'n8n-nodes-base.unknown', typeVersion: 2, usedByWorkflows: ['wf-2'] },
{
type: 'n8n-nodes-base.unknown',
typeVersion: 1,
usedBy: [
{ kind: 'workflow', id: 'wf-1' },
{ kind: 'workflow', id: 'wf-2' },
],
},
{
type: 'n8n-nodes-base.unknown',
typeVersion: 2,
usedBy: [{ kind: 'workflow', id: 'wf-2' }],
},
]);
});
@ -95,13 +111,22 @@ describe('collectMissingNodeTypes', () => {
];
expect(collectMissingNodeTypes(workflows, getSupportedVersions)).toEqual([
{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] },
{
type: 'n8n-nodes-base.unknown',
typeVersion: 1,
usedBy: [
{ kind: 'workflow', id: 'wf-1' },
{ kind: 'workflow', id: 'wf-2' },
],
},
]);
});
});
describe('missingNodeTypeBlockingFailures', () => {
const missing = [{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1'] }];
const missing: MissingNodeTypeRequirement[] = [
{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedBy: [{ kind: 'workflow', id: 'wf-1' }] },
];
it('fail treats every missing pair as blocking', () => {
expect(missingNodeTypeBlockingFailures('fail', missing)).toEqual(missing);
@ -115,8 +140,22 @@ describe('missingNodeTypeBlockingFailures', () => {
describe('workflowsWithMissingNodeTypes', () => {
it('unions the workflow ids across every missing pair', () => {
const blocked = workflowsWithMissingNodeTypes([
{ type: 'n8n-nodes-base.unknown', typeVersion: 1, usedByWorkflows: ['wf-1', 'wf-2'] },
{ type: 'n8n-nodes-base.other', typeVersion: 2, usedByWorkflows: ['wf-2', 'wf-3'] },
{
type: 'n8n-nodes-base.unknown',
typeVersion: 1,
usedBy: [
{ kind: 'workflow', id: 'wf-1' },
{ kind: 'workflow', id: 'wf-2' },
],
},
{
type: 'n8n-nodes-base.other',
typeVersion: 2,
usedBy: [
{ kind: 'workflow', id: 'wf-2' },
{ kind: 'workflow', id: 'wf-3' },
],
},
]);
expect(blocked).toEqual(new Set(['wf-1', 'wf-2', 'wf-3']));

View File

@ -8,7 +8,7 @@ const wf = (sourceWorkflowId: string) => ({ sourceWorkflowId });
const requiredBy = (id: string, ...parents: string[]): PackageWorkflowRequirement => ({
id,
name: id,
usedByWorkflows: parents,
usedBy: parents.map((id) => ({ kind: 'workflow', id })),
});
const idsOf = (workflows: Array<{ sourceWorkflowId: string }>) =>

View File

@ -339,7 +339,9 @@ describe('WorkflowPublisher', () => {
user,
persisted: [persisted('CHEDDAR'), persisted('BRIE')],
policy: WorkflowPublishingPolicy.PublishAll,
subWorkflowRequirements: [{ id: 'BRIE', name: 'BRIE', usedByWorkflows: ['CHEDDAR'] }],
subWorkflowRequirements: [
{ id: 'BRIE', name: 'BRIE', usedBy: [{ kind: 'workflow', id: 'CHEDDAR' }] },
],
});
expect(activationOrder()).toEqual(['BRIE', 'CHEDDAR']);

View File

@ -23,7 +23,7 @@ function makeExporter(accessibleWorkflows: Array<{ id: string; name: string }> =
}
describe('WorkflowRequirementExporter', () => {
it('groups requirements by referenced workflow and aggregates usedByWorkflows', async () => {
it('groups requirements by referenced workflow and aggregates workflow consumers', async () => {
const { exporter, workflowFinder } = makeExporter();
const result = await exporter.export({
@ -46,12 +46,15 @@ describe('WorkflowRequirementExporter', () => {
{
id: 'wf-child',
name: 'Child',
usedByWorkflows: ['wf-parent-a', 'wf-parent-b'],
usedBy: [
{ kind: 'workflow', id: 'wf-parent-a' },
{ kind: 'workflow', id: 'wf-parent-b' },
],
},
{
id: 'wf-grandchild',
name: 'Grandchild',
usedByWorkflows: ['wf-child'],
usedBy: [{ kind: 'workflow', id: 'wf-child' }],
},
]);
// Every referenced workflow is in the package, so no name lookup is needed.
@ -71,8 +74,8 @@ describe('WorkflowRequirementExporter', () => {
});
expect(result.requirements).toEqual([
{ id: 'wf-known', name: 'Known Child', usedByWorkflows: ['wf-parent'] },
{ id: 'wf-unknown', usedByWorkflows: ['wf-parent'] },
{ id: 'wf-known', name: 'Known Child', usedBy: [{ kind: 'workflow', id: 'wf-parent' }] },
{ id: 'wf-unknown', usedBy: [{ kind: 'workflow', id: 'wf-parent' }] },
]);
expect(result.requirements[1]).not.toHaveProperty('name');
expect(workflowFinder.findWorkflowsByIdsForUser).toHaveBeenCalledWith(

View File

@ -1,6 +1,7 @@
import { collectNodeTypeUsage, type NodeTypeUsage } from './node-type-usage';
import type { PreparedWorkflow } from './workflow-import.types';
import type { MissingNodeTypeMode } from '../../n8n-packages.types';
import { getWorkflowConsumerIds } from '../../spec/requirement-consumers';
/** A `(type, typeVersion)` pair the target instance cannot resolve. Field names align with the manifest requirements shape. */
export type MissingNodeTypeRequirement = NodeTypeUsage;
@ -48,5 +49,5 @@ export function missingNodeTypeBlockingFailures(
/** Package workflow ids that should not be published because they use missing node types. */
export function workflowsWithMissingNodeTypes(missing: MissingNodeTypeRequirement[]): Set<string> {
return new Set(missing.flatMap(({ usedByWorkflows }) => usedByWorkflows));
return new Set(missing.flatMap(getWorkflowConsumerIds));
}

View File

@ -1,5 +1,7 @@
import type { INode } from 'n8n-workflow';
import type { PackageNodeTypeRequirement } from '../../spec/requirements.schema';
/** One workflow's node list, keyed by the id the usage entries should reference. */
export interface WorkflowNodeTypeSource {
workflowId: string;
@ -7,15 +9,11 @@ export interface WorkflowNodeTypeSource {
}
/** A unique `(type, typeVersion)` pair and the workflows that use it. Matches the manifest requirements shape. */
export interface NodeTypeUsage {
type: string;
typeVersion: number;
usedByWorkflows: string[];
}
export type NodeTypeUsage = PackageNodeTypeRequirement;
/**
* Folds every node of the given workflows into unique `(type, typeVersion)`
* pairs with deduped `usedByWorkflows`. Disabled nodes still count — they
* pairs with unique workflow consumers. Disabled nodes still count — they
* render on canvas and are part of the content. Pure — no registry lookups.
*/
export function collectNodeTypeUsage(workflows: WorkflowNodeTypeSource[]): NodeTypeUsage[] {
@ -41,6 +39,6 @@ export function collectNodeTypeUsage(workflows: WorkflowNodeTypeSource[]): NodeT
return [...usage.values()].map(({ type, typeVersion, workflowIds }) => ({
type,
typeVersion,
usedByWorkflows: [...workflowIds],
usedBy: [...workflowIds].map((id) => ({ kind: 'workflow', id })),
}));
}

View File

@ -1,3 +1,4 @@
import { getWorkflowConsumerIds } from '../../spec/requirement-consumers';
import type { PackageWorkflowRequirement } from '../../spec/requirements.schema';
/**
@ -20,7 +21,7 @@ export function orderBySubWorkflowDependencies<T extends { sourceWorkflowId: str
for (const requirement of requirements) {
if (!workflowsById.has(requirement.id)) continue;
for (const callerId of requirement.usedByWorkflows) {
for (const callerId of getWorkflowConsumerIds(requirement)) {
// Ignore callers outside the batch and self-references (a workflow calling itself).
if (!workflowsById.has(callerId) || callerId === requirement.id) continue;
const existing = callers.get(requirement.id);

View File

@ -44,7 +44,7 @@ export class WorkflowRequirementExporter {
);
const requirements = [...usedByWorkflowsByReferencedId].map(
([referencedWorkflowId, usedByWorkflows]) => {
([referencedWorkflowId, usedByWorkflows]): PackageWorkflowRequirement => {
const name =
workflowsById.get(referencedWorkflowId)?.name ??
missingWorkflowNamesById.get(referencedWorkflowId);
@ -52,7 +52,7 @@ export class WorkflowRequirementExporter {
return {
id: referencedWorkflowId,
...(name ? { name } : {}),
usedByWorkflows,
usedBy: usedByWorkflows.map((id) => ({ kind: 'workflow', id })),
};
},
);

View File

@ -23,6 +23,7 @@ import type {
WorkflowPublishingPolicy,
} from './entities/workflow/workflow-publishing-policy.types';
import type { PackageManifest } from './spec/manifest.schema';
import type { PackageRequirementConsumer } from './spec/requirements.schema';
export type { CredentialResolution } from './entities/credential/credential.types';
export { WorkflowPublishingPolicy } from './entities/workflow/workflow-publishing-policy.types';
@ -557,24 +558,33 @@ export type BlockingIssue =
expectedType?: string;
/** For `type_mismatch`: the actual type of the resolved target credential. */
actualType?: string;
usedByWorkflows: string[];
usedBy: PackageRequirementConsumer[];
}
| ({ type: 'project-conflict' } & ProjectConflict)
| ({ type: 'folder-conflict' } & FolderConflict)
| ({ type: 'workflow-removal-forbidden' } & WorkflowRemovalFailure)
| ({ type: 'workflow-removal-conflict' } & WorkflowRemovalConflict)
| ({ type: 'folder-removal-forbidden' } & FolderRemovalFailure)
| ({ type: 'data-table-unresolved' } & DataTableResolutionFailure)
| ({ type: 'tag-unresolved' } & TagResolutionFailure)
| ({ type: 'variable-unresolved' } & VariableResolutionFailure)
| ({ type: 'variable-conflict' } & VariableConflict)
| ({ type: 'variable-limit-exceeded' } & VariableLimitFailure)
| ({
type: 'data-table-unresolved';
usedBy: PackageRequirementConsumer[];
} & DataTableResolutionFailure)
| ({ type: 'tag-unresolved'; usedBy: PackageRequirementConsumer[] } & TagResolutionFailure)
| ({
type: 'variable-unresolved';
usedBy: PackageRequirementConsumer[];
} & VariableResolutionFailure)
| ({ type: 'variable-conflict'; usedBy: PackageRequirementConsumer[] } & VariableConflict)
| ({
type: 'variable-limit-exceeded';
usedBy: PackageRequirementConsumer[];
} & VariableLimitFailure)
| {
type: 'missing-node-type';
/** Node type this instance cannot resolve (at least not at `typeVersion`). */
nodeType: string;
typeVersion: number;
usedByWorkflows: string[];
usedBy: PackageRequirementConsumer[];
}
| {
type: 'policy-violation';

View File

@ -55,8 +55,18 @@ describe('packageManifestSchema', () => {
...validManifest,
requirements: {
credentials: [
{ id: 'cred-1', name: 'A', type: 'githubApi', usedByWorkflows: ['wf-abc'] },
{ id: 'cred-1', name: 'B', type: 'slackApi', usedByWorkflows: ['wf-abc'] },
{
id: 'cred-1',
name: 'A',
type: 'githubApi',
usedBy: [{ kind: 'workflow', id: 'wf-abc' }],
},
{
id: 'cred-1',
name: 'B',
type: 'slackApi',
usedBy: [{ kind: 'workflow', id: 'wf-abc' }],
},
],
},
};
@ -69,8 +79,8 @@ describe('packageManifestSchema', () => {
...validManifest,
requirements: {
dataTables: [
{ id: 'dt-1', name: 'A', usedByWorkflows: ['wf-abc'] },
{ id: 'dt-1', name: 'B', usedByWorkflows: ['wf-abc'] },
{ id: 'dt-1', name: 'A', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] },
{ id: 'dt-1', name: 'B', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] },
],
},
};
@ -82,7 +92,9 @@ describe('packageManifestSchema', () => {
const manifest = {
...validManifest,
requirements: {
workflows: [{ id: 'wf-child', name: 'Child workflow', usedByWorkflows: ['wf-abc'] }],
workflows: [
{ id: 'wf-child', name: 'Child workflow', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] },
],
},
};
@ -96,8 +108,8 @@ describe('packageManifestSchema', () => {
...validManifest,
requirements: {
workflows: [
{ id: 'wf-child', name: 'Child A', usedByWorkflows: ['wf-abc'] },
{ id: 'wf-child', name: 'Child B', usedByWorkflows: ['wf-abc'] },
{ id: 'wf-child', name: 'Child A', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] },
{ id: 'wf-child', name: 'Child B', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] },
],
},
};
@ -143,8 +155,8 @@ describe('packageManifestSchema', () => {
...validManifest,
requirements: {
variables: [
{ name: 'API_URL', usedByWorkflows: ['wf-abc'] },
{ name: 'API_URL', usedByWorkflows: ['wf-abc'] },
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] },
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wf-abc' }] },
],
},
};

View File

@ -5,13 +5,13 @@ describe('packageRequirementsSchema', () => {
id,
name: 'My Credential',
type: 'httpBasicAuth',
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
});
const variable = (name: string) => ({ name, usedByWorkflows: ['wf-1'] });
const variable = (name: string) => ({ name, usedBy: [{ kind: 'workflow', id: 'wf-1' }] });
const nodeType = (type: string, typeVersion: number) => ({
type,
typeVersion,
usedByWorkflows: ['wf-1'],
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
});
it('accepts distinct entries per section', () => {
@ -25,13 +25,17 @@ describe('packageRequirementsSchema', () => {
});
it('accepts a workflow requirement without a name', () => {
const requirements = { workflows: [{ id: 'wf-dep', usedByWorkflows: ['wf-1'] }] };
const requirements = {
workflows: [{ id: 'wf-dep', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }],
};
expect(() => packageRequirementsSchema.parse(requirements)).not.toThrow();
});
it('rejects a workflow requirement with an empty name', () => {
const requirements = { workflows: [{ id: 'wf-dep', name: '', usedByWorkflows: ['wf-1'] }] };
const requirements = {
workflows: [{ id: 'wf-dep', name: '', usedBy: [{ kind: 'workflow', id: 'wf-1' }] }],
};
expect(() => packageRequirementsSchema.parse(requirements)).toThrow();
});
@ -45,7 +49,11 @@ describe('packageRequirementsSchema', () => {
});
it('rejects duplicate tag ids', () => {
const tag = (id: string) => ({ id, name: 'production', usedByWorkflows: ['wf-1'] });
const tag = (id: string) => ({
id,
name: 'production',
usedBy: [{ kind: 'workflow', id: 'wf-1' }],
});
const requirements = { tags: [tag('tag-1'), tag('tag-1')] };
expect(() => packageRequirementsSchema.parse(requirements)).toThrow(/Duplicate tag id: tag-1/);
@ -74,4 +82,17 @@ describe('packageRequirementsSchema', () => {
expect(() => packageRequirementsSchema.parse(requirements)).toThrow();
});
it.each([
{},
{ usedBy: [] },
{ usedBy: [{ kind: 'project', id: 'consumer' }] },
{ usedBy: [{ kind: 'workflow', id: '' }] },
{ usedBy: ['wf-1'] },
{ usedByWorkflows: ['wf-1'] },
])('rejects invalid or legacy consumer references: %j', (usage) => {
expect(() =>
packageRequirementsSchema.parse({ variables: [{ name: 'REGION', ...usage }] }),
).toThrow();
});
});

View File

@ -0,0 +1,19 @@
import sortBy from 'lodash/sortBy';
import uniqBy from 'lodash/uniqBy';
import type { PackageRequirementConsumer } from './requirements.schema';
export function getWorkflowConsumerIds(requirement: {
usedBy: PackageRequirementConsumer[];
}): string[] {
return requirement.usedBy.map(({ id }) => id);
}
export function mergeRequirementConsumers(
consumers: PackageRequirementConsumer[],
): PackageRequirementConsumer[] {
return sortBy(
uniqBy(consumers, ({ kind, id }) => `${kind}:${id}`),
['kind', 'id'],
);
}

View File

@ -1,16 +1,21 @@
import { packageRequirementConsumerSchema } from '@n8n/api-types';
import { z } from 'zod';
const requirementUsage = {
usedBy: z.array(packageRequirementConsumerSchema).min(1),
};
export const packageCredentialRequirementSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
type: z.string().min(1),
usedByWorkflows: z.array(z.string().min(1)).min(1),
...requirementUsage,
});
export const packageDataTableRequirementSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
usedByWorkflows: z.array(z.string().min(1)).min(1),
...requirementUsage,
});
// `name` is best-effort: a `reference-only` export lists workflows that are
@ -19,13 +24,13 @@ export const packageDataTableRequirementSchema = z.object({
export const packageWorkflowRequirementSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
usedByWorkflows: z.array(z.string().min(1)).min(1),
...requirementUsage,
});
export const packageTagRequirementSchema = z.object({
id: z.string().min(1),
name: z.string().min(1),
usedByWorkflows: z.array(z.string().min(1)).min(1),
...requirementUsage,
});
// Node types used by the packaged workflows, folded into unique
@ -35,7 +40,7 @@ export const packageNodeTypeRequirementSchema = z.object({
type: z.string().min(1),
// `finite()`: JSON like `1e999` parses to Infinity (mirrors the workflow node schema).
typeVersion: z.number().finite(),
usedByWorkflows: z.array(z.string().min(1)).min(1),
...requirementUsage,
});
// Variables are keyed by name, not id: a `$vars.<name>` reference resolves
@ -45,7 +50,7 @@ export const packageNodeTypeRequirementSchema = z.object({
// any value the package carries travels in the bundled variable file.
export const packageVariableRequirementSchema = z.object({
name: z.string().min(1),
usedByWorkflows: z.array(z.string().min(1)).min(1),
...requirementUsage,
});
function assertNoDuplicateKey<T>(
@ -110,6 +115,7 @@ export const packageRequirementsSchema = z.object({
),
});
export type PackageRequirementConsumer = z.infer<typeof packageRequirementConsumerSchema>;
export type PackageCredentialRequirement = z.infer<typeof packageCredentialRequirementSchema>;
export type PackageDataTableRequirement = z.infer<typeof packageDataTableRequirementSchema>;
export type PackageWorkflowRequirement = z.infer<typeof packageWorkflowRequirementSchema>;

View File

@ -101,13 +101,20 @@ describe('import-manifest-bridge', () => {
]);
});
it('unions usedByWorkflows when leftover and staging share a requirement key', async () => {
it('unions workflow consumers when leftover and staging share a requirement key', async () => {
await writeTree({
'manifest.json': leftoverManifest({
requirements: {
tags: [
{ id: 't-shared', name: 'prod', usedByWorkflows: ['w1', 'w2'] },
{ id: 't-w1-only', name: 'draft', usedByWorkflows: ['w1'] },
{
id: 't-shared',
name: 'prod',
usedBy: [
{ kind: 'workflow', id: 'w1' },
{ kind: 'workflow', id: 'w2' },
],
},
{ id: 't-w1-only', name: 'draft', usedBy: [{ kind: 'workflow', id: 'w1' }] },
],
},
}),
@ -123,7 +130,7 @@ describe('import-manifest-bridge', () => {
sourceN8nVersion: '1.0.0',
sourceId: 'inst-1',
requirements: {
tags: [{ id: 't-shared', name: 'production', usedByWorkflows: ['w2'] }],
tags: [{ id: 't-shared', name: 'production', usedBy: [{ kind: 'workflow', id: 'w2' }] }],
},
}),
sourceId: 'inst-test',
@ -133,8 +140,15 @@ describe('import-manifest-bridge', () => {
JSON.parse(await readFile(path.join(exportFolder, 'manifest.json'), 'utf-8')),
);
expect(written.requirements?.tags).toEqual([
{ id: 't-shared', name: 'production', usedByWorkflows: ['w1', 'w2'] },
{ id: 't-w1-only', name: 'draft', usedByWorkflows: ['w1'] },
{
id: 't-shared',
name: 'production',
usedBy: [
{ kind: 'workflow', id: 'w1' },
{ kind: 'workflow', id: 'w2' },
],
},
{ id: 't-w1-only', name: 'draft', usedBy: [{ kind: 'workflow', id: 'w1' }] },
]);
});
@ -142,7 +156,7 @@ describe('import-manifest-bridge', () => {
await writeTree({
'manifest.json': leftoverManifest({
requirements: {
tags: [{ id: 't-dropped', name: 'prod', usedByWorkflows: ['w2'] }],
tags: [{ id: 't-dropped', name: 'prod', usedBy: [{ kind: 'workflow', id: 'w2' }] }],
},
}),
'projects/alpha/workflows/w2/workflow.json': JSON.stringify({ id: 'w2', name: 'W2' }),

View File

@ -27,8 +27,14 @@ const manifest: PackageManifest = {
],
requirements: {
variables: [
{ name: 'API_URL', usedByWorkflows: ['wfA', 'wfB'] },
{ name: 'TOKEN', usedByWorkflows: ['wfB'] },
{
name: 'API_URL',
usedBy: [
{ kind: 'workflow', id: 'wfA' },
{ kind: 'workflow', id: 'wfB' },
],
},
{ name: 'TOKEN', usedBy: [{ kind: 'workflow', id: 'wfB' }] },
],
},
};
@ -39,7 +45,9 @@ describe('scopeManifestToProject', () => {
expect(ids(scoped.workflows)).toEqual(['wfA']);
expect(ids(scoped.variables)).toEqual(['varA', 'varT']);
expect(scoped.requirements?.variables).toEqual([{ name: 'API_URL', usedByWorkflows: ['wfA'] }]);
expect(scoped.requirements?.variables).toEqual([
{ name: 'API_URL', usedBy: [{ kind: 'workflow', id: 'wfA' }] },
]);
});
it('resolves a shared name to each project regardless of the entry order', () => {

View File

@ -560,7 +560,7 @@ describe('WorkingCopyUpdater', () => {
projects: [alpha],
workflows: [wf('w1')],
requirements: {
tags: [{ id: 't-old', name: 'prod', usedByWorkflows: ['w1'] }],
tags: [{ id: 't-old', name: 'prod', usedBy: [{ kind: 'workflow', id: 'w1' }] }],
},
}),
files: {

View File

@ -6,7 +6,11 @@ import { N8N_VERSION } from '@/constants';
import { MANIFEST_FILE } from '@/modules/n8n-packages/spec/constants';
import type { ManifestEntry, PackageManifest } from '@/modules/n8n-packages/spec/manifest.schema';
import { packageManifestSchema } from '@/modules/n8n-packages/spec/manifest.schema';
import type { PackageRequirements } from '@/modules/n8n-packages/spec/requirements.schema';
import { getWorkflowConsumerIds } from '@/modules/n8n-packages/spec/requirement-consumers';
import type {
PackageRequirementConsumer,
PackageRequirements,
} from '@/modules/n8n-packages/spec/requirements.schema';
/**
* TEMPORARY bridge: import still inventories a directory package from
@ -66,7 +70,7 @@ function dropSelectedRequirementUsers(
return { ...leftover, ...(requirements ? { requirements } : { requirements: undefined }) };
}
function dropSelectedUsers<T extends { usedByWorkflows: string[] }>(
function dropSelectedUsers<T extends { usedBy: PackageRequirementConsumer[] }>(
rows: T[] | undefined,
selectedWorkflowIds: Set<string>,
): T[] | undefined {
@ -74,9 +78,9 @@ function dropSelectedUsers<T extends { usedByWorkflows: string[] }>(
const kept = rows
.map((row) => ({
...row,
usedByWorkflows: row.usedByWorkflows.filter((id) => !selectedWorkflowIds.has(id)),
usedBy: row.usedBy.filter(({ id }) => !selectedWorkflowIds.has(id)),
}))
.filter((row) => row.usedByWorkflows.length > 0);
.filter((row) => row.usedBy.length > 0);
return kept.length > 0 ? kept : undefined;
}
@ -266,7 +270,7 @@ function requirementsBlock(
return requirements ? { requirements } : {};
}
function mergeRequirementRows<T extends { usedByWorkflows: string[] }>(
function mergeRequirementRows<T extends { usedBy: PackageRequirementConsumer[] }>(
leftover: T[] | undefined,
staging: T[] | undefined,
remainingWorkflowIds: Set<string>,
@ -275,31 +279,38 @@ function mergeRequirementRows<T extends { usedByWorkflows: string[] }>(
): T[] | undefined {
const byKey = new Map<string, T>();
for (const row of leftover ?? []) {
const usedByWorkflows = remainingUsers(
row.usedByWorkflows.filter((id) => !selectedWorkflowIds.has(id)),
const usedBy = remainingUsers(
row.usedBy.filter(({ id }) => !selectedWorkflowIds.has(id)),
remainingWorkflowIds,
);
if (usedByWorkflows.length === 0) continue;
byKey.set(keyOf(row), { ...row, usedByWorkflows });
if (usedBy.length === 0) continue;
byKey.set(keyOf(row), { ...row, usedBy });
}
for (const row of staging ?? []) {
const key = keyOf(row);
const usedByWorkflows = remainingUsers(
[...(byKey.get(key)?.usedByWorkflows ?? []), ...row.usedByWorkflows],
const usedBy = remainingUsers(
[...(byKey.get(key)?.usedBy ?? []), ...row.usedBy],
remainingWorkflowIds,
);
if (usedByWorkflows.length === 0) {
if (usedBy.length === 0) {
byKey.delete(key);
continue;
}
byKey.set(key, { ...row, usedByWorkflows });
byKey.set(key, { ...row, usedBy });
}
const rows = [...byKey.values()];
return rows.length > 0 ? rows : undefined;
}
function remainingUsers(ids: string[], remainingWorkflowIds: Set<string>): string[] {
return [...new Set(ids.filter((id) => remainingWorkflowIds.has(id)))].sort();
function remainingUsers(
usedBy: PackageRequirementConsumer[],
remainingWorkflowIds: Set<string>,
): PackageRequirementConsumer[] {
return [
...new Set(getWorkflowConsumerIds({ usedBy }).filter((id) => remainingWorkflowIds.has(id))),
]
.sort()
.map((id) => ({ kind: 'workflow', id }));
}
function compactRequirements(input: {

View File

@ -37,7 +37,11 @@ import {
type ManifestEntry,
type PackageManifest,
} from '@/modules/n8n-packages/spec/manifest.schema';
import type { PackageRequirements } from '@/modules/n8n-packages/spec/requirements.schema';
import { getWorkflowConsumerIds } from '@/modules/n8n-packages/spec/requirement-consumers';
import type {
PackageRequirementConsumer,
PackageRequirements,
} from '@/modules/n8n-packages/spec/requirements.schema';
import type { SerializedWorkflow } from '@/modules/n8n-packages/spec/serialized/workflow.schema';
import { parsePackageFiles, type PackageFile } from './base-branch-files';
@ -325,13 +329,13 @@ export function scopeManifestToProject(
variables.set(entry.name, entry);
}
}
const scopeRows = <T extends { usedByWorkflows: string[] }>(rows: T[] | undefined) => {
const scopeRows = <T extends { usedBy: PackageRequirementConsumer[] }>(rows: T[] | undefined) => {
const kept = rows
?.map((row) => ({
...row,
usedByWorkflows: row.usedByWorkflows.filter((id) => workflowIds.has(id)),
usedBy: row.usedBy.filter(({ id }) => workflowIds.has(id)),
}))
.filter((row) => row.usedByWorkflows.length > 0);
.filter((row) => row.usedBy.length > 0);
return kept?.length ? kept : undefined;
};
const { requirements } = manifest;
@ -432,7 +436,7 @@ function calculateDependencyImpact({
previous.some(({ path }) => changedPaths.has(path)) ||
(currentPath !== undefined && changedPaths.has(currentPath));
}
for (const workflowId of requirement.usedByWorkflows) {
for (const workflowId of getWorkflowConsumerIds(requirement)) {
dependencyCounts.set(workflowId, (dependencyCounts.get(workflowId) ?? 0) + 1);
if (dependencyChanged) affectedWorkflowIds.add(workflowId);
}

View File

@ -155,15 +155,15 @@ oneOf:
actualType:
type: string
description: 'For `type_mismatch`: the actual type of the resolved target credential.'
usedByWorkflows:
usedBy:
type: array
items:
type: string
$ref: packageRequirementConsumer.generated.yml
required:
- type
- kind
- sourceId
- usedByWorkflows
- usedBy
description: A credential reference that could not be resolved in the target project.
- type: object
properties:
@ -457,14 +457,14 @@ oneOf:
currentName:
type: string
description: "For a rename `name-conflict`: the matched table's current name."
usedByWorkflows:
usedBy:
type: array
items:
type: string
$ref: packageRequirementConsumer.generated.yml
required:
- type
- kind
- usedByWorkflows
- usedBy
description: A data table referenced by the package's workflows that could not be resolved against the target project — under `dataTableMissingMode=must-preexist` or `dataTableSchemaConflictPolicy`, or because the data table module is disabled on this instance, or the caller lacks the scope the resolution needs.
- type: object
properties:
@ -498,15 +498,15 @@ oneOf:
existingName:
type: string
description: 'For `rename-drift`: the current name of the same-id target tag.'
usedByWorkflows:
usedBy:
type: array
items:
type: string
description: Package workflow ids (non-skipped) that reference the source tag — not workflows attached to the contested target tag.
$ref: packageRequirementConsumer.generated.yml
description: Package consumers that reference the source tag. This excludes skipped consumers and workflows attached to the contested target tag.
required:
- type
- kind
- usedByWorkflows
- usedBy
description: "A tag referenced by the package's workflows that could not be resolved on the target instance. `kind` distinguishes the cause: `rename-drift` (the same-id target tag carries a different name — under `tagConflictPolicy=fail`, or `rename` when the package name is held by another tag), `name-collision` (the id is free but the name belongs to a different tag under `tagMissingMode=create` with `tagConflictPolicy=fail`; also raised when two package tags collide with each other, or when the target tag a reconcile would re-key is claimed by another package tag), `invalid-name` / `invalid-id` (the package tag's name or id cannot be written on this instance), or `permission-denied` (the importing user lacks the global `tag:create` / `tag:update` scope the plan needs)."
- type: object
properties:
@ -517,14 +517,14 @@ oneOf:
name:
type: string
description: Requirement name with no match in the target project or global scope.
usedByWorkflows:
usedBy:
type: array
items:
type: string
$ref: packageRequirementConsumer.generated.yml
required:
- type
- name
- usedByWorkflows
- usedBy
description: A variable reference that could not be resolved in the target project or the global scope, under `variableMissingMode=must-preexist`.
- type: object
properties:
@ -538,14 +538,14 @@ oneOf:
projectId:
type: string
description: Project owning the resolved variable. Absent when it resolved at the global scope.
usedByWorkflows:
usedBy:
type: array
items:
type: string
$ref: packageRequirementConsumer.generated.yml
required:
- type
- name
- usedByWorkflows
- usedBy
description: A variable that resolved in the target project or the global scope, but whose value differs from the one the package bundles for it, under `variableConflictPolicy=fail`. Also reported under `overwrite`, once per scope, when the projects of a package resolve one row and disagree about the value it should hold. Values are never reported — only the name and the scope the variable was found in.
- type: object
properties:
@ -567,18 +567,18 @@ oneOf:
items:
type: string
description: The unique variable names the import would create.
usedByWorkflows:
usedBy:
type: array
items:
type: string
description: Package workflow ids that reference any of the listed variables.
$ref: packageRequirementConsumer.generated.yml
description: Package consumers that reference any of the listed variables.
required:
- type
- limit
- remaining
- requested
- names
- usedByWorkflows
- usedBy
description: Creating the package's variables under `create-stub` or `create-with-value` would exceed the instance variable quota (`quota:maxVariables`). Reported once for the whole import; nothing is created.
- type: object
properties:
@ -592,16 +592,16 @@ oneOf:
typeVersion:
type: number
description: Node type version the package's workflows use.
usedByWorkflows:
usedBy:
type: array
items:
type: string
description: Package workflow ids that use this node type and version.
$ref: packageRequirementConsumer.generated.yml
description: Package consumers that use this node type and version.
required:
- type
- nodeType
- typeVersion
- usedByWorkflows
- usedBy
description: A node type — or a version of a node type — used by a package workflow that this instance does not have, under `missingNodeTypeMode=fail`. One issue is reported per missing `(nodeType, typeVersion)` pair.
- type: object
properties:

View File

@ -0,0 +1,12 @@
type: object
properties:
kind:
type: string
enum:
- workflow
id:
type: string
minLength: 1
required:
- kind
- id

View File

@ -80,7 +80,7 @@ async function buildImportPackage(
? {
variables: [{ id: 'var-http-source', name: variable.name, target: variable.target }],
requirements: {
variables: [{ name: variable.name, usedByWorkflows: [wfId] }],
variables: [{ name: variable.name, usedBy: [{ kind: 'workflow', id: wfId }] }],
},
}
: {}),
@ -467,7 +467,7 @@ describe('POST /n8n-packages/import', () => {
type: 'missing-node-type',
nodeType: 'n8n-nodes-community.chatBot',
typeVersion: 1,
usedByWorkflows: ['wf-unknown-node'],
usedBy: [{ kind: 'workflow', id: 'wf-unknown-node' }],
},
],
});