From c3a3cf9c67adc62b545d80b794404c88ddf696c2 Mon Sep 17 00:00:00 2001
From: Marie <51697796+ijreilly@users.noreply.github.com>
Date: Thu, 26 Mar 2026 19:25:12 +0400
Subject: [PATCH] [Apps SDK] Add error message if relationType is missing
(#19006)
Before
After
---
.../__tests__/manifest-validate.spec.ts | 169 +++++++++++++++++-
.../build/manifest/manifest-validate.ts | 69 ++++++-
.../validate-junction-target-settings.util.ts | 4 +
...lation-flat-field-join-column-name.util.ts | 13 +-
...h-or-relation-flat-field-on-delete.util.ts | 4 +
5 files changed, 253 insertions(+), 6 deletions(-)
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts
index 8112b02d8c..f62d0a6427 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/__tests__/manifest-validate.spec.ts
@@ -3,7 +3,7 @@ import {
type Manifest,
type FieldManifest,
} from 'twenty-shared/application';
-import { FieldMetadataType } from 'twenty-shared/types';
+import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { manifestValidate } from '@/cli/utilities/build/manifest/manifest-validate';
const validApplication: ApplicationManifest = {
@@ -193,4 +193,171 @@ describe('manifestValidate', () => {
expect(result.warnings).toContain('No front component defined');
});
});
+
+ describe('relation field validation', () => {
+ it('should fail when a RELATION field in fields is missing relationType', () => {
+ const relationFieldWithoutSettings = {
+ objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
+ universalIdentifier: '550e8400-e29b-41d4-a716-446655440010',
+ type: FieldMetadataType.RELATION,
+ name: 'company',
+ label: 'Company',
+ relationTargetFieldMetadataUniversalIdentifier:
+ '550e8400-e29b-41d4-a716-446655440011',
+ relationTargetObjectMetadataUniversalIdentifier:
+ '20202020-b374-4779-a561-80086cb2e17f',
+ } as unknown as FieldManifest;
+
+ const result = manifestValidate({
+ ...validManifest,
+ fields: [relationFieldWithoutSettings],
+ });
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors[0]).toContain('company');
+ expect(result.errors[0]).toContain('missing relationType');
+ });
+
+ it('should fail when a RELATION field in object fields is missing relationType', () => {
+ const result = manifestValidate({
+ ...validManifest,
+ objects: [
+ {
+ universalIdentifier: 'obj-uuid',
+ nameSingular: 'recipient',
+ namePlural: 'recipients',
+ labelSingular: 'Recipient',
+ labelPlural: 'Recipients',
+ labelIdentifierFieldMetadataUniversalIdentifier: 'label-field-uuid',
+ fields: [
+ {
+ universalIdentifier: 'label-field-uuid',
+ type: FieldMetadataType.TEXT,
+ name: 'name',
+ label: 'Name',
+ },
+ {
+ universalIdentifier: '550e8400-e29b-41d4-a716-446655440012',
+ type: FieldMetadataType.RELATION,
+ name: 'company',
+ label: 'Company',
+ relationTargetFieldMetadataUniversalIdentifier:
+ '550e8400-e29b-41d4-a716-446655440013',
+ relationTargetObjectMetadataUniversalIdentifier:
+ '20202020-b374-4779-a561-80086cb2e17f',
+ } as unknown as FieldManifest,
+ ],
+ },
+ ],
+ });
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors[0]).toContain('company');
+ expect(result.errors[0]).toContain('missing relationType');
+ });
+
+ it('should pass when a RELATION field has valid universalSettings with relationType', () => {
+ const validRelationField = {
+ objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
+ universalIdentifier: '550e8400-e29b-41d4-a716-446655440014',
+ type: FieldMetadataType.RELATION,
+ name: 'company',
+ label: 'Company',
+ relationTargetFieldMetadataUniversalIdentifier:
+ '550e8400-e29b-41d4-a716-446655440015',
+ relationTargetObjectMetadataUniversalIdentifier:
+ '20202020-b374-4779-a561-80086cb2e17f',
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ joinColumnName: 'companyId',
+ },
+ } as unknown as FieldManifest;
+
+ const result = manifestValidate({
+ ...validManifest,
+ fields: [validRelationField],
+ });
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('should fail when a MANY_TO_ONE field is missing joinColumnName', () => {
+ const manyToOneWithoutJoinColumn = {
+ objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
+ universalIdentifier: '550e8400-e29b-41d4-a716-446655440018',
+ type: FieldMetadataType.RELATION,
+ name: 'company',
+ label: 'Company',
+ relationTargetFieldMetadataUniversalIdentifier:
+ '550e8400-e29b-41d4-a716-446655440019',
+ relationTargetObjectMetadataUniversalIdentifier:
+ '20202020-b374-4779-a561-80086cb2e17f',
+ universalSettings: {
+ relationType: RelationType.MANY_TO_ONE,
+ },
+ } as unknown as FieldManifest;
+
+ const result = manifestValidate({
+ ...validManifest,
+ fields: [manyToOneWithoutJoinColumn],
+ });
+
+ expect(result.isValid).toBe(false);
+ expect(result.isValid).toBe(false);
+ expect(result.errors[0]).toContain('company');
+ expect(result.errors[0]).toContain('missing joinColumnName');
+ });
+
+ it('should pass when a ONE_TO_MANY field has no joinColumnName', () => {
+ const oneToManyField = {
+ objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
+ universalIdentifier: '550e8400-e29b-41d4-a716-446655440020',
+ type: FieldMetadataType.RELATION,
+ name: 'contacts',
+ label: 'Contacts',
+ relationTargetFieldMetadataUniversalIdentifier:
+ '550e8400-e29b-41d4-a716-446655440021',
+ relationTargetObjectMetadataUniversalIdentifier:
+ '20202020-b374-4779-a561-80086cb2e17f',
+ universalSettings: {
+ relationType: RelationType.ONE_TO_MANY,
+ },
+ } as unknown as FieldManifest;
+
+ const result = manifestValidate({
+ ...validManifest,
+ fields: [oneToManyField],
+ });
+
+ expect(result.isValid).toBe(true);
+ expect(result.errors).toHaveLength(0);
+ });
+
+ it('should fail when a RELATION field has an invalid relationType', () => {
+ const relationFieldWithBadType = {
+ objectUniversalIdentifier: '20202020-b374-4779-a561-80086cb2e17f',
+ universalIdentifier: '550e8400-e29b-41d4-a716-446655440016',
+ type: FieldMetadataType.RELATION,
+ name: 'company',
+ label: 'Company',
+ relationTargetFieldMetadataUniversalIdentifier:
+ '550e8400-e29b-41d4-a716-446655440017',
+ relationTargetObjectMetadataUniversalIdentifier:
+ '20202020-b374-4779-a561-80086cb2e17f',
+ universalSettings: {
+ relationType: 'INVALID_TYPE',
+ },
+ } as unknown as FieldManifest;
+
+ const result = manifestValidate({
+ ...validManifest,
+ fields: [relationFieldWithBadType],
+ });
+
+ expect(result.isValid).toBe(false);
+ expect(result.errors[0]).toContain('company');
+ expect(result.errors[0]).toContain('invalid relationType');
+ });
+ });
});
diff --git a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts
index 370bca7dae..d930c5ee43 100644
--- a/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts
+++ b/packages/twenty-sdk/src/cli/utilities/build/manifest/manifest-validate.ts
@@ -1,6 +1,17 @@
-import { type Manifest } from 'twenty-shared/application';
+import { type FieldManifest, type Manifest } from 'twenty-shared/application';
+import { FieldMetadataType, RelationType } from 'twenty-shared/types';
import { isNonEmptyArray } from 'twenty-shared/utils';
+const RELATION_FIELD_TYPES: string[] = [
+ FieldMetadataType.RELATION,
+ FieldMetadataType.MORPH_RELATION,
+];
+
+const VALID_RELATION_TYPES: string[] = [
+ RelationType.MANY_TO_ONE,
+ RelationType.ONE_TO_MANY,
+];
+
const extractDuplicates = (values: string[]): string[] => {
const seen = new Set();
const duplicates = new Set();
@@ -34,6 +45,52 @@ const findUniversalIdentifiers = (obj: object): string[] => {
return universalIdentifiers;
};
+
+const validateRelationFields = (
+ fields: Pick[],
+): string[] => {
+ const errors: string[] = [];
+
+ for (const field of fields) {
+ if (!RELATION_FIELD_TYPES.includes(field.type)) {
+ continue;
+ }
+
+ const settings = field.universalSettings as
+ | { relationType?: string; joinColumnName?: string | null }
+ | null
+ | undefined;
+
+ if (!settings?.relationType) {
+ errors.push(
+ `Relation field "${field.name}" is missing relationType. ` +
+ `${field.type} fields must declare a relationType (${VALID_RELATION_TYPES.join(' or ')}) in universalSettings.`,
+ );
+ continue;
+ }
+
+ if (!VALID_RELATION_TYPES.includes(settings.relationType)) {
+ errors.push(
+ `Relation field "${field.name}" has invalid relationType "${settings.relationType}". ` +
+ `Expected ${VALID_RELATION_TYPES.join(' or ')}.`,
+ );
+ continue;
+ }
+
+ if (
+ settings.relationType === RelationType.MANY_TO_ONE &&
+ !settings.joinColumnName
+ ) {
+ errors.push(
+ `MANY_TO_ONE relation field "${field.name}" is missing joinColumnName. ` +
+ `MANY_TO_ONE relations must declare a joinColumnName in universalSettings.`,
+ );
+ }
+ }
+
+ return errors;
+};
+
export const manifestValidate = (manifest: Manifest) => {
const errors: string[] = [];
const warnings: string[] = [];
@@ -56,5 +113,15 @@ export const manifestValidate = (manifest: Manifest) => {
warnings.push('No front component defined');
}
+ const allFields: Pick<
+ FieldManifest,
+ 'type' | 'name' | 'universalSettings'
+ >[] = [
+ ...manifest.fields,
+ ...manifest.objects.flatMap((object) => object.fields),
+ ];
+
+ errors.push(...validateRelationFields(allFields));
+
return { errors, warnings, isValid: errors.length === 0 };
};
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts
index db95f7a287..38b5c8d573 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-junction-target-settings.util.ts
@@ -34,6 +34,10 @@ export const validateJunctionTargetSettings = ({
}: ValidateJunctionTargetSettingsArgs): FlatFieldMetadataValidationError[] => {
const { universalSettings } = universalFlatFieldMetadata;
+ if (!isDefined(universalSettings)) {
+ return [];
+ }
+
const junctionTargetFieldUniversalIdentifier =
universalSettings.junctionTargetFieldUniversalIdentifier;
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts
index 218ce20f11..c2860b5002 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-join-column-name.util.ts
@@ -1,4 +1,3 @@
-import { msg } from '@lingui/core/macro';
import { RelationType } from 'twenty-shared/types';
import { assertUnreachable, isDefined } from 'twenty-shared/utils';
@@ -25,6 +24,15 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
+ if (!isDefined(universalFlatFieldMetadata.universalSettings?.relationType)) {
+ errors.push({
+ code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
+ message: `Relation field "${universalFlatFieldMetadata.name}" is missing relationType in universalSettings. Expected MANY_TO_ONE or ONE_TO_MANY.`,
+ });
+
+ return errors;
+ }
+
switch (universalFlatFieldMetadata.universalSettings.relationType) {
case RelationType.MANY_TO_ONE: {
if (
@@ -34,7 +42,6 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message:
'Many to one field metadata should carry the join column name in its settings',
- userFriendlyMessage: msg`A many to one relation field should always declare a join column`,
});
return errors;
@@ -50,7 +57,6 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({
errors.push({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message: 'Could not find relation field parent flat object',
- userFriendlyMessage: msg`Could not find relation field parent flat object`,
});
return errors;
@@ -74,7 +80,6 @@ export const validateMorphOrRelationFlatFieldJoinColumName = ({
code: FieldMetadataExceptionCode.INVALID_FIELD_INPUT,
message:
'One to many field metadata should not carry the join column name in its settings',
- userFriendlyMessage: msg`A one to many relation field should never declare a join column`,
});
}
break;
diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts
index 11771b5c81..80a8e20b35 100644
--- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts
+++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/validators/utils/validate-morph-or-relation-flat-field-on-delete.util.ts
@@ -14,6 +14,10 @@ export const validateMorphOrRelationFlatFieldOnDelete = ({
}): FlatFieldMetadataValidationError[] => {
const errors: FlatFieldMetadataValidationError[] = [];
+ if (!isDefined(universalFlatFieldMetadata.universalSettings?.relationType)) {
+ return errors;
+ }
+
if (
isDefined(universalFlatFieldMetadata.universalSettings.onDelete) &&
universalFlatFieldMetadata.universalSettings.relationType !==