From 466d272f9cc4b1d7a4c1998a8f24554cd10eb5a1 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Thu, 15 Jan 2026 14:05:30 +0100 Subject: [PATCH] Create syncable entity cursor rule (#17165) # Introduction As per title We could improve the integration tests section ( maybe a dedicated tool ) --- .cursor/rules/README.mdc | 2 + .cursor/rules/creating-syncable-entity.mdc | 1225 ++++++++++++++++++++ 2 files changed, 1227 insertions(+) create mode 100644 .cursor/rules/creating-syncable-entity.mdc diff --git a/.cursor/rules/README.mdc b/.cursor/rules/README.mdc index ea05636131..a5d26d75ab 100644 --- a/.cursor/rules/README.mdc +++ b/.cursor/rules/README.mdc @@ -13,6 +13,7 @@ This directory contains Twenty's development guidelines and best practices in th - **architecture.mdc** - Project overview, technology stack, and infrastructure setup (Always Applied) - **nx-rules.mdc** - Nx workspace guidelines and best practices (Auto-attached to Nx files) - **server-migrations.mdc** - Backend migration and TypeORM guidelines for `twenty-server` (Auto-attached to server entities and migration files) +- **creating-syncable-entity.mdc** - Comprehensive guide for creating new syncable entities (with universalIdentifier and applicationId) in the workspace migration system (Agent-requested for metadata-modules and workspace-migration files) ### Code Quality - **typescript-guidelines.mdc** - TypeScript best practices and conventions (Auto-attached to .ts/.tsx files) @@ -39,6 +40,7 @@ You can manually reference any rule using the `@ruleName` syntax: - `@nx-rules` - Include Nx-specific guidance - `@react-general-guidelines` - Load React best practices - `@testing-guidelines` - Get testing recommendations +- `@creating-syncable-entity` - Guide for creating new syncable entities ### Rule Types Used - **Always Applied** - Loaded in every context (architecture.mdc, README.mdc) diff --git a/.cursor/rules/creating-syncable-entity.mdc b/.cursor/rules/creating-syncable-entity.mdc new file mode 100644 index 0000000000..4310297814 --- /dev/null +++ b/.cursor/rules/creating-syncable-entity.mdc @@ -0,0 +1,1225 @@ +--- +description: Guide for creating a new syncable entity in Twenty's workspace migration system +globs: ["**/metadata-modules/**", "**/workspace-migration/**"] +alwaysApply: false +--- + +# Creating a New Syncable Entity + +This guide explains how to create a new **syncable entity** in Twenty's workspace migration architecture. + +## What is a Syncable Entity? + +A syncable entity is a metadata entity that: +- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications (typically set to `standardId` for standard entities or `id` for custom entities) +- Has an **`applicationId`**: Links the entity to an application (Twenty Standard or Custom applications) +- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline +- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection + +Examples of existing syncable entities: `skill`, `agent`, `view`, `viewField`, `role`, `pageLayout`, etc. + +--- + +## Table of Contents + +1. [Overview](#overview) +2. [File Structure](#file-structure) +3. [Step-by-Step Implementation](#step-by-step-implementation) + - [Step 1: Add Metadata Name Constant](#step-1-add-metadata-name-constant-twenty-shared) + - [Step 2: Create TypeORM Entity](#step-2-create-typeorm-entity) + - [Step 3: Define Flat Entity Type](#step-3-define-flat-entity-type) + - [Step 4: Define Editable Properties](#step-4-define-editable-properties) + - [Step 5: Register in Central Constants](#step-5-register-in-central-constants) + - [Step 6: Create Cache Service](#step-6-create-cache-service) + - [Step 7: Create Flat Entity Module](#step-7-create-flat-entity-module) + - [Step 8: Define Action Types](#step-8-define-action-types) + - [Step 9: Create Validator Service](#step-9-create-validator-service) + - [Step 10: Create Builder Service](#step-10-create-builder-service) + - [Step 11: Create Action Handlers](#step-11-create-action-handlers-runner) + - [Step 12: Wire in Orchestrator Service](#step-12-wire-in-orchestrator-service-critical) + - [Step 13: Register in Modules](#step-13-register-in-modules) +4. [Using the Entity in Services](#using-the-entity-in-services) +5. [Integration Tests](#integration-tests) + +--- + +## Overview + +The syncable entity system consists of several interconnected components: + +``` +Input DTO → Transform Utils → Flat Entity → Builder/Validator → Runner → Database + ↓ + Cache Service +``` + +- **TypeORM Entity**: Database model extending `SyncableEntityRequired` (new) or `SyncableEntity` (legacy) +- **Flat Entity**: Denormalized type derived from the entity (no relations, dates as strings) +- **Transform Utils**: Convert input DTOs to flat entities (sanitization, defaults, ID generation) - **all transformations happen here** +- **Cache Service**: Computes and caches flat entity maps per workspace +- **Builder Service**: Validates and builds migration actions - **never throws, never mutates** +- **Validator Service**: Contains business logic validation rules - **never throws, never mutates, returns aggregated errors** +- **Runner/Action Handlers**: Executes actions against the database +- **Orchestrator**: Coordinates all builders and manages the migration flow + +### Key Design Principle: Separation of Concerns + +| Layer | Responsibility | Can Throw? | Can Mutate? | +|-------|---------------|------------|-------------| +| Transform Utils | Data transformation, sanitization, defaults | Yes (input validation) | N/A (creates new objects) | +| Validator | Business rule validation | **No** (returns errors) | **No** | +| Builder | Action creation | **No** (returns errors) | **No** | +| Runner | Database operations | Yes (DB errors) | Yes (via TypeORM) | + +--- + +## File Structure + +For a new entity called `myEntity`, you'll create/modify files in these locations: + +``` +packages/twenty-shared/src/metadata/ +├── all-metadata-name.constant.ts # Add metadata name + +packages/twenty-server/src/engine/metadata-modules/ +├── my-entity/ +│ ├── entities/ +│ │ └── my-entity.entity.ts # TypeORM entity +│ ├── dtos/ +│ │ ├── create-my-entity.input.ts +│ │ └── update-my-entity.input.ts +│ ├── my-entity.service.ts # Business service +│ └── my-entity.module.ts +├── flat-my-entity/ +│ ├── types/ +│ │ ├── flat-my-entity.type.ts # Flat entity type +│ │ └── flat-my-entity-maps.type.ts # Maps type +│ ├── constants/ +│ │ └── flat-my-entity-editable-properties.constant.ts +│ ├── services/ +│ │ └── workspace-flat-my-entity-map-cache.service.ts +│ ├── utils/ +│ │ └── from-my-entity-entity-to-flat-my-entity.util.ts +│ └── flat-my-entity.module.ts +├── flat-entity/ +│ ├── types/ +│ │ └── all-flat-entity-types-by-metadata-name.ts # Register entity +│ └── constant/ +│ ├── all-flat-entity-properties-to-compare-and-stringify.constant.ts +│ ├── all-metadata-relations.constant.ts +│ └── all-metadata-required-metadata-for-validation.constant.ts + +packages/twenty-server/src/engine/workspace-manager/workspace-migration/ +├── workspace-migration-builder/ +│ ├── builders/ +│ │ └── my-entity/ +│ │ ├── types/ +│ │ │ └── workspace-migration-my-entity-action.type.ts +│ │ └── workspace-migration-my-entity-actions-builder.service.ts +│ ├── validators/ +│ │ ├── services/ +│ │ │ └── flat-my-entity-validator.service.ts +│ │ └── utils/ +│ │ └── validate-my-entity-*.util.ts +│ └── workspace-migration-builder.module.ts # Register builder +├── workspace-migration-runner/ +│ └── action-handlers/ +│ ├── my-entity/ +│ │ └── services/ +│ │ ├── create-my-entity-action-handler.service.ts +│ │ ├── update-my-entity-action-handler.service.ts +│ │ └── delete-my-entity-action-handler.service.ts +│ └── workspace-schema-migration-runner-action-handlers.module.ts +└── services/ + └── workspace-migration-build-orchestrator.service.ts # Wire builder +``` + +--- + +## Step-by-Step Implementation + +### Step 1: Add Metadata Name Constant (twenty-shared) + +**File:** `packages/twenty-shared/src/metadata/all-metadata-name.constant.ts` + +```typescript +export const ALL_METADATA_NAME = { + // ... existing entries + myEntity: 'myEntity', +} as const; +``` + +--- + +### Step 2: Create TypeORM Entity + +**File:** `src/engine/metadata-modules/my-entity/entities/my-entity.entity.ts` + +Your entity **must extend `SyncableEntityRequired`** (for new entities) or `SyncableEntity` (for migrated entities) to participate in the workspace migration system. + +#### For New Entities (Recommended): Use `SyncableEntityRequired` + +For brand new entities with no legacy data to migrate, use `SyncableEntityRequired` which enforces that `universalIdentifier` and `applicationId` are always set: + +```typescript +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { SyncableEntityRequired } from 'src/engine/workspace-manager/types/syncable-entity-required.interface'; + +@Entity('myEntity') +export class MyEntityEntity + extends SyncableEntityRequired + implements Required +{ + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: true, type: 'uuid' }) + standardId: string | null; + + @Column({ nullable: false }) + name: string; + + @Column({ nullable: false }) + label: string; + + @Column({ nullable: true, type: 'varchar' }) + icon: string | null; + + @Column({ nullable: true, type: 'text' }) + description: string | null; + + @Column({ default: false }) + isCustom: boolean; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} +``` + +#### For Migrated Entities: Use `SyncableEntity` + +Only use `SyncableEntity` when you need to support legacy data where `universalIdentifier` might not be set yet: + +```typescript +import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; + +@Entity('myEntity') +export class MyEntityEntity + extends SyncableEntity + implements Required +{ + // ... +} +``` + +**What the base classes provide:** + +```typescript +// SyncableEntityRequired - For new entities (recommended) +// File: src/engine/workspace-manager/types/syncable-entity-required.interface.ts +@Index(['workspaceId', 'universalIdentifier'], { unique: true }) +export abstract class SyncableEntityRequired extends WorkspaceRelatedEntity { + @Column({ nullable: false, type: 'uuid' }) // NOT nullable + universalIdentifier: string; + + @Column({ nullable: false, type: 'uuid' }) // NOT nullable + applicationId: string; + + @ManyToOne('ApplicationEntity', { onDelete: 'CASCADE', nullable: false }) + @JoinColumn({ name: 'applicationId' }) + application: Relation; +} + +// SyncableEntity - For entities with legacy data migration needs +// File: src/engine/workspace-manager/types/syncable-entity.interface.ts +@Index(['workspaceId', 'universalIdentifier'], { unique: true }) +export abstract class SyncableEntity extends WorkspaceRelatedEntity { + @Column({ nullable: true, type: 'uuid' }) // nullable for migration + universalIdentifier: string; + + @Column({ nullable: true, type: 'uuid' }) // nullable for migration + applicationId: string | null; + + @ManyToOne('ApplicationEntity', { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'applicationId' }) + application: Relation; +} + +// WorkspaceRelatedEntity (common base) +// File: src/engine/workspace-manager/types/workspace-related-entity.ts +export abstract class WorkspaceRelatedEntity { + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; +} +``` + +**Key differences:** +| Property | `SyncableEntityRequired` | `SyncableEntity` | +|----------|-------------------------|------------------| +| `universalIdentifier` | Required (`string`) | Nullable (`string`) | +| `applicationId` | Required (`string`) | Nullable (`string \| null`) | +| Use case | New entities | Legacy migration | + +--- + +### Step 3: Define Flat Entity Type + +**File:** `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type.ts` + +```typescript +import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type'; + +export type FlatMyEntity = FlatEntityFrom; +``` + +The `FlatEntityFrom` utility type automatically: +- Removes relation properties (ManyToOne, OneToMany) +- Converts Date properties to string (ISO format) +- Adds `{relationName}Ids` arrays for OneToMany relations +- Preserves nullability from the base class (`SyncableEntityRequired` → non-nullable `universalIdentifier`/`applicationId`) + +**File:** `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type.ts` + +```typescript +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatMyEntity } from './flat-my-entity.type'; + +export type FlatMyEntityMaps = FlatEntityMaps; +``` + +--- + +### Step 4: Define Editable Properties + +**File:** `src/engine/metadata-modules/flat-my-entity/constants/flat-my-entity-editable-properties.constant.ts` + +```typescript +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; + +export const FLAT_MY_ENTITY_EDITABLE_PROPERTIES = [ + 'name', + 'label', + 'icon', + 'description', +] as const satisfies (keyof FlatMyEntity)[]; +``` + +--- + +### Step 5: Register in Central Constants + +#### 5a. All Flat Entity Types Registry + +**File:** `src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts` + +```typescript +// Add imports +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; +import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { + type CreateMyEntityAction, + type DeleteMyEntityAction, + type UpdateMyEntityAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; + +export type AllFlatEntityTypesByMetadataName = { + // ... existing entries + myEntity: { + actions: { + create: CreateMyEntityAction; + update: UpdateMyEntityAction; + delete: DeleteMyEntityAction; + }; + flatEntity: FlatMyEntity; + entity: MyEntityEntity; + }; +}; +``` + +#### 5b. Properties to Compare and Stringify + +**File:** `src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts` + +```typescript +import { FLAT_MY_ENTITY_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-my-entity/constants/flat-my-entity-editable-properties.constant'; + +export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = { + // ... existing entries + myEntity: { + propertiesToCompare: [...FLAT_MY_ENTITY_EDITABLE_PROPERTIES], + propertiesToStringify: [], // Add properties that need JSON.stringify for comparison + }, +} as const satisfies { + [P in AllMetadataName]: OneFlatEntityConfiguration

; +}; +``` + +#### 5c. Metadata Relations + +**File:** `src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts` + +```typescript +export const ALL_METADATA_RELATIONS = { + // ... existing entries + myEntity: { + manyToOne: { + workspace: null, + application: null, + // Add other relations if your entity has them: + // parentEntity: { + // metadataName: 'parentEntity', + // flatEntityForeignKeyAggregator: 'myEntityIds', + // foreignKey: 'parentEntityId', + // }, + }, + oneToMany: {}, + }, +} as const satisfies MetadataRelationsProperties; +``` + +#### 5d. Required Metadata for Validation + +**File:** `src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts` + +```typescript +export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { + // ... existing entries + myEntity: { + // Add metadata names that are required when validating this entity: + // parentEntity: true, + }, +} as const satisfies MetadataRequiredForValidation; +``` + +--- + +### Step 6: Create Cache Service + +**File:** `src/engine/metadata-modules/flat-my-entity/services/workspace-flat-my-entity-map-cache.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; + +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type'; +import { fromMyEntityEntityToFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util'; +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; +import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util'; + +@Injectable() +@WorkspaceCache('flatMyEntityMaps') // Key must match the AllFlatEntityMaps property name +export class WorkspaceFlatMyEntityMapCacheService extends WorkspaceCacheProvider { + constructor( + @InjectRepository(MyEntityEntity) + private readonly myEntityRepository: Repository, + ) { + super(); + } + + async computeForCache(workspaceId: string): Promise { + const entities = await this.myEntityRepository.find({ + where: { workspaceId }, + withDeleted: true, // Include soft-deleted entities + }); + + const flatMyEntityMaps = createEmptyFlatEntityMaps(); + + for (const entity of entities) { + const flatEntity = fromMyEntityEntityToFlatMyEntity(entity); + + addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ + flatEntity, + flatEntityMapsToMutate: flatMyEntityMaps, + }); + } + + return flatMyEntityMaps; + } +} +``` + +**File:** `src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util.ts` + +```typescript +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; +import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; + +export const fromMyEntityEntityToFlatMyEntity = ( + entity: MyEntityEntity, +): FlatMyEntity => { + return { + id: entity.id, + standardId: entity.standardId, + name: entity.name, + label: entity.label, + icon: entity.icon, + description: entity.description, + isCustom: entity.isCustom, + workspaceId: entity.workspaceId, + // universalIdentifier: standardId for standard entities, id for custom entities + universalIdentifier: entity.standardId || entity.id, + applicationId: entity.applicationId, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + }; +}; +``` + +> **Important:** The `universalIdentifier` must be set correctly: +> - For **standard entities** (shipped with Twenty): use `standardId` +> - For **custom entities** (created by users): use `id` +> - Pattern: `entity.standardId || entity.id` + +--- + +### Step 7: Create Flat Entity Module + +**File:** `src/engine/metadata-modules/flat-my-entity/flat-my-entity.module.ts` + +```typescript +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; +import { WorkspaceFlatMyEntityMapCacheService } from 'src/engine/metadata-modules/flat-my-entity/services/workspace-flat-my-entity-map-cache.service'; +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([MyEntityEntity]), + WorkspaceManyOrAllFlatEntityMapsCacheModule, + ], + providers: [WorkspaceFlatMyEntityMapCacheService], + exports: [WorkspaceFlatMyEntityMapCacheService], +}) +export class FlatMyEntityModule {} +``` + +--- + +### Step 8: Define Action Types + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type.ts` + +```typescript +import { type BaseCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-create-workspace-migration-action.type'; +import { type BaseDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-delete-workspace-migration-action.type'; +import { type BaseUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-update-workspace-migration-action.type'; + +export type CreateMyEntityAction = BaseCreateWorkspaceMigrationAction<'myEntity'>; + +export type UpdateMyEntityAction = BaseUpdateWorkspaceMigrationAction<'myEntity'>; + +export type DeleteMyEntityAction = BaseDeleteWorkspaceMigrationAction<'myEntity'>; +``` + +--- + +### Step 9: Create Validator Service + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service.ts` + +> **Critical Rules for Validators and Builders:** +> +> 1. **Never throw exceptions** - Always fail slow by collecting errors in an array and returning them to the caller. This allows validating all entities and reporting all errors at once, rather than failing on the first error. +> +> 2. **Never mutate entity maps** - The validator/builder should only read from the flat entity maps, never modify them. No data transformation should happen here - transformations belong in the input-to-flat-entity utilities before calling the migration service. +> +> 3. **No side effects** - Validation logic should be pure: given the same inputs, it should always produce the same outputs. + +```typescript +import { Injectable } from '@nestjs/common'; + +import { msg, t } from '@lingui/core/macro'; +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; +import { isStandardMetadata } from 'src/engine/metadata-modules/utils/is-standard-metadata.util'; +import { MyEntityExceptionCode } from 'src/engine/metadata-modules/my-entity/my-entity.exception'; +import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; +import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; + +@Injectable() +export class FlatMyEntityValidatorService { + public validateFlatMyEntityCreation({ + flatEntityToValidate: flatMyEntity, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatMyEntityMaps: optimisticFlatMyEntityMaps, + }, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.myEntity + >): FailedFlatEntityValidation<'myEntity', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatMyEntity.id, + universalIdentifier: flatMyEntity.universalIdentifier, + name: flatMyEntity.name, + }, + metadataName: 'myEntity', + type: 'create', + }); + + // Add your validation rules here + // Example: validate required properties, uniqueness, etc. + + return validationResult; + } + + public validateFlatMyEntityDeletion({ + flatEntityToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatMyEntityMaps: optimisticFlatMyEntityMaps, + }, + buildOptions, + }: FlatEntityValidationArgs< + typeof ALL_METADATA_NAME.myEntity + >): FailedFlatEntityValidation<'myEntity', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityToValidate.id, + universalIdentifier: flatEntityToValidate.universalIdentifier, + name: flatEntityToValidate.name, + }, + metadataName: 'myEntity', + type: 'delete', + }); + + const existingEntity = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: flatEntityToValidate.id, + flatEntityMaps: optimisticFlatMyEntityMaps, + }); + + if (!isDefined(existingEntity)) { + validationResult.errors.push({ + code: MyEntityExceptionCode.MY_ENTITY_NOT_FOUND, + message: t`Entity not found`, + userFriendlyMessage: msg`Entity not found`, + }); + + return validationResult; + } + + // Prevent deletion of standard entities unless it's a system build + if (!buildOptions.isSystemBuild && isStandardMetadata(existingEntity)) { + validationResult.errors.push({ + code: MyEntityExceptionCode.MY_ENTITY_IS_STANDARD, + message: t`Cannot delete standard entity`, + userFriendlyMessage: msg`Cannot delete standard entity`, + }); + } + + return validationResult; + } + + public validateFlatMyEntityUpdate({ + flatEntityId, + flatEntityUpdates, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatMyEntityMaps: optimisticFlatMyEntityMaps, + }, + buildOptions, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.myEntity + >): FailedFlatEntityValidation<'myEntity', 'update'> { + const fromFlatEntity = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId, + flatEntityMaps: optimisticFlatMyEntityMaps, + }); + + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + id: flatEntityId, + universalIdentifier: fromFlatEntity?.universalIdentifier, + }, + metadataName: 'myEntity', + type: 'update', + }); + + if (!isDefined(fromFlatEntity)) { + validationResult.errors.push({ + code: MyEntityExceptionCode.MY_ENTITY_NOT_FOUND, + message: t`Entity not found`, + userFriendlyMessage: msg`Entity not found`, + }); + + return validationResult; + } + + // Add your update validation rules here + + return validationResult; + } +} +``` + +--- + +### Step 10: Create Builder Service + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service.ts` + +The builder service orchestrates validation and creates migration actions. It follows the same rules as validators: +- **Never throws** - delegates to validator and returns fail status with errors +- **Never mutates** - the base class handles optimistic cache updates after successful validation + +```typescript +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { UpdateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type'; +import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type'; +import { FlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-result.type'; +import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service'; + +@Injectable() +export class WorkspaceMigrationMyEntityActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.myEntity +> { + constructor( + private readonly flatMyEntityValidatorService: FlatMyEntityValidatorService, + ) { + super(ALL_METADATA_NAME.myEntity); + } + + protected validateFlatEntityCreation( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType { + const validationResult = + this.flatMyEntityValidatorService.validateFlatMyEntityCreation(args); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'myEntity', + flatEntity: flatEntityToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: FlatEntityValidationArgs, + ): FlatEntityValidationReturnType { + const validationResult = + this.flatMyEntityValidatorService.validateFlatMyEntityDeletion(args); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'myEntity', + entityId: flatEntityToValidate.id, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs, + ): FlatEntityValidationReturnType { + const validationResult = + this.flatMyEntityValidatorService.validateFlatMyEntityUpdate(args); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityId, flatEntityUpdates } = args; + + const updateAction: UpdateMyEntityAction = { + type: 'update', + metadataName: 'myEntity', + entityId: flatEntityId, + updates: flatEntityUpdates, + }; + + return { + status: 'success', + action: updateAction, + }; + } +} +``` + +--- + +### Step 11: Create Action Handlers (Runner) + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { CreateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class CreateMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'myEntity', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { flatEntity } = action; + + const repository = + queryRunner.manager.getRepository(MyEntityEntity); + + await repository.save({ + ...flatEntity, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + // Only needed for entities that affect the workspace schema (objects, fields) + return; + } +} +``` + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { UpdateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; +import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; + +@Injectable() +export class UpdateMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'myEntity', +) { + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId, updates } = action; + + const repository = + queryRunner.manager.getRepository(MyEntityEntity); + + await repository.update( + { id: entityId, workspaceId }, + fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ updates }), + ); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} +``` + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; + +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; + +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { DeleteMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; +import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'myEntity', +) { + constructor() { + super(); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, queryRunner, workspaceId } = context; + const { entityId } = action; + + const repository = + queryRunner.manager.getRepository(MyEntityEntity); + + await repository.delete({ id: entityId, workspaceId }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return; + } +} +``` + +--- + +### Step 12: Wire in Orchestrator Service (CRITICAL) + +> **⚠️ This step is frequently forgotten!** The orchestrator service must be updated to call your builder's `validateAndBuild` method. + +**File:** `src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts` + +1. **Add import:** + +```typescript +import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service'; +``` + +2. **Add to constructor:** + +```typescript +constructor( + // ... existing services + private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService, +) {} +``` + +3. **Add to `buildWorkspaceMigration` method** (after extracting from `fromToAllFlatEntityMaps`): + +```typescript +const { + // ... existing extractions + flatMyEntityMaps, +} = fromToAllFlatEntityMaps; +``` + +4. **Add the builder call block:** + +```typescript +if (isDefined(flatMyEntityMaps)) { + const { from: fromFlatMyEntityMaps, to: toFlatMyEntityMaps } = + flatMyEntityMaps; + + const myEntityResult = + await this.workspaceMigrationMyEntityActionsBuilderService.validateAndBuild( + { + additionalCacheDataMaps, + from: fromFlatMyEntityMaps, + to: toFlatMyEntityMaps, + buildOptions, + dependencyOptimisticFlatEntityMaps: undefined, // Or add dependencies + workspaceId, + }, + ); + + this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation( + { + allFlatEntityMaps: optimisticAllFlatEntityMaps, + flatEntityMapsAndRelatedFlatEntityMaps: + myEntityResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps, + }, + ); + + if (myEntityResult.status === 'fail') { + orchestratorFailureReport.myEntity.push(...myEntityResult.errors); + } else { + orchestratorActionsReport.myEntity = myEntityResult.actions; + } +} +``` + +5. **Add actions to the return statement:** + +```typescript +return { + // ... existing + workspaceMigration: { + // ... existing + actions: [ + // ... existing actions + + // My Entity + ...aggregatedOrchestratorActionsReport.myEntity.delete, + ...aggregatedOrchestratorActionsReport.myEntity.create, + ...aggregatedOrchestratorActionsReport.myEntity.update, + ], + }, +}; +``` + +--- + +### Step 13: Register in Modules + +#### 13a. Builder Module + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts` + +```typescript +import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service'; + +@Module({ + // ... + providers: [ + // ... existing + WorkspaceMigrationMyEntityActionsBuilderService, + ], + exports: [ + // ... existing + WorkspaceMigrationMyEntityActionsBuilderService, + ], +}) +export class WorkspaceMigrationBuilderModule {} +``` + +#### 13b. Validators Module + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts` + +```typescript +import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service'; + +@Module({ + // ... + providers: [ + // ... existing + FlatMyEntityValidatorService, + ], + exports: [ + // ... existing + FlatMyEntityValidatorService, + ], +}) +export class WorkspaceMigrationBuilderValidatorsModule {} +``` + +#### 13c. Action Handlers Module + +**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts` + +```typescript +import { CreateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service'; +import { UpdateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service'; +import { DeleteMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service'; + +@Module({ + // ... + providers: [ + // ... existing + CreateMyEntityActionHandlerService, + UpdateMyEntityActionHandlerService, + DeleteMyEntityActionHandlerService, + ], +}) +export class WorkspaceSchemaMigrationRunnerActionHandlersModule {} +``` + +--- + +## Using the Entity in Services + +Here's how to use the entity in a business service. + +**Important:** All data transformations (sanitization, normalization, default values) must happen **before** calling `validateBuildAndRunWorkspaceMigration`. The builder/validator should receive clean, ready-to-persist flat entities - it only validates business rules and creates actions, it does not transform data. + +```typescript +import { Injectable } from '@nestjs/common'; + +import { isDefined } from 'twenty-shared/utils'; + +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; + +@Injectable() +export class MyEntityService { + constructor( + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, + private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + ) {} + + async create(input: CreateMyEntityInput, workspaceId: string) { + // 1. Transform input to flat entity + // This is where ALL transformations happen: + // - Sanitize strings (trim, remove duplicate whitespace) + // - Compute derived values (e.g., name from label) + // - Set default values + // - Generate IDs + // - Set universalIdentifier and applicationId + const flatMyEntityToCreate = fromCreateInputToFlatEntity({ + input, + workspaceId, + }); + + // 2. Call validate, build, and run + const validateAndBuildResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + myEntity: { + flatEntityToCreate: [flatMyEntityToCreate], + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + // 3. Throw if validation failed + if (isDefined(validateAndBuildResult)) { + throw new WorkspaceMigrationBuilderException( + validateAndBuildResult, + 'Validation errors occurred while creating entity', + ); + } + + // 4. Return freshly cached entity + const { flatMyEntityMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatMyEntityMaps'], + }, + ); + + return findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: flatMyEntityToCreate.id, + flatEntityMaps: flatMyEntityMaps, + }); + } +} +``` + +--- + +## Integration Tests + +Create integration tests following the existing pattern: + +``` +test/integration/metadata/suites/my-entity/ +├── __snapshots__/ +│ └── failing-my-entity-creation.integration-spec.ts.snap +├── failing-my-entity-creation.integration-spec.ts +├── successful-my-entity-creation.integration-spec.ts +└── utils/ + ├── create-one-my-entity.util.ts + └── delete-one-my-entity.util.ts +``` + +Tests should cover: +- Successful CRUD operations +- Failing validation scenarios (using `eachTestingContextFilter` pattern) +- Edge cases and business rules + +--- + +## Checklist + +Before considering your syncable entity complete, verify: + +### Syncable Entity Requirements +- [ ] TypeORM entity **extends `SyncableEntityRequired`** (new entities) or `SyncableEntity` (legacy migration) +- [ ] Entity has `standardId` column (nullable, for standard entities) +- [ ] Entity has `isCustom` boolean column +- [ ] Entity-to-flat transform sets `universalIdentifier` correctly (`standardId || id`) + +### Registration (twenty-shared) +- [ ] Metadata name added to `ALL_METADATA_NAME` + +### Flat Entity Definition +- [ ] Flat entity type defined using `FlatEntityFrom` +- [ ] Flat entity maps type defined +- [ ] Editable properties constant defined + +### Central Constants Registration +- [ ] Registered in `AllFlatEntityTypesByMetadataName` +- [ ] Registered in `ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY` +- [ ] Registered in `ALL_METADATA_RELATIONS` +- [ ] Registered in `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION` + +### Cache Layer +- [ ] Cache service created with `@WorkspaceCache` decorator +- [ ] Cache key matches `flat{EntityName}Maps` pattern +- [ ] Cache service uses `withDeleted: true` when fetching entities +- [ ] Flat entity module created and exports cache service + +### Migration Builder +- [ ] Action types defined (create, update, delete) using base types +- [ ] Validator service created with correct behavior: + - [ ] **Never throws** - returns errors array (fail slow) + - [ ] **Never mutates** flat entity maps + - [ ] **No transformations** - only validates business rules +- [ ] Builder service extends `WorkspaceEntityMigrationBuilderService` + +### Migration Runner +- [ ] Action handlers created for create/update/delete +- [ ] Handlers extend `WorkspaceMigrationRunnerActionHandler('action', 'metadataName')` + +### Orchestrator Wiring (⚠️ COMMONLY FORGOTTEN) +- [ ] **Builder imported in orchestrator service** +- [ ] **Builder injected in orchestrator constructor** +- [ ] **Builder's `validateAndBuild` called in `buildWorkspaceMigration` method** +- [ ] **Actions added to return statement** + +### Module Registration +- [ ] Builder registered in builder module (providers + exports) +- [ ] Validator registered in validators module +- [ ] Action handlers registered in action handlers module + +### Testing +- [ ] Integration tests written (successful + failing scenarios)