[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
@@ -0,0 +1,71 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type ExtractJsonbProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type EmptyObject = {};
type TestedRecord = {
// Non-JsonbProperty fields
plainString: string;
plainNumber: number;
plainObject: EmptyObject;
plainObjectNullable: EmptyObject | null;
plainArray: string[];
plainUnknown: unknown;
jsonbString: JsonbProperty<string>;
jsonbPlainUnknown: JsonbProperty<unknown>;
jsonbNumber: JsonbProperty<number>;
jsonbull: JsonbProperty<null>;
// JsonbProperty fields - should be extracted
jsonbPlainObject: JsonbProperty<EmptyObject>;
jsonbPlainArray: JsonbProperty<string[]>;
jsonbPlainObjectNullable: JsonbProperty<EmptyObject | null>;
jsonbEmpty: JsonbProperty<EmptyObject>;
jsonbArray: JsonbProperty<string[]>;
jsonbNested: JsonbProperty<{ nested: { deep: number } }>;
jsonbNullable: JsonbProperty<EmptyObject> | null;
jsonbUndefinable: JsonbProperty<EmptyObject> | undefined;
jsonbOptional?: JsonbProperty<EmptyObject>;
jsonbInnerNullable: JsonbProperty<EmptyObject | null>;
jsonbInnerUndefinable: JsonbProperty<EmptyObject | undefined>;
jsonbUnionWithPrimitive: JsonbProperty<EmptyObject> | string | null;
jsonbInnerNullableWithProperties: JsonbProperty<null | { value: string }>;
wrongUsageButPassing:
| JsonbProperty<null | { value: string }>
| string
| { foo: string };
};
type TestResult = ExtractJsonbProperties<TestedRecord>;
// eslint-disable-next-line unused-imports/no-unused-vars
type Assertions = [
Expect<
Equal<
TestResult,
| 'jsonbPlainObject'
| 'jsonbPlainArray'
| 'jsonbPlainObjectNullable'
| 'jsonbEmpty'
| 'jsonbArray'
| 'jsonbNested'
| 'jsonbNullable'
| 'jsonbUndefinable'
| 'jsonbOptional'
| 'jsonbInnerNullable'
| 'jsonbInnerUndefinable'
| 'jsonbInnerNullableWithProperties'
| 'jsonbUnionWithPrimitive'
| 'wrongUsageButPassing'
>
>,
// Empty object returns never
Expect<Equal<ExtractJsonbProperties<EmptyObject>, never>>,
// Object with no JsonbProperty fields returns never
Expect<Equal<ExtractJsonbProperties<{ a: string; b: number }>, never>>,
];
@@ -0,0 +1,173 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import { type SerializedRelation } from 'twenty-shared/types';
import { type FormatJsonbSerializedRelation } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type';
import {
type JSONB_PROPERTY_BRAND,
type JsonbProperty,
} from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
type BrandedObjectWithRelation = JsonbProperty<{
name: string;
targetFieldMetadataId: SerializedRelation;
}>;
type BrandedObjectWithoutRelation = JsonbProperty<{
name: string;
count: number;
}>;
type UnbrandedObject = {
name: string;
targetFieldMetadataId: SerializedRelation;
};
// eslint-disable-next-line unused-imports/no-unused-vars
type BrandedObjectAssertions = [
// Branded object with SerializedRelation: Id suffix renamed to UniversalIdentifier
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}
>
>,
// Branded object without SerializedRelation: no renaming, just removes brand
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithoutRelation>,
{
name: string;
count: number;
}
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnbrandedObjectAssertions = [
// Unbranded objects pass through unchanged
Expect<
Equal<FormatJsonbSerializedRelation<UnbrandedObject>, UnbrandedObject>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type PrimitiveAssertions = [
// Primitives pass through unchanged
Expect<Equal<FormatJsonbSerializedRelation<string>, string>>,
Expect<Equal<FormatJsonbSerializedRelation<number>, number>>,
Expect<Equal<FormatJsonbSerializedRelation<null>, null>>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ArrayAssertions = [
// Array of branded objects: transforms each element
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation[]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[]
>
>,
// Array of unbranded objects: passes through unchanged
Expect<
Equal<FormatJsonbSerializedRelation<UnbrandedObject[]>, UnbrandedObject[]>
>,
// Array of primitives: passes through unchanged
Expect<Equal<FormatJsonbSerializedRelation<string[]>, string[]>>,
// Nested array of branded objects: transforms innermost elements
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation[][]>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}[][]
>
>,
// Array of unbranded and branded objects union: transforms branded element
Expect<
Equal<
FormatJsonbSerializedRelation<
(BrandedObjectWithRelation | UnbrandedObject)[]
>,
(
| {
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
}
| UnbrandedObject
)[]
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnionAssertions = [
// Union with null: transforms branded object, keeps null
Expect<
Equal<
FormatJsonbSerializedRelation<BrandedObjectWithRelation | null>,
{
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null
>
>,
// Array of union: transforms elements appropriately
Expect<
Equal<
FormatJsonbSerializedRelation<(BrandedObjectWithRelation | null)[]>,
({
name: string;
targetFieldMetadataUniversalIdentifier: SerializedRelation;
} | null)[]
>
>,
];
type MultipleRelationsObject = JsonbProperty<{
name: string;
sourceFieldId: SerializedRelation;
targetFieldId: SerializedRelation;
regularId: string;
}>;
// eslint-disable-next-line unused-imports/no-unused-vars
type MultipleRelationsAssertions = [
// Multiple SerializedRelation properties: all get renamed
Expect<
Equal<
FormatJsonbSerializedRelation<MultipleRelationsObject>,
{
name: string;
sourceFieldUniversalIdentifier: SerializedRelation;
targetFieldUniversalIdentifier: SerializedRelation;
regularId: string;
}
>
>,
];
// Verify brand is removed
type BrandRemovedCheck =
FormatJsonbSerializedRelation<BrandedObjectWithRelation>;
// eslint-disable-next-line unused-imports/no-unused-vars
type BrandRemovedAssertion = Expect<
Equal<
typeof JSONB_PROPERTY_BRAND extends keyof BrandRemovedCheck ? true : false,
false
>
>;
@@ -0,0 +1,97 @@
import { type Equal, type Expect } from 'twenty-shared/testing';
import {
type JSONB_PROPERTY_BRAND,
type JsonbProperty,
} from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
type EmptyObject = {};
type SimpleObject = { value: string };
type NestedObject = { nested: { deep: number } };
// eslint-disable-next-line unused-imports/no-unused-vars
type PrimitiveAssertions = [
// Primitives pass through unchanged (not objects)
Expect<Equal<JsonbProperty<string>, string>>,
Expect<Equal<JsonbProperty<number>, number>>,
Expect<Equal<JsonbProperty<boolean>, boolean>>,
Expect<Equal<JsonbProperty<null>, null>>,
Expect<Equal<JsonbProperty<undefined>, undefined>>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ObjectAssertions = [
// Objects get branded
Expect<
Equal<
JsonbProperty<SimpleObject>,
SimpleObject & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<NestedObject>,
NestedObject & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<EmptyObject>,
EmptyObject & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type ArrayAssertions = [
// Arrays are objects, so they get branded on the array itself
Expect<
Equal<
JsonbProperty<string[]>,
string[] & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<number[]>,
number[] & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
Expect<
Equal<
JsonbProperty<SimpleObject[]>,
SimpleObject[] & { [JSONB_PROPERTY_BRAND]?: never }
>
>,
];
// eslint-disable-next-line unused-imports/no-unused-vars
type UnionAssertions = [
// Union of object and null: object gets branded, null passes through
Expect<
Equal<
JsonbProperty<SimpleObject | null>,
(SimpleObject & { [JSONB_PROPERTY_BRAND]?: never }) | null
>
>,
// Union of objects: both get branded (distributive conditional)
Expect<
Equal<
JsonbProperty<SimpleObject | NestedObject>,
| (SimpleObject & { [JSONB_PROPERTY_BRAND]?: never })
| (NestedObject & { [JSONB_PROPERTY_BRAND]?: never })
>
>,
// Array in union: array gets branded
Expect<
Equal<
JsonbProperty<SimpleObject[] | null>,
(SimpleObject[] & { [JSONB_PROPERTY_BRAND]?: never }) | null
>
>,
];
@@ -1,7 +1,18 @@
import { type Expect, type HasAllProperties } from 'twenty-shared/testing';
import {
type Equal,
type Expect,
type HasAllProperties,
} from 'twenty-shared/testing';
import {
type FieldMetadataDefaultOption,
type FieldMetadataType,
type FieldNumberVariant,
type LinkMetadata,
type NullablePartial,
type NumberDataType,
type RelationOnDeleteAction,
type RelationType,
type SerializedRelation,
} from 'twenty-shared/types';
import { type UniversalFlatFieldMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-metadata.type';
@@ -79,3 +90,69 @@ type UniversalFlatTransformationAssertions = [
>
>,
];
type NarrowedTestCase =
UniversalFlatFieldMetadata<FieldMetadataType.RELATION>['settings'];
type NarrowedExpectedResult = {
relationType: RelationType;
onDelete?: RelationOnDeleteAction | undefined;
joinColumnName?: string | null | undefined;
junctionTargetFieldUniversalIdentifier?: SerializedRelation | undefined;
};
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 DefaultValueTestCase = UniversalFlatFieldMetadata<
| FieldMetadataType.RELATION
| FieldMetadataType.NUMBER
| FieldMetadataType.TEXT
| FieldMetadataType.LINKS
| FieldMetadataType.CURRENCY
>['defaultValue'];
type DefaultValueExpectedResult =
| string
| number
| null
| {
amountMicros: string | null;
currencyCode: string | null;
}
| {
primaryLinkLabel: string | null;
primaryLinkUrl: string | null;
secondaryLinks: LinkMetadata[] | null;
};
type OptionsTestCase =
UniversalFlatFieldMetadata<FieldMetadataType.RATING>['options'];
type OptionsExpectedResult = FieldMetadataDefaultOption[];
// eslint-disable-next-line unused-imports/no-unused-vars
type Assertions = [
Expect<Equal<SettingsTestCase, SettingsExpectedResult>>,
Expect<Equal<NarrowedTestCase, NarrowedExpectedResult>>,
Expect<Equal<DefaultValueTestCase, DefaultValueExpectedResult>>,
Expect<Equal<OptionsTestCase, OptionsExpectedResult>>,
];
@@ -0,0 +1,17 @@
import { type JSONB_PROPERTY_BRAND } from './jsonb-property.type';
export type HasJsonbPropertyBrand<T> =
typeof JSONB_PROPERTY_BRAND extends keyof T ? true : false;
// Distributive check: returns `true` if any member of a union has the brand
type HasJsonbBrandInUnion<T> = T extends unknown
? HasJsonbPropertyBrand<T>
: never;
export type ExtractJsonbProperties<T> = NonNullable<
{
[P in keyof T]-?: true extends HasJsonbBrandInUnion<NonNullable<T[P]>>
? P
: never;
}[keyof T]
>;
@@ -0,0 +1,21 @@
import { type ExtractSerializedRelationProperties } from 'twenty-shared/types';
import { type HasJsonbPropertyBrand } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type JSONB_PROPERTY_BRAND } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
import { type RemoveSuffix } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/remove-suffix.type';
export type FormatJsonbSerializedRelation<T> = T extends unknown
? T extends (infer U)[]
? FormatJsonbSerializedRelation<U>[]
: HasJsonbPropertyBrand<T> extends true
? Omit<
{
[P in keyof T as P extends ExtractSerializedRelationProperties<T> &
string
? `${RemoveSuffix<P, 'Id'>}UniversalIdentifier`
: P]: T[P];
},
typeof JSONB_PROPERTY_BRAND
>
: T
: never;
@@ -0,0 +1,7 @@
export const JSONB_PROPERTY_BRAND = '__JsonbPropertyBrand__' as const;
export type JsonbProperty<T> = T extends unknown
? T extends object
? T & { [JSONB_PROPERTY_BRAND]?: never }
: T
: never;
@@ -6,6 +6,8 @@ import { type ExtractEntityRelatedEntityProperties } from 'src/engine/metadata-m
import { type FromMetadataEntityToMetadataName } from 'src/engine/metadata-modules/flat-entity/types/from-metadata-entity-to-metadata-name.type';
import { type MetadataManyToOneJoinColumn } from 'src/engine/metadata-modules/flat-entity/types/metadata-many-to-one-join-column.type';
import { type SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { type ExtractJsonbProperties } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/extract-jsonb-properties.type';
import { type FormatJsonbSerializedRelation } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/format-jsonb-serialized-relation.type';
import { type RemoveSuffix } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/remove-suffix.type';
// TODO Handle universal settings
@@ -22,6 +24,7 @@ export type UniversalFlatEntityFrom<
| ExtractEntityRelatedEntityProperties<TEntity>
| Extract<MetadataManyToOneJoinColumn<TMetadataName>, keyof TEntity>
| keyof CastRecordTypeOrmDatePropertiesToString<TEntity>
| ExtractJsonbProperties<TEntity>
> &
CastRecordTypeOrmDatePropertiesToString<TEntity> & {
[P in ExtractEntityOneToManyEntityRelationProperties<
@@ -34,4 +37,8 @@ export type UniversalFlatEntityFrom<
string as `${RemoveSuffix<P, 'Id'>}UniversalIdentifier`]: TEntity[P];
} & {
applicationUniversalIdentifier: string;
} & {
[P in ExtractJsonbProperties<TEntity>]: FormatJsonbSerializedRelation<
TEntity[P]
>;
};
@@ -1,6 +1,5 @@
import { type ColumnType } from 'typeorm';
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,
@@ -11,7 +10,7 @@ import { serializeFunctionDefaultValue } from 'src/engine/metadata-modules/field
import { removeSqlDDLInjection } from 'src/engine/workspace-manager/workspace-migration/utils/remove-sql-injection.util';
type SerializeDefaultValueArgs = {
defaultValue?: FieldMetadataDefaultSerializableValue;
defaultValue?: FieldMetadataDefaultValueForAnyType;
columnType?: ColumnType;
schemaName: string;
tableName: string;