diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts index ebfaf67bae2..3efdd111b14 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.test.ts @@ -543,6 +543,36 @@ describe('generate-types', () => { expect(result).toBe('AssignmentCollectionValue'); }); + it('should map resourceMapper type to structured mapper value', () => { + const prop: NodeProperty = { + name: 'columns', + displayName: 'Columns', + type: 'resourceMapper', + default: { mappingMode: 'defineBelow', value: null }, + }; + + const result = generateTypes.mapPropertyType(prop); + + expect(result).toBe('ResourceMapperValue | Expression'); + }); + + it('should map resourceMapper with loadOptionsDependsOn and noDataExpression to structured mapper value', () => { + const prop: NodeProperty = { + name: 'columns', + displayName: 'Columns', + type: 'resourceMapper', + noDataExpression: true, + default: { mappingMode: 'defineBelow', value: null }, + typeOptions: { + loadOptionsDependsOn: ['sheetName.value'], + }, + }; + + const result = generateTypes.mapPropertyType(prop); + + expect(result).toBe('ResourceMapperValue'); + }); + it('should map string type with multipleValues to an array type', () => { const prop: NodeProperty = { name: 'attendees', @@ -1996,6 +2026,39 @@ describe('generate-types', () => { expect(result).toContain('isTrigger: true'); }); + it('should emit helper type for resourceMapper properties', () => { + const sheetsLikeNode: NodeTypeDescription = { + name: 'n8n-nodes-base.googleSheets', + displayName: 'Google Sheets', + description: 'Read and write rows', + group: ['transform'], + version: 4.7, + inputs: ['main'], + outputs: ['main'], + properties: [ + { + name: 'columns', + displayName: 'Columns', + type: 'resourceMapper', + noDataExpression: true, + default: { mappingMode: 'defineBelow', value: null }, + typeOptions: { + loadOptionsDependsOn: ['sheetName.value'], + }, + }, + ], + }; + + const result = generateTypes.generateNodeTypeFile(sheetsLikeNode); + + expect(result).toContain('type ResourceMapperField = {'); + expect(result).toContain('mappingMode: string;'); + expect(result).toContain('value?: null | Record;'); + expect(result).toContain('schema?: ResourceMapperField[]'); + expect(result).toContain('columns?: ResourceMapperValue;'); + expect(result).not.toContain('columns?: string;'); + }); + // Regression: required string with default: '' used to emit // `fieldToSplitOut?: ...` in the TS type, which dropped the required // signal to LLMs consuming the type. Empty defaults don't satisfy diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts index 958fa617520..0285529f8b8 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-types.ts @@ -105,6 +105,13 @@ ${prefix} AssignmentType = 'string' | 'number' | 'boolean' | 'array' | 'object' ${prefix} AssignmentCollectionValue = { assignments: Array<{ id: string; name: string; value: unknown; type: AssignmentType }> };`; } +function generateResourceMapperTypeDeclaration(exported: boolean): string { + const prefix = exported ? 'export type' : 'type'; + return `${prefix} ResourceMapperField = { id?: string; displayName?: string; required?: boolean; defaultMatch?: boolean; display?: boolean; type?: string; canBeUsedToMatch?: boolean; [key: string]: unknown }; +${prefix} ResourceMapperCommon = { matchingColumns?: string[]; cachedResultName?: string; [key: string]: unknown }; +${prefix} ResourceMapperValue = ResourceMapperCommon & { mappingMode: string; value?: null | Record; schema?: ResourceMapperField[] };`; +} + function isCustomApiCall(operation: string): boolean { return operation === CUSTOM_API_CALL_KEY; } @@ -733,6 +740,8 @@ function mapNestedPropertyTypeInner( return 'string[]'; case 'resourceLocator': return generateResourceLocatorType(prop); + case 'resourceMapper': + return 'ResourceMapperValue | Expression'; case 'filter': return 'FilterValue'; case 'assignmentCollection': @@ -789,6 +798,8 @@ function mapNestedPropertyTypeInner( return 'IDataObject | string | Expression'; case 'resourceLocator': return generateResourceLocatorType(prop); + case 'resourceMapper': + return 'ResourceMapperValue | Expression'; case 'filter': return 'FilterValue'; case 'assignmentCollection': @@ -1425,6 +1436,8 @@ function mapPropertyTypeInner( return 'string[]'; case 'resourceLocator': return generateResourceLocatorType(prop); + case 'resourceMapper': + return 'ResourceMapperValue | Expression'; case 'filter': return 'FilterValue'; case 'assignmentCollection': @@ -1489,6 +1502,9 @@ function mapPropertyTypeInner( case 'resourceLocator': return generateResourceLocatorType(prop); + case 'resourceMapper': + return 'ResourceMapperValue | Expression'; + case 'filter': return 'FilterValue'; @@ -2330,8 +2346,9 @@ export function generateSharedFile( // Helper types const needsFilter = outputProps.some((p) => p.type === 'filter'); const needsAssignment = outputProps.some((p) => p.type === 'assignmentCollection'); + const needsResourceMapper = outputProps.some((p) => p.type === 'resourceMapper'); - if (needsFilter || needsAssignment) { + if (needsFilter || needsAssignment || needsResourceMapper) { lines.push('// Helper types for special n8n fields'); if (needsFilter) { lines.push(generateFilterTypeDeclaration(true)); @@ -2339,6 +2356,9 @@ export function generateSharedFile( if (needsAssignment) { lines.push(generateAssignmentTypeDeclarations(true)); } + if (needsResourceMapper) { + lines.push(generateResourceMapperTypeDeclaration(true)); + } lines.push(''); } @@ -2448,9 +2468,10 @@ export function generateDiscriminatorFile( // Check what helper types we need const needsFilter = props.some((p) => p.type === 'filter'); const needsAssignment = props.some((p) => p.type === 'assignmentCollection'); + const needsResourceMapper = props.some((p) => p.type === 'resourceMapper'); // Inline helper types (only the ones needed) - if (needsFilter || needsAssignment) { + if (needsFilter || needsAssignment || needsResourceMapper) { lines.push('// Helper types for special n8n fields'); if (needsFilter) { lines.push(generateFilterTypeDeclaration(false)); @@ -2458,6 +2479,9 @@ export function generateDiscriminatorFile( if (needsAssignment) { lines.push(generateAssignmentTypeDeclarations(false)); } + if (needsResourceMapper) { + lines.push(generateResourceMapperTypeDeclaration(false)); + } lines.push(''); } @@ -2892,8 +2916,9 @@ export function generateSingleVersionTypeFile( // Helper types (if needed) based on filtered properties const needsFilter = outputProps.some((p) => p.type === 'filter'); const needsAssignment = outputProps.some((p) => p.type === 'assignmentCollection'); + const needsResourceMapper = outputProps.some((p) => p.type === 'resourceMapper'); - if (needsFilter || needsAssignment) { + if (needsFilter || needsAssignment || needsResourceMapper) { lines.push('// Helper types for special n8n fields'); if (needsFilter) { lines.push(generateFilterTypeDeclaration(false)); @@ -2901,6 +2926,9 @@ export function generateSingleVersionTypeFile( if (needsAssignment) { lines.push(generateAssignmentTypeDeclarations(false)); } + if (needsResourceMapper) { + lines.push(generateResourceMapperTypeDeclaration(false)); + } lines.push(''); } @@ -3158,8 +3186,9 @@ export function generateNodeTypeFile(nodes: NodeTypeDescription | NodeTypeDescri // Helper types (if needed) - only add if they'll actually be used in output const needsFilter = outputProps.some((p) => p.type === 'filter'); const needsAssignment = outputProps.some((p) => p.type === 'assignmentCollection'); + const needsResourceMapper = outputProps.some((p) => p.type === 'resourceMapper'); - if (needsFilter || needsAssignment) { + if (needsFilter || needsAssignment || needsResourceMapper) { lines.push('// Helper types for special n8n fields'); if (needsFilter) { lines.push(generateFilterTypeDeclaration(false)); @@ -3167,6 +3196,9 @@ export function generateNodeTypeFile(nodes: NodeTypeDescription | NodeTypeDescri if (needsAssignment) { lines.push(generateAssignmentTypeDeclarations(false)); } + if (needsResourceMapper) { + lines.push(generateResourceMapperTypeDeclaration(false)); + } lines.push(''); } diff --git a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts index e1f84b67ecc..c327751db2d 100644 --- a/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts +++ b/packages/@n8n/workflow-sdk/src/generate-types/generate-zod-schemas.test.ts @@ -90,6 +90,21 @@ describe('mapPropertyToZodSchema for resourceLocator', () => { }); }); +describe('mapPropertyToZodSchema for resourceMapper', () => { + it('returns resourceMapperValueSchema', () => { + const prop: NodeProperty = { + name: 'columns', + displayName: 'Columns', + type: 'resourceMapper', + default: { mappingMode: 'defineBelow', value: null }, + }; + + const schema = mapPropertyToZodSchema(prop); + + expect(schema).toBe('resourceMapperValueSchema'); + }); +}); + describe('isPropertyOptional', () => { // Regression: required: true + default: '' on a `string` field used to be // treated as optional, because any defined default short-circuited the diff --git a/packages/@n8n/workflow-sdk/src/validation/schema-helpers.test.ts b/packages/@n8n/workflow-sdk/src/validation/schema-helpers.test.ts new file mode 100644 index 00000000000..da7630d7922 --- /dev/null +++ b/packages/@n8n/workflow-sdk/src/validation/schema-helpers.test.ts @@ -0,0 +1,64 @@ +import { resourceMapperValueSchema } from './schema-helpers'; + +describe('resourceMapperValueSchema', () => { + it('accepts defineBelow when value is null and schema is present', () => { + const result = resourceMapperValueSchema.safeParse({ + mappingMode: 'defineBelow', + value: null, + schema: [{ id: 'name', displayName: 'Name', type: 'string', required: false }], + }); + + expect(result.success).toBe(true); + }); + + it('accepts defineBelow before schema is loaded', () => { + const result = resourceMapperValueSchema.safeParse({ + mappingMode: 'defineBelow', + value: null, + }); + + expect(result.success).toBe(true); + }); + + it('accepts defineBelow with manual values', () => { + const result = resourceMapperValueSchema.safeParse({ + mappingMode: 'defineBelow', + value: { + name: '={{ $json.name }}', + email: '={{ $json.email }}', + }, + schema: [ + { id: 'name', displayName: 'Name', type: 'string', required: false }, + { id: 'email', displayName: 'Email', type: 'string', required: false }, + ], + }); + + expect(result.success).toBe(true); + }); + + it('accepts unknown mapping modes', () => { + const result = resourceMapperValueSchema.safeParse({ + mappingMode: 'newMappingMode', + value: { + name: '={{ $json.name }}', + }, + }); + + expect(result.success).toBe(true); + }); + + it('accepts autoMapInputData without schema', () => { + const result = resourceMapperValueSchema.safeParse({ + mappingMode: 'autoMapInputData', + value: null, + }); + + expect(result.success).toBe(true); + }); + + it('accepts expression values', () => { + const result = resourceMapperValueSchema.safeParse('={{ $json.columns }}'); + + expect(result.success).toBe(true); + }); +}); diff --git a/packages/@n8n/workflow-sdk/src/validation/schema-helpers.ts b/packages/@n8n/workflow-sdk/src/validation/schema-helpers.ts index f0b0d6e4d45..778f8b4751a 100644 --- a/packages/@n8n/workflow-sdk/src/validation/schema-helpers.ts +++ b/packages/@n8n/workflow-sdk/src/validation/schema-helpers.ts @@ -44,18 +44,41 @@ export type { // ============================================================================= /** - * Resource Mapper Value schema (object format) - * Used for mapping input data to columns/fields + * Resource Mapper field schema. + * Used for dynamic field/column metadata returned by resource mapper methods. */ -const resourceMapperObjectSchema = z +const resourceMapperFieldSchema = z .object({ - mappingMode: z.string(), - value: z.unknown().optional(), - schema: z.array(z.unknown()).optional(), + id: z.string().optional(), + displayName: z.string().optional(), + required: z.boolean().optional(), + defaultMatch: z.boolean().optional(), + display: z.boolean().optional(), + type: z.string().optional(), + canBeUsedToMatch: z.boolean().optional(), + }) + .passthrough(); + +/** + * Shared Resource Mapper fields. + */ +const resourceMapperCommonSchema = z + .object({ + matchingColumns: z.array(z.string()).optional(), cachedResultName: z.string().optional(), }) .passthrough(); +/** + * Resource Mapper Value schema (object format). + * Used for mapping input data to columns/fields + */ +const resourceMapperObjectSchema = resourceMapperCommonSchema.extend({ + mappingMode: z.string(), + value: z.union([z.null(), z.record(z.string(), z.unknown())]).optional(), + schema: z.array(resourceMapperFieldSchema).optional(), +}); + /** * Resource Mapper Value schema - accepts object format OR expression */