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
---------
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 {