twenty/.cursor/rules/creating-syncable-entity.mdc
Paul Rastoin bd9688421f
ObjectMetadata and FieldMetadata agnostic workspace migration runner (#17572)
# Introduction
Important note: This PR officially deprecates the `standardId`, about to
drop col and entity property after this has been merged

Important note2: Haven't updated the optimistic tool to also update the
universal identifier aggregators only the ids one, they should not be
consumed in the runner context -> need to improve typing or either the
optimistic tooling

In this PR we're introducing all the devxp allowing future metadata
incremental universal migration -> this has an impact on all existing
metadata actions handler ( explaining its size )
This PR also introduce workspace agnostic create update actions runner
for both field and object metadata in order to battle test the described
above devxp

Noting that these two metadata are the most complex to handle

Notes:
- A workspace migration is now highly bind to a
`applicationUniversalIdentifier`. Though we don't strictly validate
application scope for the moment

## Next
Migrate both object and field builder to universal comparison

## Universal Actions vs Flat Actions Architecture

### Concept

The migration system uses a two-phase action model:

1. **Universal Actions** - Actions defined using `universalIdentifier`
(stable, portable identifiers like `standardId` + `applicationId`)
2. **Flat Actions** - Actions defined using database `entityId` (UUIDs
specific to a workspace)

### Why This Separation?

- **Universal actions are portable**: They can be serialized, stored,
and replayed across different workspaces
- **Flat actions are executable**: They contain the actual database IDs
needed to perform operations
- **Decoupling**: The builder produces universal actions; the runner
transpiles them to flat actions at execution time

### Transpiler Pattern

Each action handler must implement
`transpileUniversalActionToFlatAction()`:

```typescript
@Injectable()
export class CreateFieldActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
  'create',
  'fieldMetadata',
) {
  override async transpileUniversalActionToFlatAction(
    context: WorkspaceMigrationActionRunnerArgs<UniversalCreateFieldAction>,
  ): Promise<FlatCreateFieldAction> {
    // Resolve universal identifiers to database IDs
    const flatObjectMetadata = findFlatEntityByUniversalIdentifierOrThrow({
      flatEntityMaps: allFlatEntityMaps.flatObjectMetadataMaps,
      universalIdentifier: action.objectMetadataUniversalIdentifier,
    });
    
    return {
      type: action.type,
      metadataName: action.metadataName,
      objectMetadataId: flatObjectMetadata.id, // Resolved ID
      flatFieldMetadatas: /* ... transpiled entities ... */,
    };
  }
}
```

### Action Handler Base Class

`BaseWorkspaceMigrationRunnerActionHandlerService<TActionType,
TMetadataName>` provides:

- **`transpileUniversalActionToFlatAction()`** - Abstract method each
handler must implement
- **`transpileUniversalDeleteActionToFlatDeleteAction()`** - Shared
helper for delete actions

## FlatEntityMaps custom properties
Introduced a `TWithCustomMapsProperties` generic parameter to control
whether custom indexing structures are included:

- **`false` (default)**: Returns `FlatEntityMaps<MetadataFlatEntity<T>>`
- used in builder/runner contexts
- **`true`**: Returns the full maps type with custom properties (e.g.,
`byUserWorkspaceIdAndFolderId`) - used in cache contexts

## Create Field Actions Refactor

Refactored create-field actions to support relation field pairs
bundling.

**Problem:** Relation fields (e.g., `Attachment.targetTask` ↔
`Task.attachments`) couldn't resolve each other's IDs during
transpilation because they were in separate actions with independent
`fieldIdByUniversalIdentifier` maps.

**Solution:** 
- Removed `objectMetadataUniversalIdentifier` from
`UniversalCreateFieldAction` and `objectMetadataId` from
`FlatCreateFieldAction` - each field now carries its own
- Runner groups fields by object internally and processes each table
separately
- Split aggregator into two focused utilities:
- `aggregateNonRelationFieldsIntoObjectActions` - merges non-relation
fields into object actions
- `aggregateRelationFieldPairs` - bundles relation pairs with shared
`fieldIdByUniversalIdentifier`
2026-02-02 12:22:38 +00:00

1350 lines
48 KiB
Text

---
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 2b: Using JsonbProperty and SerializedRelation Types](#step-2b-using-jsonbproperty-and-serializedrelation-types)
- [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 `SyncableEntity`
- **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 `SyncableEntity`** to participate in the workspace migration system.
```typescript
import {
Column,
CreateDateColumn,
Entity,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity('myEntity')
export class MyEntityEntity
extends SyncableEntity
implements Required<MyEntityEntity>
{
@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;
}
```
**What the base class provides:**
```typescript
// SyncableEntity - Base class for all syncable entities
// File: src/engine/workspace-manager/types/syncable-entity.interface.ts
@Index(['workspaceId', 'universalIdentifier'], { unique: true })
export abstract class SyncableEntity extends WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'uuid' })
universalIdentifier: string;
@Column({ nullable: false, type: 'uuid' })
applicationId: string;
@ManyToOne('ApplicationEntity', { onDelete: 'CASCADE', nullable: false })
@JoinColumn({ name: 'applicationId' })
application: Relation<ApplicationEntity>;
}
// WorkspaceRelatedEntity (common base)
// File: src/engine/workspace-manager/types/workspace-related-entity.ts
export abstract class WorkspaceRelatedEntity {
@Column({ nullable: false, type: 'uuid' })
workspaceId: string;
}
```
**Properties provided by `SyncableEntity`:**
| Property | Type | Description |
|----------|------|-------------|
| `universalIdentifier` | `string` (required) | Unique identifier for syncing across workspaces/applications |
| `applicationId` | `string` (required) | Links the entity to an application (Twenty Standard or Custom) |
| `workspaceId` | `string` (required) | Links the entity to a workspace (from `WorkspaceRelatedEntity`) |
---
### Step 2b: Using JsonbProperty and SerializedRelation Types
When your entity has JSONB columns or stores foreign key references inside JSONB structures, you must use the branded type wrappers to enable automatic universal identifier mapping.
#### JsonbProperty Wrapper
Wrap all JSONB column types with `JsonbProperty<T>` to mark them for the universal entity transformation system:
```typescript
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
@Entity('myEntity')
export class MyEntityEntity extends SyncableEntity {
// Simple JSONB column - wrap the type
@Column({ type: 'jsonb', nullable: true })
settings: JsonbProperty<MyEntitySettings> | null;
// JSONB column with complex type
@Column({ type: 'jsonb', nullable: false })
configuration: JsonbProperty<MyEntityConfiguration>;
// Array stored as JSONB
@Column({ type: 'jsonb', nullable: true })
tags: JsonbProperty<string[]> | null;
}
```
**When to use `JsonbProperty<T>`:**
- Any column with `type: 'jsonb'` that stores an object or array
- Configuration objects, settings, metadata blobs
- Any structured data stored as JSON in the database
**What it enables:**
- The type system can identify which properties are JSONB columns
- Automatic transformation of serialized relations within JSONB structures
- Type-safe universal entity mapping
#### SerializedRelation Type
Use `SerializedRelation` for properties **inside JSONB structures** that store foreign key references (entity IDs):
```typescript
import { SerializedRelation } from 'twenty-shared/types';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// Define the JSONB structure type
type MyEntityConfiguration = {
name: string;
// This stores a reference to another field's ID - use SerializedRelation
targetFieldMetadataId: SerializedRelation;
// This stores a reference to an object's ID
sourceObjectMetadataId: SerializedRelation;
// Regular string - NOT a foreign key reference
displayFormat: string;
};
@Entity('myEntity')
export class MyEntityEntity extends SyncableEntity {
@Column({ type: 'jsonb', nullable: false })
configuration: JsonbProperty<MyEntityConfiguration>;
}
```
**When to use `SerializedRelation`:**
- Properties inside JSONB that store UUIDs referencing other entities
- Foreign key relationships that can't use TypeORM relations (because they're in JSONB)
- Any `*Id` property inside a JSONB structure that references another metadata entity
**What it enables:**
- Automatic renaming from `*Id` to `*UniversalIdentifier` in universal entities
- Type-safe extraction of serialized relation properties
- Proper handling during workspace sync/migration
#### Complete Example
```typescript
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
import { SerializedRelation } from 'twenty-shared/types';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
// JSONB structure with serialized relations
type WidgetConfiguration = {
title: string;
// Foreign keys stored in JSONB - use SerializedRelation
fieldMetadataId: SerializedRelation;
objectMetadataId: SerializedRelation;
// Optional foreign key
viewId?: SerializedRelation;
// Regular properties (not foreign keys)
displayMode: 'compact' | 'expanded';
maxItems: number;
};
type GridPosition = {
row: number;
column: number;
width: number;
height: number;
};
@Entity('widget')
export class WidgetEntity extends SyncableEntity implements Required<WidgetEntity> {
@PrimaryGeneratedColumn('uuid')
id: string;
@Column({ nullable: true, type: 'uuid' })
standardId: string | null;
@Column({ nullable: false })
name: string;
// JSONB column with serialized relations - wrap with JsonbProperty
@Column({ type: 'jsonb', nullable: false })
configuration: JsonbProperty<WidgetConfiguration>;
// JSONB column without serialized relations - still wrap with JsonbProperty
@Column({ type: 'jsonb', nullable: false })
gridPosition: JsonbProperty<GridPosition>;
@Column({ default: false })
isCustom: boolean;
// ... other columns
}
```
**Result in Universal Entity:**
When transformed to a universal entity, the `configuration` property will have its `SerializedRelation` fields automatically renamed:
```typescript
// Original (in database/flat entity)
{
fieldMetadataId: "abc-123",
objectMetadataId: "def-456",
viewId: "ghi-789",
displayMode: "compact",
maxItems: 10,
}
// Transformed (in universal entity)
{
fieldMetadataUniversalIdentifier: "abc-123",
objectMetadataUniversalIdentifier: "def-456",
viewUniversalIdentifier: "ghi-789",
displayMode: "compact",
maxItems: 10,
}
```
---
### 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<MyEntityEntity>;
```
The `FlatEntityFrom<T>` utility type automatically:
- Removes relation properties (ManyToOne, OneToMany)
- Converts Date properties to string (ISO format)
- Adds `{relationName}Ids` arrays for OneToMany relations
- Preserves non-nullable `universalIdentifier` and `applicationId` from `SyncableEntity`
**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<FlatMyEntity>;
```
---
### 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<P>;
};
```
#### 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<FlatMyEntityMaps> {
constructor(
@InjectRepository(MyEntityEntity)
private readonly myEntityRepository: Repository<MyEntityEntity>,
) {
super();
}
async computeForCache(workspaceId: string): Promise<FlatMyEntityMaps> {
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 { belongsToTwentyStandardApp } 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 && belongsToTwentyStandardApp(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<typeof ALL_METADATA_NAME.myEntity>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.myEntity, 'create'> {
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<typeof ALL_METADATA_NAME.myEntity>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.myEntity, 'delete'> {
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<typeof ALL_METADATA_NAME.myEntity>,
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.myEntity, 'update'> {
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<CreateMyEntityAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { flatEntity } = action;
const repository =
queryRunner.manager.getRepository<MyEntityEntity>(MyEntityEntity);
await repository.save({
...flatEntity,
workspaceId,
});
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<CreateMyEntityAction>,
): Promise<void> {
// 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<UpdateMyEntityAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { entityId, updates } = action;
const repository =
queryRunner.manager.getRepository<MyEntityEntity>(MyEntityEntity);
await repository.update(
{ id: entityId, workspaceId },
fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ updates }),
);
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<UpdateMyEntityAction>,
): Promise<void> {
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<DeleteMyEntityAction>,
): Promise<void> {
const { action, queryRunner, workspaceId } = context;
const { entityId } = action;
const repository =
queryRunner.manager.getRepository<MyEntityEntity>(MyEntityEntity);
await repository.delete({ id: entityId, workspaceId });
}
async executeForWorkspaceSchema(
_context: WorkspaceMigrationActionRunnerArgs<DeleteMyEntityAction>,
): Promise<void> {
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 `SyncableEntity`**
- [ ] Entity has `standardId` column (nullable, for standard entities)
- [ ] Entity has `isCustom` boolean column
- [ ] Entity-to-flat transform sets `universalIdentifier` correctly (`standardId || id`)
### JSONB Properties and Serialized Relations
- [ ] All JSONB columns are wrapped with `JsonbProperty<T>`
- [ ] Foreign key references inside JSONB structures use `SerializedRelation` type
- [ ] JSONB structure types are properly defined with `SerializedRelation` for `*Id` properties
### Registration (twenty-shared)
- [ ] Metadata name added to `ALL_METADATA_NAME`
### Flat Entity Definition
- [ ] Flat entity type defined using `FlatEntityFrom<T>`
- [ ] 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)