twenty/.cursor/rules/creating-syncable-entity.mdc
Paul Rastoin d35d5c0463
[BREAKING_CHANGE] Deprecate remaining entities standardId (#17639)
# Introduction
Following https://github.com/twentyhq/twenty/pull/17632 and
https://github.com/twentyhq/twenty/pull/17572
This PR deprecates the agent, skill, field metadata and role
`standardId` in favor of the `universalIdentifier` usage

## Note
- Removed previous standard ids declaration modules
- Twenty-sdk now re-exports the `STANDARD_OBJECTS` universalIdentifier
hashmap constant
- deleted some sync-metadata deadcode too ( mainly types )
2026-02-03 09:06:24 +01:00

1370 lines
50 KiB
Text
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
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
- [Creating a New Syncable Entity](#creating-a-new-syncable-entity)
- [What is a Syncable Entity?](#what-is-a-syncable-entity)
- [Table of Contents](#table-of-contents)
- [Overview](#overview)
- [Key Design Principle: Separation of Concerns](#key-design-principle-separation-of-concerns)
- [File Structure](#file-structure)
- [Step-by-Step Implementation](#step-by-step-implementation)
- [Step 1: Add Metadata Name Constant (twenty-shared)](#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)
- [JsonbProperty Wrapper](#jsonbproperty-wrapper)
- [SerializedRelation Type](#serializedrelation-type)
- [Complete Example](#complete-example)
- [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)
- [5a. All Flat Entity Types Registry](#5a-all-flat-entity-types-registry)
- [5b. Properties to Compare and Stringify](#5b-properties-to-compare-and-stringify)
- [5c. Metadata Relations](#5c-metadata-relations)
- [5d. Required Metadata for Validation](#5d-required-metadata-for-validation)
- [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 (Runner)](#step-11-create-action-handlers-runner)
- [Step 12: Wire in Orchestrator Service (CRITICAL)](#step-12-wire-in-orchestrator-service-critical)
- [Step 13: Register in Modules](#step-13-register-in-modules)
- [13a. Builder Module](#13a-builder-module)
- [13b. Validators Module](#13b-validators-module)
- [13c. Action Handlers Module](#13c-action-handlers-module)
- [Using the Entity in Services](#using-the-entity-in-services)
- [Integration Tests](#integration-tests)
- [Checklist](#checklist)
- [Syncable Entity Requirements](#syncable-entity-requirements)
- [JSONB Properties and Serialized Relations](#jsonb-properties-and-serialized-relations)
- [Registration (twenty-shared)](#registration-twenty-shared)
- [Flat Entity Definition](#flat-entity-definition)
- [Central Constants Registration](#central-constants-registration)
- [Cache Layer](#cache-layer)
- [Migration Builder](#migration-builder)
- [Migration Runner](#migration-runner)
- [Orchestrator Wiring (⚠️ COMMONLY FORGOTTEN)](#orchestrator-wiring--commonly-forgotten)
- [Module Registration](#module-registration)
- [Testing](#testing)
---
## 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: 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: 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)