From d88fa0cb2bd661d15d903d06630bc77a30b23fe8 Mon Sep 17 00:00:00 2001 From: Paul Rastoin <45004772+prastoin@users.noreply.github.com> Date: Fri, 13 Feb 2026 12:18:31 +0100 Subject: [PATCH] Migrate create syncable entity cursor rule to skills (#17912) # Introduction Splitting the create syncable entity rule into dedicated scoped skills in order to favorise multi agent pattern with more granular context ### Multi-Agent Workflow For parallel development: 1. **Agent 1** (Foundation): Complete Step 1 first - unblocks everyone 2. **Agent 2** (Cache): Can start immediately after Step 1 3. **Agent 3** (Builder): Can work in parallel with Agent 4 after Step 1 4. **Agent 4** (Runner): Can work in parallel with Agent 3 after Step 1 5. **Agent 5** (Integration): Assembles everything after Steps 2-4 --- .cursor/rules/creating-syncable-entity.mdc | 1498 ++--------------- .../SKILL.md | 393 +++++ .../SKILL.md | 303 ++++ .../syncable-entity-integration/SKILL.md | 326 ++++ .../SKILL.md | 355 ++++ .../skills/syncable-entity-testing/SKILL.md | 494 ++++++ .../SKILL.md | 309 ++++ 7 files changed, 2352 insertions(+), 1326 deletions(-) create mode 100644 .cursor/skills/syncable-entity-builder-and-validation/SKILL.md create mode 100644 .cursor/skills/syncable-entity-cache-and-transform/SKILL.md create mode 100644 .cursor/skills/syncable-entity-integration/SKILL.md create mode 100644 .cursor/skills/syncable-entity-runner-and-actions/SKILL.md create mode 100644 .cursor/skills/syncable-entity-testing/SKILL.md create mode 100644 .cursor/skills/syncable-entity-types-and-constants/SKILL.md diff --git a/.cursor/rules/creating-syncable-entity.mdc b/.cursor/rules/creating-syncable-entity.mdc index facf4d9695..a237a3345c 100644 --- a/.cursor/rules/creating-syncable-entity.mdc +++ b/.cursor/rules/creating-syncable-entity.mdc @@ -1,1370 +1,216 @@ --- -description: Guide for creating a new syncable entity in Twenty's workspace migration system +description: Main guide for creating syncable entities in Twenty's workspace migration system globs: ["**/metadata-modules/**", "**/workspace-migration/**"] alwaysApply: false --- -# Creating a New Syncable Entity +# Creating a New Syncable Entity - Main Guide -This guide explains how to create a new **syncable entity** in Twenty's workspace migration architecture. +This is the main guide for creating **syncable entities** in Twenty's workspace migration architecture. + +## Documentation Structure + +This main guide provides a high-level overview and navigation hub. + +**⚡ Skills** (`.cursor/skills/syncable-entity-*/SKILL.md`) - Concise, action-oriented implementation guides for each step. Reference these when creating a new syncable entity. + +**When to use:** +- Start here for architecture overview and workflow +- Reference specific skills (`@syncable-entity-types-and-constants`) when implementing each step ## 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 a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications - 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. +Examples: `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: +## Architecture Overview ``` -Input DTO → Transform Utils → Flat Entity → Builder/Validator → Runner → Database - ↓ - Cache Service +Input DTO → Transform → Universal Flat Entity → Builder/Validator → Runner → Database + ↓ + Cache Service ``` +**Key Components:** - **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 +- **Flat Entity**: Denormalized type (no relations, dates as strings) - for caching +- **Universal Flat Entity**: Flat entity with foreign keys mapped to universal identifiers - for migrations +- **Transform Utils**: Convert DTOs to universal flat entities +- **Builder/Validator**: Validate and create migration actions +- **Runner**: Execute actions against the database -### Key Design Principle: Separation of Concerns +## Implementation Steps + +Follow these skills in order: + +### 1️⃣ **Foundation: Types & Constants** → `@syncable-entity-types-and-constants` + +**What:** Define all types, entities, and register in central constants + +**Tasks:** +- Create TypeORM entity (extends `SyncableEntity`) +- Define flat entity types +- Define action types (universal + flat) +- Register in 4 central constants + +**Why first:** Everything else depends on these types + +--- + +### 2️⃣ **Data Layer: Cache & Transform** → `@syncable-entity-cache-and-transform` + +**What:** Handle conversion between different representations + +**Tasks:** +- Create cache service +- Create entity-to-flat conversion +- Create input transform utils +- Handle foreign key resolution + +**Dependencies:** Requires Step 1 + +--- + +### 3️⃣ **Business Logic: Builder & Validation** → `@syncable-entity-builder-and-validation` + +**What:** Validate business rules and create actions + +**Tasks:** +- Create validator service (never throws, never mutates) +- Create builder service +- Wire into orchestrator (⚠️ critical!) + +**Dependencies:** Requires Steps 1-2 + +--- + +### 4️⃣ **Execution: Runner & Actions** → `@syncable-entity-runner-and-actions` + +**What:** Execute migration actions against the database + +**Tasks:** +- Create action handlers (create/update/delete) +- Implement transpilation methods +- Create universal-to-flat conversion utilities + +**Dependencies:** Requires Steps 1-3 + +--- + +### 5️⃣ **Assembly: Integration** → `@syncable-entity-integration` + +**What:** Wire everything together + +**Tasks:** +- Register in 3 NestJS modules +- Create service and resolver layers +- Use exception interceptor + +**Dependencies:** Requires Steps 1-4 + +--- + +### 6️⃣ **Testing: Integration Tests** (**MANDATORY**) → `@syncable-entity-testing` + +**What:** Comprehensive test suite + +**Tasks:** +- Create test utilities +- Write failing tests (all validator exceptions) +- Write successful tests (all CRUD operations) +- Use snapshot testing + +**Dependencies:** Requires all previous steps + +--- + +## Quick Reference + +### Multi-Agent Workflow + +For parallel development: +1. **Agent 1** (Foundation): Complete Step 1 first - unblocks everyone +2. **Agent 2** (Cache): Can start immediately after Step 1 +3. **Agent 3** (Builder): Can work in parallel with Agent 4 after Step 1 +4. **Agent 4** (Runner): Can work in parallel with Agent 3 after Step 1 +5. **Agent 5** (Integration): Assembles everything after Steps 2-4 + +### Key Design Principles | Layer | Responsibility | Can Throw? | Can Mutate? | |-------|---------------|------------|-------------| -| Transform Utils | Data transformation, sanitization, defaults | Yes (input validation) | N/A (creates new objects) | +| Transform Utils | Data transformation | Yes (input validation) | N/A (creates new) | | Validator | Business rule validation | **No** (returns errors) | **No** | | Builder | Action creation | **No** (returns errors) | **No** | | Runner | Database operations | Yes (DB errors) | Yes (via TypeORM) | ---- +### Common Pitfalls -## File Structure +⚠️ **Most Commonly Forgotten:** +1. Wiring builder in orchestrator service +2. Registering in all 3 modules (builder, validators, action handlers) +3. Setting `universalIdentifier` correctly in entity-to-flat conversion -For a new entity called `myEntity`, you'll create/modify files in these locations: +⚠️ **Common Mistakes:** +1. Using regular IDs instead of universal identifiers in transform utils +2. Throwing exceptions in validators/builders +3. Mutating entity maps in validators/builders +4. Forgetting to handle JSONB properties with `SerializedRelation` + +### File Locations ``` packages/twenty-shared/src/metadata/ -├── all-metadata-name.constant.ts # Add metadata name +└── all-metadata-name.constant.ts 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/ +├── my-entity/ # Step 1 +│ └── entities/ +├── flat-my-entity/ # Steps 1-2 │ ├── 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 +│ └── utils/ +└── flat-entity/constant/ # Step 1 (central registries) 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 +├── universal-flat-entity/constants/ # Step 1 +├── workspace-migration-builder/ # Step 3 +│ ├── builders/my-entity/ +│ └── validators/services/ +└── workspace-migration-runner/ # Step 4 + └── action-handlers/my-entity/ ``` +### Complete Checklist + +Before considering complete: +- [ ] All 6 guides completed +- [ ] TypeORM entity extends `SyncableEntity` +- [ ] All constants registered (4 central registries) +- [ ] Cache service with correct decorator +- [ ] Transform utils return universal flat entities +- [ ] Validator never throws/mutates +- [ ] Builder wired in orchestrator (⚠️ critical!) +- [ ] All 3 action handlers implemented +- [ ] All 3 modules updated +- [ ] **Integration tests written (MANDATORY)** +- [ ] **All failing scenarios covered** +- [ ] **All successful use cases tested** + --- -## 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 -{ - @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; -} - -// 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` 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 | null; - - // JSONB column with complex type - @Column({ type: 'jsonb', nullable: false }) - configuration: JsonbProperty; - - // Array stored as JSONB - @Column({ type: 'jsonb', nullable: true }) - tags: JsonbProperty | null; -} -``` - -**When to use `JsonbProperty`:** -- 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; -} -``` - -**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 { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ nullable: false }) - name: string; - - // JSONB column with serialized relations - wrap with JsonbProperty - @Column({ type: 'jsonb', nullable: false }) - configuration: JsonbProperty; - - // JSONB column without serialized relations - still wrap with JsonbProperty - @Column({ type: 'jsonb', nullable: false }) - gridPosition: JsonbProperty; - - @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; -``` - -The `FlatEntityFrom` utility type automatically: -- Removes relation properties (ManyToOne, OneToMany) -- Converts Date properties to string (ISO format) -- Adds `{relationName}Ids` arrays for OneToMany relations -- Preserves 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; -``` - ---- - -### Step 4: Define Editable Properties - -**File:** `src/engine/metadata-modules/flat-my-entity/constants/flat-my-entity-editable-properties.constant.ts` - -```typescript -import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; - -export const FLAT_MY_ENTITY_EDITABLE_PROPERTIES = [ - 'name', - 'label', - 'icon', - 'description', -] as const satisfies (keyof FlatMyEntity)[]; -``` - ---- - -### Step 5: Register in Central Constants - -#### 5a. All Flat Entity Types Registry - -**File:** `src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts` - -```typescript -// Add imports -import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; -import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; -import { - type CreateMyEntityAction, - type DeleteMyEntityAction, - type UpdateMyEntityAction, -} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; - -export type AllFlatEntityTypesByMetadataName = { - // ... existing entries - myEntity: { - actions: { - create: CreateMyEntityAction; - update: UpdateMyEntityAction; - delete: DeleteMyEntityAction; - }; - flatEntity: FlatMyEntity; - entity: MyEntityEntity; - }; -}; -``` - -#### 5b. Properties to Compare and Stringify - -**File:** `src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts` - -```typescript -import { FLAT_MY_ENTITY_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-my-entity/constants/flat-my-entity-editable-properties.constant'; - -export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = { - // ... existing entries - myEntity: { - propertiesToCompare: [...FLAT_MY_ENTITY_EDITABLE_PROPERTIES], - propertiesToStringify: [], // Add properties that need JSON.stringify for comparison - }, -} as const satisfies { - [P in AllMetadataName]: OneFlatEntityConfiguration

; -}; -``` - -#### 5c. Metadata Relations - -**File:** `src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts` - -```typescript -export const ALL_METADATA_RELATIONS = { - // ... existing entries - myEntity: { - manyToOne: { - workspace: null, - application: null, - // Add other relations if your entity has them: - // parentEntity: { - // metadataName: 'parentEntity', - // flatEntityForeignKeyAggregator: 'myEntityIds', - // foreignKey: 'parentEntityId', - // }, - }, - oneToMany: {}, - }, -} as const satisfies MetadataRelationsProperties; -``` - -#### 5d. Required Metadata for Validation - -**File:** `src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts` - -```typescript -export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { - // ... existing entries - myEntity: { - // Add metadata names that are required when validating this entity: - // parentEntity: true, - }, -} as const satisfies MetadataRequiredForValidation; -``` - ---- - -### Step 6: Create Cache Service - -**File:** `src/engine/metadata-modules/flat-my-entity/services/workspace-flat-my-entity-map-cache.service.ts` - -```typescript -import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; - -import { Repository } from 'typeorm'; - -import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; - -import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; -import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type'; -import { fromMyEntityEntityToFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util'; -import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; -import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; -import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util'; - -@Injectable() -@WorkspaceCache('flatMyEntityMaps') // Key must match the AllFlatEntityMaps property name -export class WorkspaceFlatMyEntityMapCacheService extends WorkspaceCacheProvider { - constructor( - @InjectRepository(MyEntityEntity) - private readonly myEntityRepository: Repository, - ) { - super(); - } - - async computeForCache(workspaceId: string): Promise { - const entities = await this.myEntityRepository.find({ - where: { workspaceId }, - withDeleted: true, // Include soft-deleted entities - }); - - const flatMyEntityMaps = createEmptyFlatEntityMaps(); - - for (const entity of entities) { - const flatEntity = fromMyEntityEntityToFlatMyEntity(entity); - - addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ - flatEntity, - flatEntityMapsToMutate: flatMyEntityMaps, - }); - } - - return flatMyEntityMaps; - } -} -``` - -**File:** `src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util.ts` - -```typescript -import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; -import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; - -export const fromMyEntityEntityToFlatMyEntity = ( - entity: MyEntityEntity, -): FlatMyEntity => { - return { - id: entity.id, - standardId: entity.standardId, - name: entity.name, - label: entity.label, - icon: entity.icon, - description: entity.description, - isCustom: entity.isCustom, - workspaceId: entity.workspaceId, - // universalIdentifier: standardId for standard entities, id for custom entities - universalIdentifier: entity.standardId || entity.id, - applicationId: entity.applicationId, - createdAt: entity.createdAt.toISOString(), - updatedAt: entity.updatedAt.toISOString(), - }; -}; -``` - -> **Important:** The `universalIdentifier` must be set correctly: -> - For **standard entities** (shipped with Twenty): use `standardId` -> - For **custom entities** (created by users): use `id` -> - Pattern: `entity.standardId || entity.id` - ---- - -### Step 7: Create Flat Entity Module - -**File:** `src/engine/metadata-modules/flat-my-entity/flat-my-entity.module.ts` - -```typescript -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; - -import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module'; -import { WorkspaceFlatMyEntityMapCacheService } from 'src/engine/metadata-modules/flat-my-entity/services/workspace-flat-my-entity-map-cache.service'; -import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; - -@Module({ - imports: [ - TypeOrmModule.forFeature([MyEntityEntity]), - WorkspaceManyOrAllFlatEntityMapsCacheModule, - ], - providers: [WorkspaceFlatMyEntityMapCacheService], - exports: [WorkspaceFlatMyEntityMapCacheService], -}) -export class FlatMyEntityModule {} -``` - ---- - -### Step 8: Define Action Types - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type.ts` - -```typescript -import { type BaseCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-create-workspace-migration-action.type'; -import { type BaseDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-delete-workspace-migration-action.type'; -import { type BaseUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-update-workspace-migration-action.type'; - -export type CreateMyEntityAction = BaseCreateWorkspaceMigrationAction<'myEntity'>; - -export type UpdateMyEntityAction = BaseUpdateWorkspaceMigrationAction<'myEntity'>; - -export type DeleteMyEntityAction = BaseDeleteWorkspaceMigrationAction<'myEntity'>; -``` - ---- - -### Step 9: Create Validator Service - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service.ts` - -> **Critical Rules for Validators and Builders:** -> -> 1. **Never throw exceptions** - Always fail slow by collecting errors in an array and returning them to the caller. This allows validating all entities and reporting all errors at once, rather than failing on the first error. -> -> 2. **Never mutate entity maps** - The validator/builder should only read from the flat entity maps, never modify them. No data transformation should happen here - transformations belong in the input-to-flat-entity utilities before calling the migration service. -> -> 3. **No side effects** - Validation logic should be pure: given the same inputs, it should always produce the same outputs. - -```typescript -import { Injectable } from '@nestjs/common'; - -import { msg, t } from '@lingui/core/macro'; -import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; -import { isDefined } from 'twenty-shared/utils'; - -import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; -import { 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, - ): FlatEntityValidationReturnType { - const validationResult = - this.flatMyEntityValidatorService.validateFlatMyEntityCreation(args); - - if (validationResult.errors.length > 0) { - return { - status: 'fail', - ...validationResult, - }; - } - - const { flatEntityToValidate } = args; - - return { - status: 'success', - action: { - type: 'create', - metadataName: 'myEntity', - flatEntity: flatEntityToValidate, - }, - }; - } - - protected validateFlatEntityDeletion( - args: FlatEntityValidationArgs, - ): FlatEntityValidationReturnType { - const validationResult = - this.flatMyEntityValidatorService.validateFlatMyEntityDeletion(args); - - if (validationResult.errors.length > 0) { - return { - status: 'fail', - ...validationResult, - }; - } - - const { flatEntityToValidate } = args; - - return { - status: 'success', - action: { - type: 'delete', - metadataName: 'myEntity', - entityId: flatEntityToValidate.id, - }, - }; - } - - protected validateFlatEntityUpdate( - args: FlatEntityUpdateValidationArgs, - ): FlatEntityValidationReturnType { - const validationResult = - this.flatMyEntityValidatorService.validateFlatMyEntityUpdate(args); - - if (validationResult.errors.length > 0) { - return { - status: 'fail', - ...validationResult, - }; - } - - const { flatEntityId, flatEntityUpdates } = args; - - const updateAction: UpdateMyEntityAction = { - type: 'update', - metadataName: 'myEntity', - entityId: flatEntityId, - update: flatEntityUpdates, - }; - - return { - status: 'success', - action: updateAction, - }; - } -} -``` - ---- - -### Step 11: Create Action Handlers (Runner) - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service.ts` - -```typescript -import { Injectable } from '@nestjs/common'; - -import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; - -import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; -import { CreateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; -import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; - -@Injectable() -export class CreateMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler( - 'create', - 'myEntity', -) { - constructor() { - super(); - } - - async executeForMetadata( - context: WorkspaceMigrationActionRunnerArgs, - ): Promise { - const { action, queryRunner, workspaceId } = context; - const { flatEntity } = action; - - const repository = - queryRunner.manager.getRepository(MyEntityEntity); - - await repository.save({ - ...flatEntity, - workspaceId, - }); - } - - async executeForWorkspaceSchema( - _context: WorkspaceMigrationActionRunnerArgs, - ): Promise { - // Only needed for entities that affect the workspace schema (objects, fields) - return; - } -} -``` - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service.ts` - -```typescript -import { Injectable } from '@nestjs/common'; - -import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; - -import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; -import { UpdateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; -import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; -import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity'; - -@Injectable() -export class UpdateMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler( - 'update', - 'myEntity', -) { - async executeForMetadata( - context: WorkspaceMigrationActionRunnerArgs, - ): Promise { - const { action, queryRunner, workspaceId } = context; - const { entityId, updates } = action; - - const repository = - queryRunner.manager.getRepository(MyEntityEntity); - - await repository.update( - { id: entityId, workspaceId }, - fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ updates }), - ); - } - - async executeForWorkspaceSchema( - _context: WorkspaceMigrationActionRunnerArgs, - ): Promise { - return; - } -} -``` - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service.ts` - -```typescript -import { Injectable } from '@nestjs/common'; - -import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; - -import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; -import { DeleteMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; -import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; - -@Injectable() -export class DeleteMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler( - 'delete', - 'myEntity', -) { - constructor() { - super(); - } - - async executeForMetadata( - context: WorkspaceMigrationActionRunnerArgs, - ): Promise { - const { action, queryRunner, workspaceId } = context; - const { entityId } = action; - - const repository = - queryRunner.manager.getRepository(MyEntityEntity); - - await repository.delete({ id: entityId, workspaceId }); - } - - async executeForWorkspaceSchema( - _context: WorkspaceMigrationActionRunnerArgs, - ): Promise { - return; - } -} -``` - ---- - -### Step 12: Wire in Orchestrator Service (CRITICAL) - -> **⚠️ This step is frequently forgotten!** The orchestrator service must be updated to call your builder's `validateAndBuild` method. - -**File:** `src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts` - -1. **Add import:** - -```typescript -import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service'; -``` - -2. **Add to constructor:** - -```typescript -constructor( - // ... existing services - private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService, -) {} -``` - -3. **Add to `buildWorkspaceMigration` method** (after extracting from `fromToAllFlatEntityMaps`): - -```typescript -const { - // ... existing extractions - flatMyEntityMaps, -} = fromToAllFlatEntityMaps; -``` - -4. **Add the builder call block:** - -```typescript -if (isDefined(flatMyEntityMaps)) { - const { from: fromFlatMyEntityMaps, to: toFlatMyEntityMaps } = - flatMyEntityMaps; - - const myEntityResult = - await this.workspaceMigrationMyEntityActionsBuilderService.validateAndBuild( - { - additionalCacheDataMaps, - from: fromFlatMyEntityMaps, - to: toFlatMyEntityMaps, - buildOptions, - dependencyOptimisticFlatEntityMaps: undefined, // Or add dependencies - workspaceId, - }, - ); - - this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation( - { - allFlatEntityMaps: optimisticAllFlatEntityMaps, - flatEntityMapsAndRelatedFlatEntityMaps: - myEntityResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps, - }, - ); - - if (myEntityResult.status === 'fail') { - orchestratorFailureReport.myEntity.push(...myEntityResult.errors); - } else { - orchestratorActionsReport.myEntity = myEntityResult.actions; - } -} -``` - -5. **Add actions to the return statement:** - -```typescript -return { - // ... existing - workspaceMigration: { - // ... existing - actions: [ - // ... existing actions - - // My Entity - ...aggregatedOrchestratorActionsReport.myEntity.delete, - ...aggregatedOrchestratorActionsReport.myEntity.create, - ...aggregatedOrchestratorActionsReport.myEntity.update, - ], - }, -}; -``` - ---- - -### Step 13: Register in Modules - -#### 13a. Builder Module - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts` - -```typescript -import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service'; - -@Module({ - // ... - providers: [ - // ... existing - WorkspaceMigrationMyEntityActionsBuilderService, - ], - exports: [ - // ... existing - WorkspaceMigrationMyEntityActionsBuilderService, - ], -}) -export class WorkspaceMigrationBuilderModule {} -``` - -#### 13b. Validators Module - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts` - -```typescript -import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service'; - -@Module({ - // ... - providers: [ - // ... existing - FlatMyEntityValidatorService, - ], - exports: [ - // ... existing - FlatMyEntityValidatorService, - ], -}) -export class WorkspaceMigrationBuilderValidatorsModule {} -``` - -#### 13c. Action Handlers Module - -**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts` - -```typescript -import { CreateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service'; -import { UpdateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service'; -import { DeleteMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service'; - -@Module({ - // ... - providers: [ - // ... existing - CreateMyEntityActionHandlerService, - UpdateMyEntityActionHandlerService, - DeleteMyEntityActionHandlerService, - ], -}) -export class WorkspaceSchemaMigrationRunnerActionHandlersModule {} -``` - ---- - -## Using the Entity in Services - -Here's how to use the entity in a business service. - -**Important:** All data transformations (sanitization, normalization, default values) must happen **before** calling `validateBuildAndRunWorkspaceMigration`. The builder/validator should receive clean, ready-to-persist flat entities - it only validates business rules and creates actions, it does not transform data. - -```typescript -import { Injectable } from '@nestjs/common'; - -import { isDefined } from 'twenty-shared/utils'; - -import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util'; -import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; -import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; - -@Injectable() -export class MyEntityService { - constructor( - private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, - private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, - ) {} - - async create(input: CreateMyEntityInput, workspaceId: string) { - // 1. Transform input to flat entity - // This is where ALL transformations happen: - // - Sanitize strings (trim, remove duplicate whitespace) - // - Compute derived values (e.g., name from label) - // - Set default values - // - Generate IDs - // - Set universalIdentifier and applicationId - const flatMyEntityToCreate = fromCreateInputToFlatEntity({ - input, - workspaceId, - }); - - // 2. Call validate, build, and run - const validateAndBuildResult = - await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( - { - allFlatEntityOperationByMetadataName: { - myEntity: { - flatEntityToCreate: [flatMyEntityToCreate], - flatEntityToDelete: [], - flatEntityToUpdate: [], - }, - }, - workspaceId, - isSystemBuild: false, - }, - ); - - // 3. Throw if validation failed - if (isDefined(validateAndBuildResult)) { - throw new WorkspaceMigrationBuilderException( - validateAndBuildResult, - 'Validation errors occurred while creating entity', - ); - } - - // 4. Return freshly cached entity - const { flatMyEntityMaps } = - await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatMyEntityMaps'], - }, - ); - - return findFlatEntityByIdInFlatEntityMapsOrThrow({ - flatEntityId: flatMyEntityToCreate.id, - flatEntityMaps: flatMyEntityMaps, - }); - } -} -``` - ---- - -## Integration Tests - -Create integration tests following the existing pattern: - -``` -test/integration/metadata/suites/my-entity/ -├── __snapshots__/ -│ └── failing-my-entity-creation.integration-spec.ts.snap -├── failing-my-entity-creation.integration-spec.ts -├── successful-my-entity-creation.integration-spec.ts -└── utils/ - ├── create-one-my-entity.util.ts - └── delete-one-my-entity.util.ts -``` - -Tests should cover: -- Successful CRUD operations -- Failing validation scenarios (using `eachTestingContextFilter` pattern) -- Edge cases and business rules - ---- - -## Checklist - -Before considering your syncable entity complete, verify: - -### Syncable Entity Requirements -- [ ] TypeORM entity **extends `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` -- [ ] 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` -- [ ] 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) +## Need Help? + +Reference the appropriate skill for step-by-step guidance: +- `@syncable-entity-types-and-constants` - Types, entities, constants +- `@syncable-entity-cache-and-transform` - Cache & transform +- `@syncable-entity-builder-and-validation` - Builder & validation +- `@syncable-entity-runner-and-actions` - Runner & actions +- `@syncable-entity-integration` - Integration & wiring +- `@syncable-entity-testing` - Testing patterns diff --git a/.cursor/skills/syncable-entity-builder-and-validation/SKILL.md b/.cursor/skills/syncable-entity-builder-and-validation/SKILL.md new file mode 100644 index 0000000000..e406611ebe --- /dev/null +++ b/.cursor/skills/syncable-entity-builder-and-validation/SKILL.md @@ -0,0 +1,393 @@ +--- +name: syncable-entity-builder-and-validation +description: Create validation logic and migration action builders for syncable entities in Twenty. Use when implementing business rule validation, uniqueness checks, foreign key validation, or building workspace migration actions for syncable entities. Validators never throw and never mutate. +--- + +# Syncable Entity: Builder & Validation (Step 3/6) + +**Purpose**: Implement business rule validation and create migration action builders. + +**When to use**: After completing Steps 1-2 (Types, Cache, Transform). Required before implementing action handlers. + +--- + +## Quick Start + +This step creates: +1. Validator service (business logic validation) +2. Builder service (action creation) +3. Orchestrator wiring (**CRITICAL** - often forgotten!) + +**Key principles**: +- Validators **never throw** - return error arrays +- Validators **never mutate** - pass optimistic entity maps +- Use indexed lookups (O(1)) not `Object.values().find()` (O(n)) + +--- + +## Step 1: Create Validator Service + +**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { t, msg } from '@lingui/macro'; +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; +import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type'; +import { WorkspaceMigrationValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/types/workspace-migration-validation-error.type'; +import { MyEntityExceptionCode } from 'src/engine/metadata-modules/my-entity/exceptions/my-entity-exception-code.enum'; + +@Injectable() +export class FlatMyEntityValidatorService { + validateMyEntityForCreate( + flatMyEntity: FlatMyEntity, + optimisticFlatMyEntityMaps: FlatMyEntityMaps, + ): WorkspaceMigrationValidationError[] { + const errors: WorkspaceMigrationValidationError[] = []; + + // Pattern 1: Required field validation + if (!isDefined(flatMyEntity.name) || flatMyEntity.name.trim() === '') { + errors.push({ + code: MyEntityExceptionCode.NAME_REQUIRED, + message: t`Name is required`, + userFriendlyMessage: msg`Please provide a name for this entity`, + }); + } + + // Pattern 2: Uniqueness check - use indexed map (O(1)) + const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name]; + + if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) { + errors.push({ + code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS, + message: t`Entity with name ${flatMyEntity.name} already exists`, + userFriendlyMessage: msg`An entity with this name already exists`, + }); + } + + // Pattern 3: Foreign key validation + if (isDefined(flatMyEntity.parentEntityId)) { + const parentEntity = optimisticFlatParentEntityMaps.byId[flatMyEntity.parentEntityId]; + + if (!isDefined(parentEntity)) { + errors.push({ + code: MyEntityExceptionCode.PARENT_ENTITY_NOT_FOUND, + message: t`Parent entity with ID ${flatMyEntity.parentEntityId} not found`, + userFriendlyMessage: msg`The specified parent entity does not exist`, + }); + } else if (isDefined(parentEntity.deletedAt)) { + errors.push({ + code: MyEntityExceptionCode.PARENT_ENTITY_DELETED, + message: t`Parent entity is deleted`, + userFriendlyMessage: msg`Cannot reference a deleted parent entity`, + }); + } + } + + // Pattern 4: Standard entity protection + if (flatMyEntity.isCustom === false) { + errors.push({ + code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_CREATED, + message: t`Cannot create standard entity`, + userFriendlyMessage: msg`Standard entities can only be created by the system`, + }); + } + + return errors; + } + + validateMyEntityForUpdate( + flatMyEntity: FlatMyEntity, + updates: Partial, + optimisticFlatMyEntityMaps: FlatMyEntityMaps, + ): WorkspaceMigrationValidationError[] { + const errors: WorkspaceMigrationValidationError[] = []; + + // Standard entity protection + if (flatMyEntity.isCustom === false) { + errors.push({ + code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_UPDATED, + message: t`Cannot update standard entity`, + userFriendlyMessage: msg`Standard entities cannot be modified`, + }); + return errors; // Early return if standard + } + + // Uniqueness check for name changes + if (isDefined(updates.name) && updates.name !== flatMyEntity.name) { + const existingEntityWithName = optimisticFlatMyEntityMaps.byName[updates.name]; + + if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) { + errors.push({ + code: MyEntityExceptionCode.MY_ENTITY_ALREADY_EXISTS, + message: t`Entity with name ${updates.name} already exists`, + userFriendlyMessage: msg`An entity with this name already exists`, + }); + } + } + + return errors; + } + + validateMyEntityForDelete( + flatMyEntity: FlatMyEntity, + ): WorkspaceMigrationValidationError[] { + const errors: WorkspaceMigrationValidationError[] = []; + + // Standard entity protection + if (flatMyEntity.isCustom === false) { + errors.push({ + code: MyEntityExceptionCode.STANDARD_ENTITY_CANNOT_BE_DELETED, + message: t`Cannot delete standard entity`, + userFriendlyMessage: msg`Standard entities cannot be deleted`, + }); + } + + return errors; + } +} +``` + +**Performance warning**: Avoid `Object.values().find()` - use indexed maps instead! + +```typescript +// ❌ BAD: O(n) - slow for large datasets +const duplicate = Object.values(optimisticFlatMyEntityMaps.byId).find( + (entity) => entity.name === flatMyEntity.name && entity.id !== flatMyEntity.id +); + +// ✅ GOOD: O(1) - use indexed map +const existingEntityWithName = optimisticFlatMyEntityMaps.byName[flatMyEntity.name]; +if (isDefined(existingEntityWithName) && existingEntityWithName.id !== flatMyEntity.id) { + // Handle duplicate +} +``` + +--- + +## Step 2: Create Builder Service + +**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; + +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-entity-migration-builder.service'; +import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service'; +import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type'; +import { + type UniversalCreateMyEntityAction, + type UniversalUpdateMyEntityAction, + type UniversalDeleteMyEntityAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; + +@Injectable() +export class WorkspaceMigrationMyEntityActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + 'myEntity', + UniversalFlatMyEntity, + UniversalCreateMyEntityAction, + UniversalUpdateMyEntityAction, + UniversalDeleteMyEntityAction +> { + constructor( + private readonly flatMyEntityValidatorService: FlatMyEntityValidatorService, + ) { + super(); + } + + protected buildCreateAction( + universalFlatMyEntity: UniversalFlatMyEntity, + flatEntityMaps: AllFlatEntityMapsByMetadataName, + ): BuildWorkspaceMigrationActionReturnType { + const validationResult = this.flatMyEntityValidatorService.validateMyEntityForCreate( + universalFlatMyEntity, + flatEntityMaps.flatMyEntityMaps, + ); + + if (validationResult.length > 0) { + return { + status: 'failed', + errors: validationResult, + }; + } + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'myEntity', + universalFlatEntity: universalFlatMyEntity, + }, + }; + } + + protected buildUpdateAction( + universalFlatMyEntity: UniversalFlatMyEntity, + universalUpdates: Partial, + flatEntityMaps: AllFlatEntityMapsByMetadataName, + ): BuildWorkspaceMigrationActionReturnType { + const validationResult = this.flatMyEntityValidatorService.validateMyEntityForUpdate( + universalFlatMyEntity, + universalUpdates, + flatEntityMaps.flatMyEntityMaps, + ); + + if (validationResult.length > 0) { + return { + status: 'failed', + errors: validationResult, + }; + } + + return { + status: 'success', + action: { + type: 'update', + metadataName: 'myEntity', + universalFlatEntity: universalFlatMyEntity, + universalUpdates, + }, + }; + } + + protected buildDeleteAction( + universalFlatMyEntity: UniversalFlatMyEntity, + ): BuildWorkspaceMigrationActionReturnType { + const validationResult = this.flatMyEntityValidatorService.validateMyEntityForDelete( + universalFlatMyEntity, + ); + + if (validationResult.length > 0) { + return { + status: 'failed', + errors: validationResult, + }; + } + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'myEntity', + universalFlatEntity: universalFlatMyEntity, + }, + }; + } +} +``` + +--- + +## Step 3: Wire into Orchestrator (**CRITICAL**) + +**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-build-orchestrator.service.ts` + +```typescript +@Injectable() +export class WorkspaceMigrationBuildOrchestratorService { + constructor( + // ... existing builders + private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService, + ) {} + + async buildWorkspaceMigration({ + allFlatEntityOperationByMetadataName, + flatEntityMaps, + isSystemBuild, + }: BuildWorkspaceMigrationInput): Promise { + // ... existing code + + // Add your entity builder + const myEntityResult = await this.workspaceMigrationMyEntityActionsBuilderService.build({ + flatEntitiesToCreate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToCreate ?? [], + flatEntitiesToUpdate: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToUpdate ?? [], + flatEntitiesToDelete: allFlatEntityOperationByMetadataName.myEntity?.flatEntityToDelete ?? [], + flatEntityMaps, + isSystemBuild, + }); + + // ... aggregate errors + + return { + status: aggregatedErrors.length > 0 ? 'failed' : 'success', + errors: aggregatedErrors, + actions: [ + ...existingActions, + ...myEntityResult.actions, + ], + }; + } +} +``` + +**⚠️ This step is the most commonly forgotten!** Your entity won't sync without orchestrator wiring. + +--- + +## Validation Patterns + +### Pattern 1: Required Field +```typescript +if (!isDefined(field) || field.trim() === '') { + errors.push({ code: ..., message: ..., userFriendlyMessage: ... }); +} +``` + +### Pattern 2: Uniqueness (O(1) lookup) +```typescript +const existing = optimisticMaps.byName[entity.name]; +if (isDefined(existing) && existing.id !== entity.id) { + errors.push({ ... }); +} +``` + +### Pattern 3: Foreign Key Validation +```typescript +if (isDefined(entity.parentId)) { + const parent = parentMaps.byId[entity.parentId]; + if (!isDefined(parent)) { + errors.push({ code: NOT_FOUND, ... }); + } else if (isDefined(parent.deletedAt)) { + errors.push({ code: DELETED, ... }); + } +} +``` + +### Pattern 4: Standard Entity Protection +```typescript +if (entity.isCustom === false) { + errors.push({ code: STANDARD_ENTITY_PROTECTED, ... }); + return errors; // Early return +} +``` + +--- + +## Checklist + +Before moving to Step 4: + +- [ ] Validator service created +- [ ] Validator **never throws** (returns error arrays) +- [ ] Validator **never mutates** (uses optimistic maps) +- [ ] All uniqueness checks use indexed maps (O(1)) +- [ ] Required field validation implemented +- [ ] Foreign key validation implemented +- [ ] Standard entity protection implemented +- [ ] Builder service extends `WorkspaceEntityMigrationBuilderService` +- [ ] Builder creates actions with universal entities +- [ ] **Builder wired into orchestrator** (**CRITICAL**) +- [ ] **Builder injected in orchestrator constructor** +- [ ] **Builder called in `buildWorkspaceMigration`** +- [ ] **Actions added to orchestrator return statement** + +--- + +## Next Step + +Once builder and validation are complete, proceed to: +**[Syncable Entity: Runner & Actions (Step 4/6)](../syncable-entity-runner-and-actions/SKILL.md)** + +For complete workflow, see `@creating-syncable-entity` rule. diff --git a/.cursor/skills/syncable-entity-cache-and-transform/SKILL.md b/.cursor/skills/syncable-entity-cache-and-transform/SKILL.md new file mode 100644 index 0000000000..b8800ffae8 --- /dev/null +++ b/.cursor/skills/syncable-entity-cache-and-transform/SKILL.md @@ -0,0 +1,303 @@ +--- +name: syncable-entity-cache-and-transform +description: Create cache services and transformation utilities for syncable entities in Twenty. Use when implementing entity-to-flat conversions, input DTO transpilation to universal flat entities, or cache recomputation for syncable entities. +--- + +# Syncable Entity: Cache & Transform (Step 2/6) + +**Purpose**: Create cache layer and transformation utilities to convert between different entity representations. + +**When to use**: After completing Step 1 (Types & Constants). Required before building validators and action handlers. + +--- + +## Quick Start + +This step creates: +1. Cache service for flat entity maps +2. Entity-to-flat conversion utility +3. Input transform utils (DTO → Universal Flat Entity) + +**Key principle**: Input transform utils must output **universal flat entities** (with `universalIdentifier` and foreign keys mapped to universal identifiers). + +--- + +## Step 1: Create Cache Service + +**File**: `src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { WorkspaceCache } from 'src/engine/twenty-orm/decorators/workspace-cache.decorator'; +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +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'; + +@Injectable() +export class FlatMyEntityCacheService { + constructor( + @InjectRepository(MyEntityEntity, 'metadata') + private readonly myEntityRepository: Repository, + ) {} + + @WorkspaceCache({ flatMapsKey: 'flatMyEntityMaps' }) + async getFlatMyEntityMaps(): Promise { + const myEntities = await this.myEntityRepository.find({ + withDeleted: true, // CRITICAL: Include soft-deleted entities + }); + + const flatMyEntities = myEntities.map((entity) => + fromMyEntityEntityToFlatMyEntity(entity), + ); + + return { + byId: Object.fromEntries(flatMyEntities.map((e) => [e.id, e])), + byName: Object.fromEntries(flatMyEntities.map((e) => [e.name, e])), + }; + } +} +``` + +**Critical rules**: +- Use `@WorkspaceCache` decorator with unique `flatMapsKey` +- **Always** use `withDeleted: true` to include soft-deleted entities +- Cache key pattern: `flat{EntityName}Maps` (camelCase) + +--- + +## Step 2: Entity-to-Flat Conversion + +**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util.ts` + +```typescript +import { v4 } from 'uuid'; +import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; + +export const fromMyEntityEntityToFlatMyEntity = ( + entity: MyEntityEntity, +): FlatMyEntity => { + return { + id: entity.id, + // Critical: generate a new UUID for universalIdentifier + universalIdentifier: v4(), + workspaceId: entity.workspaceId, + applicationId: entity.applicationId, + name: entity.name, + label: entity.label, + description: entity.description, + isCustom: entity.isCustom, + parentEntityId: entity.parentEntityId, + settings: entity.settings, + createdAt: entity.createdAt.toISOString(), + updatedAt: entity.updatedAt.toISOString(), + deletedAt: entity.deletedAt?.toISOString() ?? null, + }; +}; +``` + +**Critical**: `universalIdentifier` must be a new UUID generated with `v4()` (not `entity.id`) + +--- + +## Step 3: Input Transform Utils (DTO → Universal Flat Entity) + +**File**: `src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.util.ts` + +```typescript +import { v4 } from 'uuid'; +import { sanitizeString } from 'twenty-shared/string'; +import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input'; +import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type'; +import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util'; +import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type'; + +export const fromCreateMyEntityInputToUniversalFlatMyEntity = ({ + input, + workspaceId, + flatEntityMaps, +}: { + input: CreateMyEntityInput; + workspaceId: string; + flatEntityMaps?: AllFlatEntityMapsByMetadataName; +}): UniversalFlatMyEntity => { + const id = v4(); + const universalIdentifier = v4(); + + // 1. Extract foreign key IDs BEFORE sanitization + const parentEntityId = input.parentEntityId ?? null; + + // 2. Sanitize string properties + const name = sanitizeString(input.name); + const label = sanitizeString(input.label); + const description = input.description ? sanitizeString(input.description) : null; + + // 3. Build base flat entity + const baseFlatEntity = { + id, + universalIdentifier, + workspaceId, + applicationId: null, + name, + label, + description, + isCustom: true, + parentEntityId, + settings: input.settings ?? null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + deletedAt: null, + }; + + // 4. Resolve foreign keys to universal identifiers (if flatEntityMaps provided) + if (flatEntityMaps) { + return resolveEntityRelationUniversalIdentifiers({ + metadataName: 'myEntity', + flatEntity: baseFlatEntity, + flatEntityMaps, + }); + } + + // 5. Return with null universal foreign keys if no maps + return { + ...baseFlatEntity, + parentEntityUniversalIdentifier: null, + }; +}; +``` + +**Key steps**: +1. Generate IDs (`id` and `universalIdentifier` with `v4()`) +2. Extract foreign keys **before** sanitization +3. Sanitize all string properties +4. Build base flat entity +5. Resolve foreign keys → universal identifiers + +--- + +## Step 4: 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 { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { FlatMyEntityCacheService } from 'src/engine/metadata-modules/flat-my-entity/services/flat-my-entity-cache.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([MyEntityEntity], 'metadata')], + providers: [FlatMyEntityCacheService], + exports: [FlatMyEntityCacheService], +}) +export class FlatMyEntityModule {} +``` + +**Rules**: +- Import entity with `'metadata'` datasource +- Export cache service for use in other modules + +--- + +## Common Patterns + +### Pattern: Foreign Key Resolution + +```typescript +// Extract foreign keys BEFORE sanitization +const parentEntityId = input.parentEntityId ?? null; + +// After building base entity, resolve to universal identifiers +const universalFlatEntity = resolveEntityRelationUniversalIdentifiers({ + metadataName: 'myEntity', + flatEntity: baseFlatEntity, + flatEntityMaps, +}); +``` + +### Pattern: JSONB with SerializedRelation + +```typescript +// For JSONB properties containing foreign keys +const settings = input.settings + ? { + ...input.settings, + fieldMetadataId: input.settings.fieldMetadataId, + } + : null; + +// After resolution, JSONB foreign keys become universal identifiers +return resolveEntityRelationUniversalIdentifiers({ + metadataName: 'myEntity', + flatEntity: { ...baseFlatEntity, settings }, + flatEntityMaps, +}); +``` + +### Pattern: Update Transform + +```typescript +// from-update-my-entity-input-to-universal-flat-my-entity-updates.util.ts +export const fromUpdateMyEntityInputToUniversalFlatMyEntityUpdates = ({ + input, + flatEntityMaps, +}: { + input: UpdateMyEntityInput; + flatEntityMaps?: AllFlatEntityMapsByMetadataName; +}): Partial => { + const updates: Partial = {}; + + if (input.name !== undefined) { + updates.name = sanitizeString(input.name); + } + + if (input.parentEntityId !== undefined) { + updates.parentEntityId = input.parentEntityId; + } + + updates.updatedAt = new Date().toISOString(); + + // Resolve foreign keys if maps provided + if (flatEntityMaps) { + return resolveEntityRelationUniversalIdentifiers({ + metadataName: 'myEntity', + flatEntity: updates as any, + flatEntityMaps, + }); + } + + return updates; +}; +``` + +--- + +## Checklist + +Before moving to Step 3: + +- [ ] Cache service created with `@WorkspaceCache` decorator +- [ ] Cache uses `withDeleted: true` +- [ ] Cache key follows `flat{EntityName}Maps` pattern +- [ ] Entity-to-flat conversion implemented +- [ ] `universalIdentifier` set correctly (generated with `v4()`) +- [ ] Create input transform implemented +- [ ] Update input transform implemented (if needed) +- [ ] Foreign keys extracted before sanitization +- [ ] String properties sanitized +- [ ] Foreign keys resolved to universal identifiers +- [ ] Flat entity module created and exports cache service + +--- + +## Next Step + +Once cache and transform utilities are complete, proceed to: +**[Syncable Entity: Builder & Validation (Step 3/6)](../syncable-entity-builder-and-validation/SKILL.md)** + +For complete workflow, see `@creating-syncable-entity` rule. diff --git a/.cursor/skills/syncable-entity-integration/SKILL.md b/.cursor/skills/syncable-entity-integration/SKILL.md new file mode 100644 index 0000000000..563ef94298 --- /dev/null +++ b/.cursor/skills/syncable-entity-integration/SKILL.md @@ -0,0 +1,326 @@ +--- +name: syncable-entity-integration +description: Wire syncable entity services into NestJS modules, create service layer and resolvers for Twenty entities. Use when registering builders, validators, and action handlers in modules, creating business services, or exposing entities via GraphQL API with proper exception handling. +--- + +# Syncable Entity: Integration (Step 5/6) + +**Purpose**: Wire everything together, register in modules, create services and resolvers. + +**When to use**: After completing Steps 1-4 (all previous steps). Required before testing. + +--- + +## Quick Start + +This step: +1. Registers services in 3 NestJS modules +2. Creates service layer (returns flat entities) +3. Creates resolver layer (converts flat → DTO) +4. Uses exception interceptor for GraphQL + +**Key principle**: Services return flat entities, resolvers transpile flat → DTO. + +--- + +## Step 1: Register in 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({ + imports: [ + // ... existing imports + ], + providers: [ + // ... existing providers + WorkspaceMigrationMyEntityActionsBuilderService, + ], + exports: [ + // ... existing exports + WorkspaceMigrationMyEntityActionsBuilderService, + ], +}) +export class WorkspaceMigrationBuilderModule {} +``` + +**Important**: Add to both `providers` AND `exports` (builder needs to be exported for orchestrator). + +--- + +## Step 2: Register in 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({ + imports: [ + // ... existing imports + ], + providers: [ + // ... existing providers + FlatMyEntityValidatorService, + ], + exports: [ + // ... existing exports + FlatMyEntityValidatorService, + ], +}) +export class WorkspaceMigrationBuilderValidatorsModule {} +``` + +--- + +## Step 3: Register Action Handlers + +**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({ + imports: [ + // ... existing imports + ], + providers: [ + // ... existing providers + CreateMyEntityActionHandlerService, + UpdateMyEntityActionHandlerService, + DeleteMyEntityActionHandlerService, + ], + exports: [ + // ... existing exports (action handlers typically not exported) + ], +}) +export class WorkspaceSchemaMigrationRunnerActionHandlersModule {} +``` + +**Note**: Action handlers are typically only in `providers`, not `exports`. + +--- + +## Step 4: Create Service Layer + +**File**: `src/engine/metadata-modules/my-entity/my-entity.service.ts` + +```typescript +import { Injectable } from '@nestjs/common'; +import { isDefined } from 'twenty-shared/utils'; + +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; +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 { fromCreateMyEntityInputToUniversalFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-create-my-entity-input-to-universal-flat-my-entity.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): Promise { + // 1. Transform input to universal flat entity + const universalFlatMyEntityToCreate = fromCreateMyEntityInputToUniversalFlatMyEntity({ + input, + workspaceId, + }); + + // 2. Validate, build, and run + const result = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + myEntity: { + flatEntityToCreate: [universalFlatMyEntityToCreate], + flatEntityToDelete: [], + flatEntityToUpdate: [], + }, + }, + workspaceId, + isSystemBuild: false, + }, + ); + + // 3. Throw if validation failed + if (isDefined(result)) { + throw new WorkspaceMigrationBuilderException( + result, + 'Validation errors occurred while creating entity', + ); + } + + // 4. Return freshly cached flat entity + const { flatMyEntityMaps } = + await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: ['flatMyEntityMaps'], + }, + ); + + return findFlatEntityByIdInFlatEntityMapsOrThrow({ + flatEntityId: universalFlatMyEntityToCreate.id, + flatEntityMaps: flatMyEntityMaps, + }); + } +} +``` + +**Service pattern**: +1. Transform input → universal flat entity +2. Call `validateBuildAndRunWorkspaceMigration` +3. Throw if validation errors +4. **Return flat entity** (not DTO) + +--- + +## Step 5: Create Resolver Layer + +**File**: `src/engine/metadata-modules/my-entity/my-entity.resolver.ts` + +```typescript +import { UseInterceptors } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { WorkspaceMigrationGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-graphql-api-exception.interceptor'; +import { MyEntityService } from 'src/engine/metadata-modules/my-entity/my-entity.service'; +import { fromFlatMyEntityToMyEntityDto } from 'src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util'; + +@Resolver(() => MyEntityDto) +@UseInterceptors(WorkspaceMigrationGraphqlApiExceptionInterceptor) +export class MyEntityResolver { + constructor(private readonly myEntityService: MyEntityService) {} + + @Mutation(() => MyEntityDto) + async createMyEntity( + @Args('input') input: CreateMyEntityInput, + @Workspace() { id: workspaceId }: Workspace, + ): Promise { + // Service returns flat entity + const flatMyEntity = await this.myEntityService.create(input, workspaceId); + + // Resolver converts flat entity to DTO + return fromFlatMyEntityToMyEntityDto(flatMyEntity); + } + + @Mutation(() => MyEntityDto) + async updateMyEntity( + @Args('id') id: string, + @Args('input') input: UpdateMyEntityInput, + @Workspace() { id: workspaceId }: Workspace, + ): Promise { + const flatMyEntity = await this.myEntityService.update(id, input, workspaceId); + return fromFlatMyEntityToMyEntityDto(flatMyEntity); + } + + @Mutation(() => Boolean) + async deleteMyEntity( + @Args('id') id: string, + @Workspace() { id: workspaceId }: Workspace, + ) { + await this.myEntityService.delete(id, workspaceId); + return true; + } +} +``` + +**Resolver responsibilities**: +- Receives flat entities from service +- **Converts flat → DTO** using conversion utility +- Returns DTOs to GraphQL API +- Uses exception interceptor for error formatting + +--- + +## Step 6: Flat-to-DTO Conversion + +**File**: `src/engine/metadata-modules/my-entity/utils/from-flat-my-entity-to-my-entity-dto.util.ts` + +```typescript +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; +import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto'; + +export const fromFlatMyEntityToMyEntityDto = ( + flatMyEntity: FlatMyEntity, +): MyEntityDto => { + return { + id: flatMyEntity.id, + name: flatMyEntity.name, + label: flatMyEntity.label, + description: flatMyEntity.description, + isCustom: flatMyEntity.isCustom, + createdAt: flatMyEntity.createdAt, + updatedAt: flatMyEntity.updatedAt, + // Convert foreign key IDs to relation objects if needed + // parentEntity: flatMyEntity.parentEntityId ? { id: flatMyEntity.parentEntityId } : null, + }; +}; +``` + +--- + +## Layer Responsibilities + +| Layer | Input | Output | Responsibility | +|-------|-------|--------|----------------| +| **Service** | Input DTO | Flat Entity | Business logic, validation orchestration | +| **Resolver** | Service result | DTO | Flat → DTO conversion, GraphQL exposure | + +**Service Layer**: +- Works with flat entities internally +- Returns `FlatMyEntity` type +- No knowledge of DTOs or GraphQL types + +**Resolver Layer**: +- Receives flat entities from service +- Converts flat entities to DTOs +- Returns DTOs to GraphQL API + +--- + +## Exception Interceptor + +The `WorkspaceMigrationGraphqlApiExceptionInterceptor` automatically handles: + +1. `FlatEntityMapsException` → Converts to GraphQL errors (NotFoundError, etc.) +2. `WorkspaceMigrationBuilderException` → Formats validation errors with i18n +3. `WorkspaceMigrationRunnerException` → Formats runner errors + +**What it does**: +- Catches exceptions and formats for API responses +- Translates error messages based on user locale +- Ensures consistent error structure for frontend + +--- + +## Checklist + +Before moving to Step 6 (Testing): + +- [ ] Builder registered in builder module (providers + exports) +- [ ] Validator registered in validators module (providers + exports) +- [ ] All 3 action handlers registered in action handlers module (providers) +- [ ] Service layer created +- [ ] Service returns flat entities (not DTOs) +- [ ] Resolver layer created +- [ ] Resolver uses exception interceptor +- [ ] Resolver converts flat → DTO +- [ ] Flat-to-DTO conversion utility created + +--- + +## Next Step + +Once integration is complete, proceed to (**MANDATORY**): +**[Syncable Entity: Integration Testing (Step 6/6)](../syncable-entity-testing/SKILL.md)** + +For complete workflow, see `@creating-syncable-entity` rule. diff --git a/.cursor/skills/syncable-entity-runner-and-actions/SKILL.md b/.cursor/skills/syncable-entity-runner-and-actions/SKILL.md new file mode 100644 index 0000000000..ad5632457b --- /dev/null +++ b/.cursor/skills/syncable-entity-runner-and-actions/SKILL.md @@ -0,0 +1,355 @@ +--- +name: syncable-entity-runner-and-actions +description: Implement action handlers for executing workspace migrations in Twenty. Use when creating database operations for syncable entities, implementing universal-to-flat entity transpilation, or handling create/update/delete actions in the runner layer. +--- + +# Syncable Entity: Runner & Actions (Step 4/6) + +**Purpose**: Execute migration actions against the database with proper transpilation from universal to flat entities. + +**When to use**: After completing Steps 1-3 (Types, Cache, Builder). Required before integration. + +--- + +## Quick Start + +This step creates: +1. Create action handler +2. Update action handler +3. Delete action handler +4. Universal-to-flat conversion utilities + +**Key pattern**: Each handler has two phases: +1. **Transpilation**: Universal action → Flat action +2. **Execution**: Flat action → Database operation + +--- + +## Step 1: Create Action Handler + +**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 { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WorkspaceCreateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-create-action-handler.service'; +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util'; +import { + type UniversalCreateMyEntityAction, + type FlatCreateMyEntityAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type'; + +@Injectable() +export class CreateMyEntityActionHandlerService extends WorkspaceCreateActionHandlerService< + 'myEntity', + UniversalCreateMyEntityAction, + FlatCreateMyEntityAction +> { + constructor( + @InjectRepository(MyEntityEntity, 'metadata') + private readonly myEntityRepository: Repository, + ) { + super(); + } + + // Phase 1: Transpile universal action to flat action + protected transpileUniversalActionToFlatAction( + universalAction: UniversalCreateMyEntityAction, + flatEntityMaps: AllFlatEntityMapsByMetadataName, + ): FlatCreateMyEntityAction { + return { + type: 'create', + metadataName: 'myEntity', + flatEntity: fromUniversalFlatMyEntityToFlatMyEntity( + universalAction.universalFlatEntity, + flatEntityMaps, + ), + }; + } + + // Phase 2: Execute flat action against database + protected async executeForMetadata( + flatActions: FlatCreateMyEntityAction[], + ): Promise { + const flatEntities = flatActions.map((action) => action.flatEntity); + + await this.insertFlatEntitiesInRepository({ + repository: this.myEntityRepository, + flatEntities, + }); + } + + protected async executeForWorkspaceSchema(): Promise { + // No workspace schema changes needed for metadata-only entity + return; + } +} +``` + +**Key helper methods**: +- `transpileUniversalActionToFlatAction`: Converts universal → flat +- `insertFlatEntitiesInRepository`: Base class helper for inserts +- `executeForMetadata`: Metadata database operations +- `executeForWorkspaceSchema`: Workspace schema changes (if needed) + +--- + +## Step 2: Update Action Handler + +**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 { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WorkspaceUpdateActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-update-action-handler.service'; +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util'; +import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util'; + +@Injectable() +export class UpdateMyEntityActionHandlerService extends WorkspaceUpdateActionHandlerService< + 'myEntity', + UniversalUpdateMyEntityAction, + FlatUpdateMyEntityAction +> { + constructor( + @InjectRepository(MyEntityEntity, 'metadata') + private readonly myEntityRepository: Repository, + ) { + super(); + } + + protected transpileUniversalActionToFlatAction( + universalAction: UniversalUpdateMyEntityAction, + flatEntityMaps: AllFlatEntityMapsByMetadataName, + ): FlatUpdateMyEntityAction { + const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity( + universalAction.universalFlatEntity, + flatEntityMaps, + ); + + // Resolve universal foreign keys in updates to regular IDs + const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({ + metadataName: 'myEntity', + universalUpdates: universalAction.universalUpdates, + flatEntityMaps, + }); + + return { + type: 'update', + metadataName: 'myEntity', + flatEntity, + updates: flatUpdates, + }; + } + + protected async executeForMetadata( + flatActions: FlatUpdateMyEntityAction[], + ): Promise { + for (const action of flatActions) { + await this.myEntityRepository.update( + { id: action.flatEntity.id }, + action.updates, + ); + } + } + + protected async executeForWorkspaceSchema(): Promise { + return; + } +} +``` + +**Update-specific helper**: +- `resolveUniversalUpdateRelationIdentifiersToIds`: Maps universal identifiers back to regular IDs in the updates object + +--- + +## Step 3: Delete Action Handler + +**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 { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { WorkspaceDeleteActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-delete-action-handler.service'; +import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; +import { fromUniversalFlatMyEntityToFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util'; + +@Injectable() +export class DeleteMyEntityActionHandlerService extends WorkspaceDeleteActionHandlerService< + 'myEntity', + UniversalDeleteMyEntityAction, + FlatDeleteMyEntityAction +> { + constructor( + @InjectRepository(MyEntityEntity, 'metadata') + private readonly myEntityRepository: Repository, + ) { + super(); + } + + protected transpileUniversalActionToFlatAction( + universalAction: UniversalDeleteMyEntityAction, + flatEntityMaps: AllFlatEntityMapsByMetadataName, + ): FlatDeleteMyEntityAction { + // Use base class helper for delete transpilation + return this.transpileUniversalDeleteActionToFlatDeleteAction({ + universalAction, + flatEntityMaps, + fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity, + }); + } + + protected async executeForMetadata( + flatActions: FlatDeleteMyEntityAction[], + ): Promise { + const ids = flatActions.map((action) => action.flatEntity.id); + + await this.myEntityRepository.delete(ids); + } + + protected async executeForWorkspaceSchema(): Promise { + return; + } +} +``` + +**Delete-specific helper**: +- `transpileUniversalDeleteActionToFlatDeleteAction`: Base class helper that handles standard delete transpilation + +--- + +## Step 4: Universal-to-Flat Conversion + +**File**: `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/utils/from-universal-flat-my-entity-to-flat-my-entity.util.ts` + +```typescript +import { resolveUniversalRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util'; +import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type'; +import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; +import { type AllFlatEntityMapsByMetadataName } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps-by-metadata-name.type'; + +export const fromUniversalFlatMyEntityToFlatMyEntity = ( + universalFlatMyEntity: UniversalFlatMyEntity, + flatEntityMaps: AllFlatEntityMapsByMetadataName, +): FlatMyEntity => { + // Resolve universal foreign keys back to regular IDs + return resolveUniversalRelationIdentifiersToIds({ + metadataName: 'myEntity', + universalFlatEntity: universalFlatMyEntity, + flatEntityMaps, + }) as FlatMyEntity; +}; +``` + +**Key utility**: +- `resolveUniversalRelationIdentifiersToIds`: Maps universal identifiers → regular IDs (reverse of `resolveEntityRelationUniversalIdentifiers`) + +--- + +## Action Handler Patterns + +### Pattern: Create Handler +```typescript +// 1. Transpile: Universal → Flat +protected transpileUniversalActionToFlatAction( + universalAction, + flatEntityMaps, +) { + return { + type: 'create', + metadataName: 'myEntity', + flatEntity: fromUniversalFlatMyEntityToFlatMyEntity( + universalAction.universalFlatEntity, + flatEntityMaps, + ), + }; +} + +// 2. Execute: Flat → Database +protected async executeForMetadata(flatActions) { + await this.insertFlatEntitiesInRepository({ + repository: this.myEntityRepository, + flatEntities: flatActions.map(a => a.flatEntity), + }); +} +``` + +### Pattern: Update Handler +```typescript +// Transpile with update-specific resolution +protected transpileUniversalActionToFlatAction( + universalAction, + flatEntityMaps, +) { + const flatEntity = fromUniversalFlatMyEntityToFlatMyEntity( + universalAction.universalFlatEntity, + flatEntityMaps, + ); + + const flatUpdates = resolveUniversalUpdateRelationIdentifiersToIds({ + metadataName: 'myEntity', + universalUpdates: universalAction.universalUpdates, + flatEntityMaps, + }); + + return { type: 'update', metadataName: 'myEntity', flatEntity, updates: flatUpdates }; +} +``` + +### Pattern: Delete Handler +```typescript +// Use base class helper +protected transpileUniversalActionToFlatAction( + universalAction, + flatEntityMaps, +) { + return this.transpileUniversalDeleteActionToFlatDeleteAction({ + universalAction, + flatEntityMaps, + fromUniversalFlatEntityToFlatEntity: fromUniversalFlatMyEntityToFlatMyEntity, + }); +} + +// Delete +protected async executeForMetadata(flatActions) { + const ids = flatActions.map(a => a.flatEntity.id); + await this.myEntityRepository.delete(ids); +} +``` + +--- + +## Checklist + +Before moving to Step 5: + +- [ ] Create action handler implemented +- [ ] Update action handler implemented +- [ ] Delete action handler implemented +- [ ] All handlers extend appropriate base class +- [ ] `transpileUniversalActionToFlatAction` implemented in all handlers +- [ ] `executeForMetadata` implemented in all handlers +- [ ] `executeForWorkspaceSchema` implemented (or returns empty) +- [ ] Universal-to-flat conversion utility created +- [ ] Create handler uses `insertFlatEntitiesInRepository` +- [ ] Update handler uses `resolveUniversalUpdateRelationIdentifiersToIds` +- [ ] Delete handler uses `transpileUniversalDeleteActionToFlatDeleteAction` +- [ ] Delete handler uses hard delete (`delete()`) + +--- + +## Next Step + +Once action handlers are complete, proceed to: +**[Syncable Entity: Integration (Step 5/6)](../syncable-entity-integration/SKILL.md)** + +For complete workflow, see `@creating-syncable-entity` rule. diff --git a/.cursor/skills/syncable-entity-testing/SKILL.md b/.cursor/skills/syncable-entity-testing/SKILL.md new file mode 100644 index 0000000000..2099e9e04c --- /dev/null +++ b/.cursor/skills/syncable-entity-testing/SKILL.md @@ -0,0 +1,494 @@ +--- +name: syncable-entity-testing +description: Create comprehensive integration tests for syncable entities in Twenty. Use when writing integration tests for metadata entities, covering validator exceptions, input transpilation errors, and CRUD operations. Tests are MANDATORY for all syncable entities. +--- + +# Syncable Entity: Integration Testing (Step 6/6 - MANDATORY) + +**Purpose**: Create comprehensive test suite covering all validation scenarios, input transpilation exceptions, and successful use cases. + +**When to use**: After completing Steps 1-5. Integration tests are **REQUIRED** for all syncable entities. + +--- + +## Quick Start + +Tests must cover: +1. **Failing scenarios** - All validator exceptions and input transpilation errors +2. **Successful scenarios** - All CRUD operations and edge cases +3. **Test utilities** - Reusable query factories and helper functions + +**Test pattern**: Two-file pattern (query factory + wrapper) for each operation. + +--- + +## Step 1: Create Test Utilities + +### Pattern: Query Factory + +**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util.ts` + +```typescript +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input'; + +export type CreateMyEntityFactoryInput = CreateMyEntityInput; + +const DEFAULT_MY_ENTITY_GQL_FIELDS = ` + id + name + label + description + isCustom + createdAt + updatedAt +`; + +export const createMyEntityQueryFactory = ({ + input, + gqlFields = DEFAULT_MY_ENTITY_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation CreateMyEntity($input: CreateMyEntityInput!) { + createMyEntity(input: $input) { + ${gqlFields} + } + } + `, + variables: { + input, + }, +}); +``` + +### Pattern: Wrapper Utility + +**File**: `test/integration/metadata/suites/my-entity/utils/create-my-entity.util.ts` + +```typescript +import { + type CreateMyEntityFactoryInput, + createMyEntityQueryFactory, +} from 'test/integration/metadata/suites/my-entity/utils/create-my-entity-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; +import { type MyEntityDto } from 'src/engine/metadata-modules/my-entity/dtos/my-entity.dto'; + +export const createMyEntity = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + createMyEntity: MyEntityDto; +}> => { + const graphqlOperation = createMyEntityQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'My entity creation should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'My entity creation has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; +``` + +**Required utilities** (follow same pattern): +- `update-my-entity-query-factory.util.ts` + `update-my-entity.util.ts` +- `delete-my-entity-query-factory.util.ts` + `delete-my-entity.util.ts` + +--- + +## Step 2: Failing Creation Tests + +**File**: `test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts` + +```typescript +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util'; +import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; +import { isDefined } from 'twenty-shared/utils'; +import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input'; + +type TestContext = { + input: CreateMyEntityInput; +}; + +type GlobalTestContext = { + existingEntityLabel: string; + existingEntityName: string; +}; + +const globalTestContext: GlobalTestContext = { + existingEntityLabel: 'Existing Test Entity', + existingEntityName: 'existingTestEntity', +}; + +type CreateMyEntityTestingContext = EachTestingContext[]; + +describe('My entity creation should fail', () => { + let existingEntityId: string | undefined; + + beforeAll(async () => { + // Setup: Create entity for uniqueness tests + const { data } = await createMyEntity({ + expectToFail: false, + input: { + name: globalTestContext.existingEntityName, + label: globalTestContext.existingEntityLabel, + }, + }); + + existingEntityId = data.createMyEntity.id; + }); + + afterAll(async () => { + // Cleanup + if (isDefined(existingEntityId)) { + await deleteMyEntity({ + expectToFail: false, + input: { id: existingEntityId }, + }); + } + }); + + const failingMyEntityCreationTestCases: CreateMyEntityTestingContext = [ + // Input transpilation validation + { + title: 'when name is missing', + context: { + input: { + label: 'Entity Missing Name', + } as CreateMyEntityInput, + }, + }, + { + title: 'when label is missing', + context: { + input: { + name: 'entityMissingLabel', + } as CreateMyEntityInput, + }, + }, + { + title: 'when name is empty string', + context: { + input: { + name: '', + label: 'Empty Name Entity', + }, + }, + }, + + // Validator business logic + { + title: 'when name already exists (uniqueness)', + context: { + input: { + name: globalTestContext.existingEntityName, + label: 'Duplicate Name Entity', + }, + }, + }, + { + title: 'when trying to create standard entity', + context: { + input: { + name: 'myEntity', + label: 'Standard Entity', + isCustom: false, + } as CreateMyEntityInput, + }, + }, + + // Foreign key validation + { + title: 'when parentEntityId does not exist', + context: { + input: { + name: 'invalidParentEntity', + label: 'Invalid Parent Entity', + parentEntityId: '00000000-0000-0000-0000-000000000000', + }, + }, + }, + ]; + + it.each(eachTestingContextFilter(failingMyEntityCreationTestCases))( + '$title', + async ({ context }) => { + const { errors } = await createMyEntity({ + expectToFail: true, + input: context.input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); +``` + +**Test coverage requirements**: +- ✅ Missing required fields +- ✅ Empty strings +- ✅ Invalid format +- ✅ Uniqueness violations +- ✅ Standard entity protection +- ✅ Foreign key validation + +--- + +## Step 3: Successful Creation Tests + +**File**: `test/integration/metadata/suites/my-entity/successful-my-entity-creation.integration-spec.ts` + +```typescript +import { createMyEntity } from 'test/integration/metadata/suites/my-entity/utils/create-my-entity.util'; +import { deleteMyEntity } from 'test/integration/metadata/suites/my-entity/utils/delete-my-entity.util'; +import { type CreateMyEntityInput } from 'src/engine/metadata-modules/my-entity/dtos/create-my-entity.input'; + +describe('My entity creation should succeed', () => { + let createdEntityId: string; + + afterEach(async () => { + if (createdEntityId) { + await deleteMyEntity({ + expectToFail: false, + input: { id: createdEntityId }, + }); + } + }); + + it('should create entity with minimal required input', async () => { + const { data } = await createMyEntity({ + expectToFail: false, + input: { + name: 'minimalEntity', + label: 'Minimal Entity', + }, + }); + + createdEntityId = data?.createMyEntity?.id; + + expect(data.createMyEntity).toMatchObject({ + id: expect.any(String), + name: 'minimalEntity', + label: 'Minimal Entity', + description: null, + isCustom: true, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('should create entity with all optional fields', async () => { + const input = { + name: 'fullEntity', + label: 'Full Entity', + description: 'Entity with all fields specified', + } as const satisfies CreateMyEntityInput; + + const { data } = await createMyEntity({ + expectToFail: false, + input, + }); + + createdEntityId = data?.createMyEntity?.id; + + expect(data.createMyEntity).toMatchObject({ + id: expect.any(String), + name: 'fullEntity', + label: 'Full Entity', + description: 'Entity with all fields specified', + isCustom: true, + }); + }); + + it('should sanitize input by trimming whitespace', async () => { + const { data } = await createMyEntity({ + expectToFail: false, + input: { + name: ' entityWithSpaces ', + label: ' Entity With Spaces ', + description: ' Description with spaces ', + }, + }); + + createdEntityId = data?.createMyEntity?.id; + + expect(data.createMyEntity).toMatchObject({ + id: expect.any(String), + name: 'entityWithSpaces', + label: 'Entity With Spaces', + description: 'Description with spaces', + }); + }); + + it('should handle long text content', async () => { + const longDescription = 'A'.repeat(1000); + + const { data } = await createMyEntity({ + expectToFail: false, + input: { + name: 'longDescEntity', + label: 'Long Description Entity', + description: longDescription, + }, + }); + + createdEntityId = data?.createMyEntity?.id; + + expect(data.createMyEntity).toMatchObject({ + id: expect.any(String), + description: longDescription, + }); + }); +}); +``` + +**Test coverage requirements**: +- ✅ Minimal required input +- ✅ All optional fields +- ✅ Input sanitization +- ✅ Long text content +- ✅ Special characters + +--- + +## Step 4: Update and Delete Tests + +Create similar test files for update and delete operations: + +**Required files**: +- `failing-my-entity-update.integration-spec.ts` +- `successful-my-entity-update.integration-spec.ts` +- `failing-my-entity-deletion.integration-spec.ts` +- `successful-my-entity-deletion.integration-spec.ts` + +--- + +## Testing Best Practices + +### Pattern: Cleanup +```typescript +afterEach(async () => { + if (createdEntityId) { + await deleteMyEntity({ + expectToFail: false, + input: { id: createdEntityId }, + }); + } +}); +``` + +### Pattern: Type-Safe Inputs +```typescript +const input = { + name: 'myEntity', + label: 'My Entity', +} as const satisfies CreateMyEntityInput; +``` + +### Pattern: Snapshot Testing +```typescript +expectOneNotInternalServerErrorSnapshot({ + errors, +}); +``` + +--- + +## Running Tests + +```bash +# Run all entity tests +npx jest test/integration/metadata/suites/my-entity --config=packages/twenty-server/jest.config.mjs + +# Run specific test file +npx jest test/integration/metadata/suites/my-entity/failing-my-entity-creation.integration-spec.ts --config=packages/twenty-server/jest.config.mjs + +# Update snapshots +npx jest test/integration/metadata/suites/my-entity --updateSnapshot --config=packages/twenty-server/jest.config.mjs +``` + +--- + +## Complete Test Checklist + +### Test Utilities +- [ ] `create-my-entity-query-factory.util.ts` created +- [ ] `create-my-entity.util.ts` created +- [ ] `update-my-entity-query-factory.util.ts` created +- [ ] `update-my-entity.util.ts` created +- [ ] `delete-my-entity-query-factory.util.ts` created +- [ ] `delete-my-entity.util.ts` created + +### Failing Tests Coverage +- [ ] Missing required fields +- [ ] Empty string validation +- [ ] Uniqueness violations +- [ ] Standard entity protection +- [ ] Foreign key validation +- [ ] JSONB property validation (if applicable) + +### Successful Tests Coverage +- [ ] Create with minimal input +- [ ] Create with all optional fields +- [ ] Input sanitization (whitespace) +- [ ] Long text content +- [ ] Update single field +- [ ] Update multiple fields +- [ ] Successful deletion + +### Snapshot Tests +- [ ] All failing tests use `expectOneNotInternalServerErrorSnapshot` +- [ ] Snapshots committed to `__snapshots__/` directory + +--- + +## Success Criteria + +Your integration tests are complete when: + +✅ All test utilities created (minimum 6 files) +✅ Failing creation tests cover all validators +✅ Failing update tests cover business rules +✅ Failing deletion tests cover protection rules +✅ Successful tests cover all use cases +✅ All snapshots generated and committed +✅ All tests pass consistently +✅ Test coverage meets requirements (>80%) + +--- + +## Final Step + +✅ **Step 6 Complete!** → Your syncable entity is fully tested and production-ready! + +**Congratulations!** You've successfully created a new syncable entity in Twenty's workspace migration system. + +For complete workflow, see `@creating-syncable-entity` rule. diff --git a/.cursor/skills/syncable-entity-types-and-constants/SKILL.md b/.cursor/skills/syncable-entity-types-and-constants/SKILL.md new file mode 100644 index 0000000000..d91de3d87f --- /dev/null +++ b/.cursor/skills/syncable-entity-types-and-constants/SKILL.md @@ -0,0 +1,309 @@ +--- +name: syncable-entity-types-and-constants +description: Define types, entities, and central constant registrations for syncable entities in Twenty's workspace migration system. Use when creating new syncable entities, defining TypeORM entities, flat entity types, or registering in central constants (ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME, ALL_METADATA_RELATIONS, ALL_UNIVERSAL_METADATA_RELATIONS). +--- + +# Syncable Entity: Types & Constants (Step 1/6) + +**Purpose**: Define all types, entities, and register in central constants. This is the foundation - everything else depends on these types being correct. + +**When to use**: First step when creating any new syncable entity. Must be completed before other steps. + +--- + +## Quick Start + +This step creates: +1. Metadata name constant (twenty-shared) +2. TypeORM entity (extends `SyncableEntity`) +3. Flat entity types +4. Action types (universal + flat) +5. Central constant registrations (4 constants) + +--- + +## Step 1: Add Metadata Name + +**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` + +```typescript +import { Entity, Column, ManyToOne, JoinColumn } from 'typeorm'; +import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; + +@Entity({ name: 'myEntity' }) +export class MyEntityEntity extends SyncableEntity { + @Column({ type: 'varchar' }) + name: string; + + @Column({ type: 'varchar' }) + label: string; + + @Column({ type: 'boolean', default: true }) + isCustom: boolean; + + // Foreign key example (optional) + @Column({ type: 'uuid', nullable: true }) + parentEntityId: string | null; + + @ManyToOne(() => ParentEntityEntity, { nullable: true }) + @JoinColumn({ name: 'parentEntityId' }) + parentEntity: ParentEntityEntity | null; + + // JSONB column example (optional) + @Column({ type: 'jsonb', nullable: true }) + settings: Record | null; +} +``` + +**Key rules**: +- Must extend `SyncableEntity` (provides `id`, `universalIdentifier`, `applicationId`, etc.) +- Must have `isCustom` boolean column +- Use `@Column({ type: 'jsonb' })` for JSON data + +--- + +## Step 3: Define Flat Entity Types + +**File**: `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type.ts` + +```typescript +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type'; +import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity'; + +export type FlatMyEntity = FlatEntityFrom; +``` + +**Maps file** (if entity has indexed lookups): + +```typescript +// flat-my-entity-maps.type.ts +export type FlatMyEntityMaps = { + byId: Record; + byName: Record; + // Add other indexes as needed +}; +``` + +--- + +## Step 4: Define Editable Properties + +**File**: `src/engine/metadata-modules/flat-my-entity/constants/editable-flat-my-entity-properties.constant.ts` + +```typescript +export const EDITABLE_FLAT_MY_ENTITY_PROPERTIES = [ + 'name', + 'label', + 'description', + 'parentEntityId', + 'settings', +] as const satisfies ReadonlyArray; +``` + +**Rule**: Only include properties that can be updated (exclude `id`, `createdAt`, `universalIdentifier`, etc.) + +--- + +## Step 5: 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 FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type'; +import { type UniversalFlatMyEntity } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-my-entity.type'; + +// Universal actions (used by builder/runner) +export type UniversalCreateMyEntityAction = { + type: 'create'; + metadataName: 'myEntity'; + universalFlatEntity: UniversalFlatMyEntity; +}; + +export type UniversalUpdateMyEntityAction = { + type: 'update'; + metadataName: 'myEntity'; + universalFlatEntity: UniversalFlatMyEntity; + universalUpdates: Partial; +}; + +export type UniversalDeleteMyEntityAction = { + type: 'delete'; + metadataName: 'myEntity'; + universalFlatEntity: UniversalFlatMyEntity; +}; + +// Flat actions (internal to runner) +export type FlatCreateMyEntityAction = { + type: 'create'; + metadataName: 'myEntity'; + flatEntity: FlatMyEntity; +}; + +export type FlatUpdateMyEntityAction = { + type: 'update'; + metadataName: 'myEntity'; + flatEntity: FlatMyEntity; + updates: Partial; +}; + +export type FlatDeleteMyEntityAction = { + type: 'delete'; + metadataName: 'myEntity'; + flatEntity: FlatMyEntity; +}; +``` + +--- + +## Step 6: Register in Central Constants + +### 6a. AllFlatEntityTypesByMetadataName + +**File**: `src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts` + +```typescript +export type AllFlatEntityTypesByMetadataName = { + // ... existing entries + myEntity: { + flatEntityMaps: FlatMyEntityMaps; + universalActions: { + create: UniversalCreateMyEntityAction; + update: UniversalUpdateMyEntityAction; + delete: UniversalDeleteMyEntityAction; + }; + flatActions: { + create: FlatCreateMyEntityAction; + update: FlatUpdateMyEntityAction; + delete: FlatDeleteMyEntityAction; + }; + flatEntity: FlatMyEntity; + universalFlatEntity: UniversalFlatMyEntity; + entity: MyEntityEntity; + }; +}; +``` + +### 6b. ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME + +**File**: `src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts` + +```typescript +export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { + // ... existing entries + myEntity: { + name: { toCompare: true }, + label: { toCompare: true }, + description: { toCompare: true }, + parentEntityId: { + toCompare: true, + universalProperty: 'parentEntityUniversalIdentifier', + }, + settings: { + toCompare: true, + toStringify: true, + universalProperty: 'universalSettings', + }, + }, +} as const; +``` + +**Rules**: +- `toCompare: true` → Editable property (checked for changes) +- `toStringify: true` → JSONB/object property (needs JSON serialization) +- `universalProperty` → Maps to universal version (for foreign keys & JSONB with `SerializedRelation`) + +### 6c. ALL_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, + parentEntity: { + metadataName: 'parentEntity', + flatEntityForeignKeyAggregator: 'myEntityIds', + foreignKey: 'parentEntityId', + isNullable: false, + }, + }, + oneToMany: { + childEntities: { metadataName: 'childEntity' }, + }, + // Only if JSONB contains SerializedRelation fields + serializedRelations: { + fieldMetadata: true, + }, + }, +} as const; +``` + +### 6d. ALL_UNIVERSAL_METADATA_RELATIONS + +**File**: `src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-universal-metadata-relations.constant.ts` + +```typescript +export const ALL_UNIVERSAL_METADATA_RELATIONS = { + // ... existing entries + myEntity: { + manyToOne: { + workspace: null, + application: null, + parentEntity: { + metadataName: 'parentEntity', + foreignKey: 'parentEntityId', + universalForeignKey: 'parentEntityUniversalIdentifier', + universalFlatEntityForeignKeyAggregator: 'myEntityUniversalIdentifiers', + isNullable: false, + }, + }, + oneToMany: { + childEntities: { metadataName: 'childEntity' }, + }, + }, +} as const; +``` + +--- + +## Checklist + +Before moving to Step 2: + +- [ ] Metadata name added to `ALL_METADATA_NAME` +- [ ] TypeORM entity created (extends `SyncableEntity`) +- [ ] `isCustom` column added +- [ ] Flat entity type defined +- [ ] Flat entity maps type defined (if needed) +- [ ] Editable properties constant defined +- [ ] Universal and flat action types defined +- [ ] Registered in `AllFlatEntityTypesByMetadataName` +- [ ] Registered in `ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME` +- [ ] Registered in `ALL_METADATA_RELATIONS` +- [ ] Registered in `ALL_UNIVERSAL_METADATA_RELATIONS` +- [ ] TypeScript compiles without errors + +--- + +## Next Step + +Once all types and constants are defined, proceed to: +**[Syncable Entity: Cache & Transform (Step 2/6)](../syncable-entity-cache-and-transform/SKILL.md)** + +For complete workflow, see `@creating-syncable-entity` rule.