[TYPES] UniversalEntity JsonbProperty and SerializedRelation (#17396)

# Introduction

In this PR we're introducing mainly two branded type signatures for both
`JsonbProperty` entities properties and `SerializedRelation` (jsonb
serialized property storing another entity id).

Allowing to dynamically map over them later in order to build universal
`jsonb` `serialized` relations.

## `JsonbProperty`

A branded wrapper type that marks entity properties stored as PostgreSQL
JSONB columns. It adds a phantom brand `__JsonbPropertyBrand__` to
object types while leaving primitives unchanged. The branded key is
optional and typed as never, also omitted when transpiled to
`UniversalFlat`

**Should be used at entities lvl only:**
```typescript
@Column({ type: 'jsonb', nullable: false })
gridPosition: JsonbProperty<GridPosition>;

@Column({ nullable: false, type: 'jsonb', default: [] })
publishedVersions: JsonbProperty<string[]>;
```

## `SerializedRelation`

A branded string type that marks foreign key IDs stored inside JSONB
objects. These are entity references serialized within a JSONB column
rather than being a regular database foreign key.

**Usage in jsonb property generic***
```ts
type FieldMetadataRelationSettings = {
  relationType: RelationType;
  onDelete?: RelationOnDeleteAction;
  joinColumnName?: string | null;
  junctionTargetFieldId?: SerializedRelation;
};
```

## `FormatJsonbSerializedRelation<T>`

A transformation type that processes JSONB properties for universal
entity mapping. It:
1. Detects properties with the `JsonbProperty` brand
2. Finds `SerializedRelation` properties
3. Renames them from `*Id` to `*UniversalIdentifier`
4. Removes the brand from the output type ( optional though )

```typescript
// Input: JsonbProperty<{ targetFieldMetadataId: SerializedRelation }>
// Output: { targetFieldMetadataUniversalIdentifier: SerializedRelation }
```

## Result
An example of the dynamic type mapping, through a type-test example
```ts
type SettingsTestCase = UniversalFlatFieldMetadata<
    | FieldMetadataType.RELATION
    | FieldMetadataType.NUMBER
    | FieldMetadataType.TEXT
  >['settings']

type SettingsExpectedResult =
  | {
      relationType: RelationType;
      onDelete?: RelationOnDeleteAction | undefined;
      joinColumnName?: string | null | undefined;
      junctionTargetFieldUniversalIdentifier?: SerializedRelation | undefined;
    }
  | {
      dataType?: NumberDataType | undefined;
      decimals?: number | undefined;
      type?: FieldNumberVariant | undefined;
    }
  | {
      displayedMaxRows?: number | undefined;
    }
  | null;

type Assertions = [
  Expect<Equal<SettingsTestCase, SettingsExpectedResult>>,
]
```

## Remarks

- Removed duplicated twenty-server and twenty-shared typed
- Removed class validator instances for default value that were not used
at runtime, we will refactor that to add validation across all entities
following a same pattern
This commit is contained in:
Paul Rastoin
2026-01-26 15:25:43 +01:00
committed by GitHub
parent d0bc9a94c0
commit 44202668fd
62 changed files with 1082 additions and 1070 deletions
@@ -1,95 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { validateDefaultValueForType } from 'src/engine/metadata-modules/field-metadata/utils/validate-default-value-for-type.util';
describe('validateDefaultValueForType', () => {
it('should return true for null defaultValue', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, null).isValid,
).toBe(true);
});
// Dynamic default values
it('should validate uuid dynamic default value for UUID type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.UUID, 'uuid').isValid,
).toBe(true);
});
it('should validate now dynamic default value for DATE_TIME type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.DATE_TIME, 'now').isValid,
).toBe(true);
});
it('should return false for mismatched dynamic default value', () => {
expect(
validateDefaultValueForType(FieldMetadataType.UUID, 'now').isValid,
).toBe(false);
});
// Static default values
it('should validate string default value for TEXT type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, "'test'").isValid,
).toBe(true);
});
it('should return false for invalid string default value for TEXT type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.TEXT, 123).isValid,
).toBe(false);
});
it('should validate number default value for NUMBER type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, 100).isValid,
).toBe(true);
});
it('should return false for invalid number default value for NUMBER type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.NUMBER, '100').isValid,
).toBe(false);
});
it('should validate boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, true).isValid,
).toBe(true);
});
it('should return false for invalid boolean default value for BOOLEAN type', () => {
expect(
validateDefaultValueForType(FieldMetadataType.BOOLEAN, 'true').isValid,
).toBe(false);
});
// CURRENCY type
it('should validate CURRENCY default value', () => {
expect(
validateDefaultValueForType(FieldMetadataType.CURRENCY, {
amountMicros: '100',
currencyCode: "'USD'",
}).isValid,
).toBe(true);
});
it('should return false for invalid CURRENCY default value', () => {
expect(
validateDefaultValueForType(
// @ts-expect-error Just for testing purposes
{ amountMicros: 100, currencyCode: "'USD'" },
FieldMetadataType.CURRENCY,
).isValid,
).toBe(false);
});
// Unknown type
it('should return false for unknown type', () => {
expect(
validateDefaultValueForType('unknown' as FieldMetadataType, "'test'")
.isValid,
).toBe(false);
});
});
@@ -1,6 +1,8 @@
import { FieldActorSource, FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import {
FieldActorSource,
type FieldMetadataDefaultValue,
FieldMetadataType,
} from 'twenty-shared/types';
// No need to refactor as unused in workspace migration v2
export function generateDefaultValue(
@@ -1,15 +1,12 @@
import {
type FieldMetadataDefaultSerializableValue,
type FieldMetadataFunctionDefaultValue,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import {
type FieldMetadataDefaultValueForAnyType,
type FieldMetadataDefaultValueFunctionNames,
type FieldMetadataFunctionDefaultValue,
fieldMetadataDefaultValueFunctionName,
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
} from 'twenty-shared/types';
export const isFunctionDefaultValue = (
defaultValue: FieldMetadataDefaultSerializableValue,
defaultValue: FieldMetadataDefaultValueForAnyType,
): defaultValue is FieldMetadataFunctionDefaultValue => {
return (
typeof defaultValue === 'string' &&
@@ -1,4 +1,4 @@
import { type FieldMetadataDefaultSerializableValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
import {
FieldMetadataException,
@@ -8,7 +8,7 @@ import { isFunctionDefaultValue } from 'src/engine/metadata-modules/field-metada
import { serializeFunctionDefaultValue } from 'src/engine/metadata-modules/field-metadata/utils/serialize-function-default-value.util';
export const serializeDefaultValue = (
defaultValue?: FieldMetadataDefaultSerializableValue,
defaultValue?: FieldMetadataDefaultValueForAnyType,
) => {
if (defaultValue === undefined || defaultValue === null) {
return null;
@@ -1,4 +1,4 @@
import { type FieldMetadataFunctionDefaultValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataFunctionDefaultValue } from 'twenty-shared/types';
export const serializeFunctionDefaultValue = (
defaultValue?: FieldMetadataFunctionDefaultValue,
@@ -1,7 +1,7 @@
import { type FieldMetadataDefaultSerializableValue } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import { type FieldMetadataDefaultValueForAnyType } from 'twenty-shared/types';
export const unserializeDefaultValue = (
serializedDefaultValue: FieldMetadataDefaultSerializableValue,
serializedDefaultValue: FieldMetadataDefaultValueForAnyType,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): any => {
if (serializedDefaultValue === undefined || serializedDefaultValue === null) {
@@ -1,118 +0,0 @@
import { plainToInstance } from 'class-transformer';
import { type ValidationError, validateSync } from 'class-validator';
import { FieldMetadataType } from 'twenty-shared/types';
import {
type FieldMetadataClassValidation,
type FieldMetadataDefaultValue,
} from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-default-value.interface';
import {
FieldMetadataDefaultActor,
FieldMetadataDefaultValueAddress,
FieldMetadataDefaultValueBoolean,
FieldMetadataDefaultValueCurrency,
FieldMetadataDefaultValueDate,
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueEmails,
FieldMetadataDefaultValueFullName,
FieldMetadataDefaultValueLinks,
FieldMetadataDefaultValueNowFunction,
FieldMetadataDefaultValueNumber,
FieldMetadataDefaultValuePhones,
FieldMetadataDefaultValueRawJson,
FieldMetadataDefaultValueRichTextV2,
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueStringArray,
FieldMetadataDefaultValueUuidFunction,
} from 'src/engine/metadata-modules/field-metadata/dtos/default-value.input';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
export const defaultValueValidatorsMap = {
[FieldMetadataType.UUID]: [
FieldMetadataDefaultValueString,
FieldMetadataDefaultValueUuidFunction,
],
[FieldMetadataType.TEXT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.DATE_TIME]: [
FieldMetadataDefaultValueDateTime,
FieldMetadataDefaultValueNowFunction,
],
[FieldMetadataType.DATE]: [FieldMetadataDefaultValueDate],
[FieldMetadataType.BOOLEAN]: [FieldMetadataDefaultValueBoolean],
[FieldMetadataType.NUMBER]: [FieldMetadataDefaultValueNumber],
[FieldMetadataType.NUMERIC]: [FieldMetadataDefaultValueString],
[FieldMetadataType.CURRENCY]: [FieldMetadataDefaultValueCurrency],
[FieldMetadataType.FULL_NAME]: [FieldMetadataDefaultValueFullName],
[FieldMetadataType.RATING]: [FieldMetadataDefaultValueString],
[FieldMetadataType.SELECT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.MULTI_SELECT]: [FieldMetadataDefaultValueStringArray],
[FieldMetadataType.ADDRESS]: [FieldMetadataDefaultValueAddress],
[FieldMetadataType.RICH_TEXT_V2]: [FieldMetadataDefaultValueRichTextV2],
[FieldMetadataType.RICH_TEXT]: [FieldMetadataDefaultValueString],
[FieldMetadataType.RAW_JSON]: [FieldMetadataDefaultValueRawJson],
[FieldMetadataType.LINKS]: [FieldMetadataDefaultValueLinks],
[FieldMetadataType.ACTOR]: [FieldMetadataDefaultActor],
[FieldMetadataType.EMAILS]: [FieldMetadataDefaultValueEmails],
[FieldMetadataType.PHONES]: [FieldMetadataDefaultValuePhones],
};
type ValidationResult = {
isValid: boolean;
errors: ValidationError[];
};
export const validateDefaultValueForType = (
type: FieldMetadataType,
defaultValue: FieldMetadataDefaultValue,
): ValidationResult => {
if (defaultValue === null) {
return {
isValid: true,
errors: [],
};
}
// @ts-expect-error legacy noImplicitAny
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const validators = defaultValueValidatorsMap[type] as any[];
if (!validators) {
return {
isValid: false,
errors: [],
};
}
const validationResults = validators.map((validator) => {
const computedDefaultValue = isCompositeFieldMetadataType(type)
? defaultValue
: { value: defaultValue };
const defaultValueInstance = plainToInstance<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any,
FieldMetadataClassValidation
>(validator, computedDefaultValue as FieldMetadataClassValidation);
const errors = validateSync(defaultValueInstance, {
whitelist: true,
forbidNonWhitelisted: true,
forbidUnknownValues: true,
});
const isValid = errors.length === 0;
return {
isValid,
errors,
};
});
const isValid = validationResults.some((result) => result.isValid);
return {
isValid,
errors: validationResults.flatMap((result) => result.errors),
};
};