From cf91b87892952816e03481b39ddfa3df2ecfb87d Mon Sep 17 00:00:00 2001 From: Abhinav A P <85828531+abhinavapasok@users.noreply.github.com> Date: Thu, 25 Jun 2026 13:35:31 +0530 Subject: [PATCH] fix(server): skip defaultValue null check for relation/morph fields on update (#21875) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Updating any metadata property (e.g. `description`, `label`) of an existing **non-nullable RELATION** field fails with: ``` INVALID_FIELD_INPUT: Default value cannot be null for non-nullable fields ``` A relation field has no literal `defaultValue` (it's always `null`), so the update-path validator rejects every required relation. **Creating** the same field is fine — only **updates** fail. This also blocks any incremental app re-sync (`yarn twenty dev --once`) whose diff touches a required relation field. ## Fix Added a guard in `FlatFieldMetadataValidatorService.validateFlatFieldMetadataUpdate()` using the already-imported `isMorphOrRelationUniversalFlatFieldMetadata` utility to skip the `defaultValue === null` check for relation/morph field types: ```diff if ( + !isMorphOrRelationUniversalFlatFieldMetadata( + flatFieldMetadataToValidate, + ) && flatFieldMetadataToValidate.isNullable === false && flatFieldMetadataToValidate.defaultValue === null ) { ``` ### Why this works: - Relation fields represent foreign key relationships, not columns with literal defaults - The same guard is already used at line 144 in the same method for relation-specific validation - The create path (`validateFlatFieldMetadataCreation`) never had this check, which is why creation always worked - No new imports needed — `isMorphOrRelationUniversalFlatFieldMetadata` is already imported on line 14 ## Verification - `npx nx build twenty-server` ✅ compiles successfully Fixes #21751 Review in cubic --------- Co-authored-by: Charles Bochet Co-authored-by: prastoin --- .../flat-field-metadata-validator.service.ts | 2 + ...tadata-relation-update.integration-spec.ts | 50 +++++++++++++++++++ .../src/types/FieldMetadataDefaultValue.ts | 16 +++++- ...-default-value-classification.type-test.ts | 19 +++++++ packages/twenty-shared/src/types/index.ts | 6 ++- 5 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 packages/twenty-shared/src/types/__tests__/field-metadata-default-value-classification.type-test.ts diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts index d952d1a3d8..507d4096fb 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { msg } from '@lingui/core/macro'; import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isFieldMetadataTypeWithDefaultValue } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { FieldMetadataExceptionCode } from 'src/engine/metadata-modules/field-metadata/field-metadata.exception'; @@ -191,6 +192,7 @@ export class FlatFieldMetadataValidatorService { } if ( + isFieldMetadataTypeWithDefaultValue(flatFieldMetadataToValidate.type) && flatFieldMetadataToValidate.isNullable === false && flatFieldMetadataToValidate.defaultValue === null ) { diff --git a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts index 0c8dadb85a..d5f7dcabab 100644 --- a/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/field-metadata/relation/successful-field-metadata-relation-update.integration-spec.ts @@ -142,6 +142,56 @@ describe('Field metadata relation update should succeed', () => { expect(data).toBeDefined(); expect(data.updateOneField.name).toBe('leadEmployer'); }); + + // Regression test for https://github.com/twentyhq/twenty/issues/21751: a non-nullable relation has no literal defaultValue and must still be updatable. + it('should successfully update a non-nullable relation field', async () => { + const { + data: { + createOneField: { id: nonNullableRelationFieldId, isNullable }, + }, + } = await createOneFieldMetadata({ + input: { + objectMetadataId: globalTestContext.employeeObjectId, + name: 'mandatoryEmployer', + label: 'Mandatory employer', + isLabelSyncedWithName: false, + isNullable: false, + type: FieldMetadataType.RELATION, + relationCreationPayload: { + targetFieldLabel: 'mandatoryEmployees', + type: RelationType.MANY_TO_ONE, + targetObjectMetadataId: globalTestContext.enterpriseObjectId, + targetFieldIcon: 'IconBuildingSkyscraper', + }, + }, + gqlFields: ` + id + isNullable + `, + }); + + expect(isNullable).toBe(false); + + const { data, errors } = await updateOneFieldMetadata({ + expectToFail: false, + input: { + idToUpdate: nonNullableRelationFieldId, + updatePayload: { + description: 'Updated description for a required relation', + }, + }, + gqlFields: ` + id + description + `, + }); + + expect(errors).toBeUndefined(); + expect(data).toBeDefined(); + expect(data.updateOneField.description).toBe( + 'Updated description for a required relation', + ); + }); }); describe('Field metadata self-relation update should succeed', () => { diff --git a/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts b/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts index e72bea9536..bfede7273e 100644 --- a/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts +++ b/packages/twenty-shared/src/types/FieldMetadataDefaultValue.ts @@ -1,5 +1,5 @@ import { type LinkMetadata } from '@/types/composite-types/links.composite-type'; -import { type FieldMetadataType } from '@/types/FieldMetadataType'; +import { FieldMetadataType } from '@/types/FieldMetadataType'; import { type IsExactly } from '@/types/IsExactly'; export const fieldMetadataDefaultValueFunctionName = { @@ -111,3 +111,17 @@ export type FieldMetadataDefaultValue< : T extends keyof FieldMetadataDefaultValueMapping ? FieldMetadataDefaultValueMapping[T] : never | null; + +export const FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE = [ + FieldMetadataType.RELATION, + FieldMetadataType.MORPH_RELATION, + FieldMetadataType.FILES, + FieldMetadataType.TS_VECTOR, +] as const satisfies FieldMetadataType[]; + +export const isFieldMetadataTypeWithDefaultValue = ( + type: FieldMetadataType, +): boolean => + !( + FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE as readonly FieldMetadataType[] + ).includes(type); diff --git a/packages/twenty-shared/src/types/__tests__/field-metadata-default-value-classification.type-test.ts b/packages/twenty-shared/src/types/__tests__/field-metadata-default-value-classification.type-test.ts new file mode 100644 index 0000000000..789f1a54fe --- /dev/null +++ b/packages/twenty-shared/src/types/__tests__/field-metadata-default-value-classification.type-test.ts @@ -0,0 +1,19 @@ +import { type Equal, type Expect } from '@/testing'; +import { + type FieldMetadataDefaultValueMapping, + type FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE, +} from '@/types/FieldMetadataDefaultValue'; +import { type FieldMetadataType } from '@/types/FieldMetadataType'; + +type ClassifiedFieldMetadataType = + | keyof FieldMetadataDefaultValueMapping + | (typeof FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE)[number]; + +type Unclassified = Exclude; +type ExtraClassified = Exclude; + +// oxlint-disable-next-line unused-imports/no-unused-vars +type Assertions = [ + Expect>, + Expect>, +]; diff --git a/packages/twenty-shared/src/types/index.ts b/packages/twenty-shared/src/types/index.ts index cd1e3ab8a9..e855a4c4c1 100644 --- a/packages/twenty-shared/src/types/index.ts +++ b/packages/twenty-shared/src/types/index.ts @@ -92,7 +92,11 @@ export type { FieldMetadataDefaultValueForAnyType, FieldMetadataDefaultValue, } from './FieldMetadataDefaultValue'; -export { fieldMetadataDefaultValueFunctionName } from './FieldMetadataDefaultValue'; +export { + fieldMetadataDefaultValueFunctionName, + FIELD_METADATA_TYPES_WITHOUT_DEFAULT_VALUE, + isFieldMetadataTypeWithDefaultValue, +} from './FieldMetadataDefaultValue'; export type { FieldMetadataMultiItemSettings } from './FieldMetadataMultiItemSettings'; export { FieldMetadataSettingsOnClickAction } from './FieldMetadataMultiItemSettings'; export type {