mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-17 05:29:44 +02:00
refactor(core): Use typed package requirement consumers (#40621)
This commit is contained in:
parent
13679fcb97
commit
806a2aa94b
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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`),
|
||||
|
|
|
|||
|
|
@ -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 }] }),
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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)!;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 }]);
|
||||
|
|
|
|||
|
|
@ -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' }],
|
||||
},
|
||||
]),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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('');
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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'])]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -208,7 +208,6 @@ function orphanBindingFailures(
|
|||
kind: 'source_not_found',
|
||||
sourceId,
|
||||
targetId,
|
||||
usedByWorkflows: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -89,7 +89,10 @@ export class VariableExporter {
|
|||
);
|
||||
}
|
||||
|
||||
requirements.push({ name, usedByWorkflows });
|
||||
requirements.push({
|
||||
name,
|
||||
usedBy: usedByWorkflows.map((id) => ({ kind: 'workflow', id })),
|
||||
});
|
||||
}
|
||||
|
||||
return { entries, requirements };
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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']));
|
||||
|
|
|
|||
|
|
@ -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 }>) =>
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 })),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
);
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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' }),
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,12 @@
|
|||
type: object
|
||||
properties:
|
||||
kind:
|
||||
type: string
|
||||
enum:
|
||||
- workflow
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
required:
|
||||
- kind
|
||||
- id
|
||||
|
|
@ -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' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user