mirror of
https://github.com/twentyhq/twenty
synced 2026-04-21 13:37:22 +00:00
# Introduction
Important note: This PR officially deprecates the `standardId`, about to
drop col and entity property after this has been merged
Important note2: Haven't updated the optimistic tool to also update the
universal identifier aggregators only the ids one, they should not be
consumed in the runner context -> need to improve typing or either the
optimistic tooling
In this PR we're introducing all the devxp allowing future metadata
incremental universal migration -> this has an impact on all existing
metadata actions handler ( explaining its size )
This PR also introduce workspace agnostic create update actions runner
for both field and object metadata in order to battle test the described
above devxp
Noting that these two metadata are the most complex to handle
Notes:
- A workspace migration is now highly bind to a
`applicationUniversalIdentifier`. Though we don't strictly validate
application scope for the moment
## Next
Migrate both object and field builder to universal comparison
## Universal Actions vs Flat Actions Architecture
### Concept
The migration system uses a two-phase action model:
1. **Universal Actions** - Actions defined using `universalIdentifier`
(stable, portable identifiers like `standardId` + `applicationId`)
2. **Flat Actions** - Actions defined using database `entityId` (UUIDs
specific to a workspace)
### Why This Separation?
- **Universal actions are portable**: They can be serialized, stored,
and replayed across different workspaces
- **Flat actions are executable**: They contain the actual database IDs
needed to perform operations
- **Decoupling**: The builder produces universal actions; the runner
transpiles them to flat actions at execution time
### Transpiler Pattern
Each action handler must implement
`transpileUniversalActionToFlatAction()`:
```typescript
@Injectable()
export class CreateFieldActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
'create',
'fieldMetadata',
) {
override async transpileUniversalActionToFlatAction(
context: WorkspaceMigrationActionRunnerArgs<UniversalCreateFieldAction>,
): Promise<FlatCreateFieldAction> {
// Resolve universal identifiers to database IDs
const flatObjectMetadata = findFlatEntityByUniversalIdentifierOrThrow({
flatEntityMaps: allFlatEntityMaps.flatObjectMetadataMaps,
universalIdentifier: action.objectMetadataUniversalIdentifier,
});
return {
type: action.type,
metadataName: action.metadataName,
objectMetadataId: flatObjectMetadata.id, // Resolved ID
flatFieldMetadatas: /* ... transpiled entities ... */,
};
}
}
```
### Action Handler Base Class
`BaseWorkspaceMigrationRunnerActionHandlerService<TActionType,
TMetadataName>` provides:
- **`transpileUniversalActionToFlatAction()`** - Abstract method each
handler must implement
- **`transpileUniversalDeleteActionToFlatDeleteAction()`** - Shared
helper for delete actions
## FlatEntityMaps custom properties
Introduced a `TWithCustomMapsProperties` generic parameter to control
whether custom indexing structures are included:
- **`false` (default)**: Returns `FlatEntityMaps<MetadataFlatEntity<T>>`
- used in builder/runner contexts
- **`true`**: Returns the full maps type with custom properties (e.g.,
`byUserWorkspaceIdAndFolderId`) - used in cache contexts
## Create Field Actions Refactor
Refactored create-field actions to support relation field pairs
bundling.
**Problem:** Relation fields (e.g., `Attachment.targetTask` ↔
`Task.attachments`) couldn't resolve each other's IDs during
transpilation because they were in separate actions with independent
`fieldIdByUniversalIdentifier` maps.
**Solution:**
- Removed `objectMetadataUniversalIdentifier` from
`UniversalCreateFieldAction` and `objectMetadataId` from
`FlatCreateFieldAction` - each field now carries its own
- Runner groups fields by object internally and processes each table
separately
- Split aggregator into two focused utilities:
- `aggregateNonRelationFieldsIntoObjectActions` - merges non-relation
fields into object actions
- `aggregateRelationFieldPairs` - bundles relation pairs with shared
`fieldIdByUniversalIdentifier`
1350 lines
48 KiB
Text
1350 lines
48 KiB
Text
---
|
|
description: Guide for creating a new syncable entity in Twenty's workspace migration system
|
|
globs: ["**/metadata-modules/**", "**/workspace-migration/**"]
|
|
alwaysApply: false
|
|
---
|
|
|
|
# Creating a New Syncable Entity
|
|
|
|
This guide explains how to create a new **syncable entity** in Twenty's workspace migration architecture.
|
|
|
|
## What is a Syncable Entity?
|
|
|
|
A syncable entity is a metadata entity that:
|
|
- Has a **`universalIdentifier`**: A unique identifier used for syncing entities across workspaces/applications (typically set to `standardId` for standard entities or `id` for custom entities)
|
|
- Has an **`applicationId`**: Links the entity to an application (Twenty Standard or Custom applications)
|
|
- Participates in the **workspace migration system**: Can be created, updated, and deleted through the migration pipeline
|
|
- Is **cached as a flat entity**: Denormalized representation for efficient validation and change detection
|
|
|
|
Examples of existing syncable entities: `skill`, `agent`, `view`, `viewField`, `role`, `pageLayout`, etc.
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
1. [Overview](#overview)
|
|
2. [File Structure](#file-structure)
|
|
3. [Step-by-Step Implementation](#step-by-step-implementation)
|
|
- [Step 1: Add Metadata Name Constant](#step-1-add-metadata-name-constant-twenty-shared)
|
|
- [Step 2: Create TypeORM Entity](#step-2-create-typeorm-entity)
|
|
- [Step 2b: Using JsonbProperty and SerializedRelation Types](#step-2b-using-jsonbproperty-and-serializedrelation-types)
|
|
- [Step 3: Define Flat Entity Type](#step-3-define-flat-entity-type)
|
|
- [Step 4: Define Editable Properties](#step-4-define-editable-properties)
|
|
- [Step 5: Register in Central Constants](#step-5-register-in-central-constants)
|
|
- [Step 6: Create Cache Service](#step-6-create-cache-service)
|
|
- [Step 7: Create Flat Entity Module](#step-7-create-flat-entity-module)
|
|
- [Step 8: Define Action Types](#step-8-define-action-types)
|
|
- [Step 9: Create Validator Service](#step-9-create-validator-service)
|
|
- [Step 10: Create Builder Service](#step-10-create-builder-service)
|
|
- [Step 11: Create Action Handlers](#step-11-create-action-handlers-runner)
|
|
- [Step 12: Wire in Orchestrator Service](#step-12-wire-in-orchestrator-service-critical)
|
|
- [Step 13: Register in Modules](#step-13-register-in-modules)
|
|
4. [Using the Entity in Services](#using-the-entity-in-services)
|
|
5. [Integration Tests](#integration-tests)
|
|
|
|
---
|
|
|
|
## Overview
|
|
|
|
The syncable entity system consists of several interconnected components:
|
|
|
|
```
|
|
Input DTO → Transform Utils → Flat Entity → Builder/Validator → Runner → Database
|
|
↓
|
|
Cache Service
|
|
```
|
|
|
|
- **TypeORM Entity**: Database model extending `SyncableEntity`
|
|
- **Flat Entity**: Denormalized type derived from the entity (no relations, dates as strings)
|
|
- **Transform Utils**: Convert input DTOs to flat entities (sanitization, defaults, ID generation) - **all transformations happen here**
|
|
- **Cache Service**: Computes and caches flat entity maps per workspace
|
|
- **Builder Service**: Validates and builds migration actions - **never throws, never mutates**
|
|
- **Validator Service**: Contains business logic validation rules - **never throws, never mutates, returns aggregated errors**
|
|
- **Runner/Action Handlers**: Executes actions against the database
|
|
- **Orchestrator**: Coordinates all builders and manages the migration flow
|
|
|
|
### Key Design Principle: Separation of Concerns
|
|
|
|
| Layer | Responsibility | Can Throw? | Can Mutate? |
|
|
|-------|---------------|------------|-------------|
|
|
| Transform Utils | Data transformation, sanitization, defaults | Yes (input validation) | N/A (creates new objects) |
|
|
| Validator | Business rule validation | **No** (returns errors) | **No** |
|
|
| Builder | Action creation | **No** (returns errors) | **No** |
|
|
| Runner | Database operations | Yes (DB errors) | Yes (via TypeORM) |
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
For a new entity called `myEntity`, you'll create/modify files in these locations:
|
|
|
|
```
|
|
packages/twenty-shared/src/metadata/
|
|
├── all-metadata-name.constant.ts # Add metadata name
|
|
|
|
packages/twenty-server/src/engine/metadata-modules/
|
|
├── my-entity/
|
|
│ ├── entities/
|
|
│ │ └── my-entity.entity.ts # TypeORM entity
|
|
│ ├── dtos/
|
|
│ │ ├── create-my-entity.input.ts
|
|
│ │ └── update-my-entity.input.ts
|
|
│ ├── my-entity.service.ts # Business service
|
|
│ └── my-entity.module.ts
|
|
├── flat-my-entity/
|
|
│ ├── types/
|
|
│ │ ├── flat-my-entity.type.ts # Flat entity type
|
|
│ │ └── flat-my-entity-maps.type.ts # Maps type
|
|
│ ├── constants/
|
|
│ │ └── flat-my-entity-editable-properties.constant.ts
|
|
│ ├── services/
|
|
│ │ └── workspace-flat-my-entity-map-cache.service.ts
|
|
│ ├── utils/
|
|
│ │ └── from-my-entity-entity-to-flat-my-entity.util.ts
|
|
│ └── flat-my-entity.module.ts
|
|
├── flat-entity/
|
|
│ ├── types/
|
|
│ │ └── all-flat-entity-types-by-metadata-name.ts # Register entity
|
|
│ └── constant/
|
|
│ ├── all-flat-entity-properties-to-compare-and-stringify.constant.ts
|
|
│ ├── all-metadata-relations.constant.ts
|
|
│ └── all-metadata-required-metadata-for-validation.constant.ts
|
|
|
|
packages/twenty-server/src/engine/workspace-manager/workspace-migration/
|
|
├── workspace-migration-builder/
|
|
│ ├── builders/
|
|
│ │ └── my-entity/
|
|
│ │ ├── types/
|
|
│ │ │ └── workspace-migration-my-entity-action.type.ts
|
|
│ │ └── workspace-migration-my-entity-actions-builder.service.ts
|
|
│ ├── validators/
|
|
│ │ ├── services/
|
|
│ │ │ └── flat-my-entity-validator.service.ts
|
|
│ │ └── utils/
|
|
│ │ └── validate-my-entity-*.util.ts
|
|
│ └── workspace-migration-builder.module.ts # Register builder
|
|
├── workspace-migration-runner/
|
|
│ └── action-handlers/
|
|
│ ├── my-entity/
|
|
│ │ └── services/
|
|
│ │ ├── create-my-entity-action-handler.service.ts
|
|
│ │ ├── update-my-entity-action-handler.service.ts
|
|
│ │ └── delete-my-entity-action-handler.service.ts
|
|
│ └── workspace-schema-migration-runner-action-handlers.module.ts
|
|
└── services/
|
|
└── workspace-migration-build-orchestrator.service.ts # Wire builder
|
|
```
|
|
|
|
---
|
|
|
|
## Step-by-Step Implementation
|
|
|
|
### Step 1: Add Metadata Name Constant (twenty-shared)
|
|
|
|
**File:** `packages/twenty-shared/src/metadata/all-metadata-name.constant.ts`
|
|
|
|
```typescript
|
|
export const ALL_METADATA_NAME = {
|
|
// ... existing entries
|
|
myEntity: 'myEntity',
|
|
} as const;
|
|
```
|
|
|
|
---
|
|
|
|
### Step 2: Create TypeORM Entity
|
|
|
|
**File:** `src/engine/metadata-modules/my-entity/entities/my-entity.entity.ts`
|
|
|
|
Your entity **must extend `SyncableEntity`** to participate in the workspace migration system.
|
|
|
|
```typescript
|
|
import {
|
|
Column,
|
|
CreateDateColumn,
|
|
Entity,
|
|
PrimaryGeneratedColumn,
|
|
UpdateDateColumn,
|
|
} from 'typeorm';
|
|
|
|
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
|
|
|
@Entity('myEntity')
|
|
export class MyEntityEntity
|
|
extends SyncableEntity
|
|
implements Required<MyEntityEntity>
|
|
{
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ nullable: true, type: 'uuid' })
|
|
standardId: string | null;
|
|
|
|
@Column({ nullable: false })
|
|
name: string;
|
|
|
|
@Column({ nullable: false })
|
|
label: string;
|
|
|
|
@Column({ nullable: true, type: 'varchar' })
|
|
icon: string | null;
|
|
|
|
@Column({ nullable: true, type: 'text' })
|
|
description: string | null;
|
|
|
|
@Column({ default: false })
|
|
isCustom: boolean;
|
|
|
|
@CreateDateColumn({ type: 'timestamptz' })
|
|
createdAt: Date;
|
|
|
|
@UpdateDateColumn({ type: 'timestamptz' })
|
|
updatedAt: Date;
|
|
}
|
|
```
|
|
|
|
**What the base class provides:**
|
|
|
|
```typescript
|
|
// SyncableEntity - Base class for all syncable entities
|
|
// File: src/engine/workspace-manager/types/syncable-entity.interface.ts
|
|
@Index(['workspaceId', 'universalIdentifier'], { unique: true })
|
|
export abstract class SyncableEntity extends WorkspaceRelatedEntity {
|
|
@Column({ nullable: false, type: 'uuid' })
|
|
universalIdentifier: string;
|
|
|
|
@Column({ nullable: false, type: 'uuid' })
|
|
applicationId: string;
|
|
|
|
@ManyToOne('ApplicationEntity', { onDelete: 'CASCADE', nullable: false })
|
|
@JoinColumn({ name: 'applicationId' })
|
|
application: Relation<ApplicationEntity>;
|
|
}
|
|
|
|
// WorkspaceRelatedEntity (common base)
|
|
// File: src/engine/workspace-manager/types/workspace-related-entity.ts
|
|
export abstract class WorkspaceRelatedEntity {
|
|
@Column({ nullable: false, type: 'uuid' })
|
|
workspaceId: string;
|
|
}
|
|
```
|
|
|
|
**Properties provided by `SyncableEntity`:**
|
|
| Property | Type | Description |
|
|
|----------|------|-------------|
|
|
| `universalIdentifier` | `string` (required) | Unique identifier for syncing across workspaces/applications |
|
|
| `applicationId` | `string` (required) | Links the entity to an application (Twenty Standard or Custom) |
|
|
| `workspaceId` | `string` (required) | Links the entity to a workspace (from `WorkspaceRelatedEntity`) |
|
|
|
|
---
|
|
|
|
### Step 2b: Using JsonbProperty and SerializedRelation Types
|
|
|
|
When your entity has JSONB columns or stores foreign key references inside JSONB structures, you must use the branded type wrappers to enable automatic universal identifier mapping.
|
|
|
|
#### JsonbProperty Wrapper
|
|
|
|
Wrap all JSONB column types with `JsonbProperty<T>` to mark them for the universal entity transformation system:
|
|
|
|
```typescript
|
|
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
|
|
|
|
@Entity('myEntity')
|
|
export class MyEntityEntity extends SyncableEntity {
|
|
// Simple JSONB column - wrap the type
|
|
@Column({ type: 'jsonb', nullable: true })
|
|
settings: JsonbProperty<MyEntitySettings> | null;
|
|
|
|
// JSONB column with complex type
|
|
@Column({ type: 'jsonb', nullable: false })
|
|
configuration: JsonbProperty<MyEntityConfiguration>;
|
|
|
|
// Array stored as JSONB
|
|
@Column({ type: 'jsonb', nullable: true })
|
|
tags: JsonbProperty<string[]> | null;
|
|
}
|
|
```
|
|
|
|
**When to use `JsonbProperty<T>`:**
|
|
- Any column with `type: 'jsonb'` that stores an object or array
|
|
- Configuration objects, settings, metadata blobs
|
|
- Any structured data stored as JSON in the database
|
|
|
|
**What it enables:**
|
|
- The type system can identify which properties are JSONB columns
|
|
- Automatic transformation of serialized relations within JSONB structures
|
|
- Type-safe universal entity mapping
|
|
|
|
#### SerializedRelation Type
|
|
|
|
Use `SerializedRelation` for properties **inside JSONB structures** that store foreign key references (entity IDs):
|
|
|
|
```typescript
|
|
import { SerializedRelation } from 'twenty-shared/types';
|
|
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
|
|
|
|
// Define the JSONB structure type
|
|
type MyEntityConfiguration = {
|
|
name: string;
|
|
// This stores a reference to another field's ID - use SerializedRelation
|
|
targetFieldMetadataId: SerializedRelation;
|
|
// This stores a reference to an object's ID
|
|
sourceObjectMetadataId: SerializedRelation;
|
|
// Regular string - NOT a foreign key reference
|
|
displayFormat: string;
|
|
};
|
|
|
|
@Entity('myEntity')
|
|
export class MyEntityEntity extends SyncableEntity {
|
|
@Column({ type: 'jsonb', nullable: false })
|
|
configuration: JsonbProperty<MyEntityConfiguration>;
|
|
}
|
|
```
|
|
|
|
**When to use `SerializedRelation`:**
|
|
- Properties inside JSONB that store UUIDs referencing other entities
|
|
- Foreign key relationships that can't use TypeORM relations (because they're in JSONB)
|
|
- Any `*Id` property inside a JSONB structure that references another metadata entity
|
|
|
|
**What it enables:**
|
|
- Automatic renaming from `*Id` to `*UniversalIdentifier` in universal entities
|
|
- Type-safe extraction of serialized relation properties
|
|
- Proper handling during workspace sync/migration
|
|
|
|
#### Complete Example
|
|
|
|
```typescript
|
|
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';
|
|
import { SerializedRelation } from 'twenty-shared/types';
|
|
|
|
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
|
|
import { JsonbProperty } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/jsonb-property.type';
|
|
|
|
// JSONB structure with serialized relations
|
|
type WidgetConfiguration = {
|
|
title: string;
|
|
// Foreign keys stored in JSONB - use SerializedRelation
|
|
fieldMetadataId: SerializedRelation;
|
|
objectMetadataId: SerializedRelation;
|
|
// Optional foreign key
|
|
viewId?: SerializedRelation;
|
|
// Regular properties (not foreign keys)
|
|
displayMode: 'compact' | 'expanded';
|
|
maxItems: number;
|
|
};
|
|
|
|
type GridPosition = {
|
|
row: number;
|
|
column: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
@Entity('widget')
|
|
export class WidgetEntity extends SyncableEntity implements Required<WidgetEntity> {
|
|
@PrimaryGeneratedColumn('uuid')
|
|
id: string;
|
|
|
|
@Column({ nullable: true, type: 'uuid' })
|
|
standardId: string | null;
|
|
|
|
@Column({ nullable: false })
|
|
name: string;
|
|
|
|
// JSONB column with serialized relations - wrap with JsonbProperty
|
|
@Column({ type: 'jsonb', nullable: false })
|
|
configuration: JsonbProperty<WidgetConfiguration>;
|
|
|
|
// JSONB column without serialized relations - still wrap with JsonbProperty
|
|
@Column({ type: 'jsonb', nullable: false })
|
|
gridPosition: JsonbProperty<GridPosition>;
|
|
|
|
@Column({ default: false })
|
|
isCustom: boolean;
|
|
|
|
// ... other columns
|
|
}
|
|
```
|
|
|
|
**Result in Universal Entity:**
|
|
|
|
When transformed to a universal entity, the `configuration` property will have its `SerializedRelation` fields automatically renamed:
|
|
|
|
```typescript
|
|
// Original (in database/flat entity)
|
|
{
|
|
fieldMetadataId: "abc-123",
|
|
objectMetadataId: "def-456",
|
|
viewId: "ghi-789",
|
|
displayMode: "compact",
|
|
maxItems: 10,
|
|
}
|
|
|
|
// Transformed (in universal entity)
|
|
{
|
|
fieldMetadataUniversalIdentifier: "abc-123",
|
|
objectMetadataUniversalIdentifier: "def-456",
|
|
viewUniversalIdentifier: "ghi-789",
|
|
displayMode: "compact",
|
|
maxItems: 10,
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 3: Define Flat Entity Type
|
|
|
|
**File:** `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type.ts`
|
|
|
|
```typescript
|
|
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity.type';
|
|
|
|
export type FlatMyEntity = FlatEntityFrom<MyEntityEntity>;
|
|
```
|
|
|
|
The `FlatEntityFrom<T>` utility type automatically:
|
|
- Removes relation properties (ManyToOne, OneToMany)
|
|
- Converts Date properties to string (ISO format)
|
|
- Adds `{relationName}Ids` arrays for OneToMany relations
|
|
- Preserves non-nullable `universalIdentifier` and `applicationId` from `SyncableEntity`
|
|
|
|
**File:** `src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type.ts`
|
|
|
|
```typescript
|
|
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
|
|
import { type FlatMyEntity } from './flat-my-entity.type';
|
|
|
|
export type FlatMyEntityMaps = FlatEntityMaps<FlatMyEntity>;
|
|
```
|
|
|
|
---
|
|
|
|
### Step 4: Define Editable Properties
|
|
|
|
**File:** `src/engine/metadata-modules/flat-my-entity/constants/flat-my-entity-editable-properties.constant.ts`
|
|
|
|
```typescript
|
|
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
|
|
|
export const FLAT_MY_ENTITY_EDITABLE_PROPERTIES = [
|
|
'name',
|
|
'label',
|
|
'icon',
|
|
'description',
|
|
] as const satisfies (keyof FlatMyEntity)[];
|
|
```
|
|
|
|
---
|
|
|
|
### Step 5: Register in Central Constants
|
|
|
|
#### 5a. All Flat Entity Types Registry
|
|
|
|
**File:** `src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts`
|
|
|
|
```typescript
|
|
// Add imports
|
|
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
|
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import {
|
|
type CreateMyEntityAction,
|
|
type DeleteMyEntityAction,
|
|
type UpdateMyEntityAction,
|
|
} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
|
|
|
export type AllFlatEntityTypesByMetadataName = {
|
|
// ... existing entries
|
|
myEntity: {
|
|
actions: {
|
|
create: CreateMyEntityAction;
|
|
update: UpdateMyEntityAction;
|
|
delete: DeleteMyEntityAction;
|
|
};
|
|
flatEntity: FlatMyEntity;
|
|
entity: MyEntityEntity;
|
|
};
|
|
};
|
|
```
|
|
|
|
#### 5b. Properties to Compare and Stringify
|
|
|
|
**File:** `src/engine/metadata-modules/flat-entity/constant/all-flat-entity-properties-to-compare-and-stringify.constant.ts`
|
|
|
|
```typescript
|
|
import { FLAT_MY_ENTITY_EDITABLE_PROPERTIES } from 'src/engine/metadata-modules/flat-my-entity/constants/flat-my-entity-editable-properties.constant';
|
|
|
|
export const ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY = {
|
|
// ... existing entries
|
|
myEntity: {
|
|
propertiesToCompare: [...FLAT_MY_ENTITY_EDITABLE_PROPERTIES],
|
|
propertiesToStringify: [], // Add properties that need JSON.stringify for comparison
|
|
},
|
|
} as const satisfies {
|
|
[P in AllMetadataName]: OneFlatEntityConfiguration<P>;
|
|
};
|
|
```
|
|
|
|
#### 5c. Metadata Relations
|
|
|
|
**File:** `src/engine/metadata-modules/flat-entity/constant/all-metadata-relations.constant.ts`
|
|
|
|
```typescript
|
|
export const ALL_METADATA_RELATIONS = {
|
|
// ... existing entries
|
|
myEntity: {
|
|
manyToOne: {
|
|
workspace: null,
|
|
application: null,
|
|
// Add other relations if your entity has them:
|
|
// parentEntity: {
|
|
// metadataName: 'parentEntity',
|
|
// flatEntityForeignKeyAggregator: 'myEntityIds',
|
|
// foreignKey: 'parentEntityId',
|
|
// },
|
|
},
|
|
oneToMany: {},
|
|
},
|
|
} as const satisfies MetadataRelationsProperties;
|
|
```
|
|
|
|
#### 5d. Required Metadata for Validation
|
|
|
|
**File:** `src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts`
|
|
|
|
```typescript
|
|
export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = {
|
|
// ... existing entries
|
|
myEntity: {
|
|
// Add metadata names that are required when validating this entity:
|
|
// parentEntity: true,
|
|
},
|
|
} as const satisfies MetadataRequiredForValidation;
|
|
```
|
|
|
|
---
|
|
|
|
### Step 6: Create Cache Service
|
|
|
|
**File:** `src/engine/metadata-modules/flat-my-entity/services/workspace-flat-my-entity-map-cache.service.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
|
|
import { Repository } from 'typeorm';
|
|
|
|
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
|
|
|
|
import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant';
|
|
import { type FlatMyEntityMaps } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity-maps.type';
|
|
import { fromMyEntityEntityToFlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util';
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
|
|
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
|
|
|
|
@Injectable()
|
|
@WorkspaceCache('flatMyEntityMaps') // Key must match the AllFlatEntityMaps property name
|
|
export class WorkspaceFlatMyEntityMapCacheService extends WorkspaceCacheProvider<FlatMyEntityMaps> {
|
|
constructor(
|
|
@InjectRepository(MyEntityEntity)
|
|
private readonly myEntityRepository: Repository<MyEntityEntity>,
|
|
) {
|
|
super();
|
|
}
|
|
|
|
async computeForCache(workspaceId: string): Promise<FlatMyEntityMaps> {
|
|
const entities = await this.myEntityRepository.find({
|
|
where: { workspaceId },
|
|
withDeleted: true, // Include soft-deleted entities
|
|
});
|
|
|
|
const flatMyEntityMaps = createEmptyFlatEntityMaps();
|
|
|
|
for (const entity of entities) {
|
|
const flatEntity = fromMyEntityEntityToFlatMyEntity(entity);
|
|
|
|
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
|
|
flatEntity,
|
|
flatEntityMapsToMutate: flatMyEntityMaps,
|
|
});
|
|
}
|
|
|
|
return flatMyEntityMaps;
|
|
}
|
|
}
|
|
```
|
|
|
|
**File:** `src/engine/metadata-modules/flat-my-entity/utils/from-my-entity-entity-to-flat-my-entity.util.ts`
|
|
|
|
```typescript
|
|
import { type FlatMyEntity } from 'src/engine/metadata-modules/flat-my-entity/types/flat-my-entity.type';
|
|
import { type MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
|
|
export const fromMyEntityEntityToFlatMyEntity = (
|
|
entity: MyEntityEntity,
|
|
): FlatMyEntity => {
|
|
return {
|
|
id: entity.id,
|
|
standardId: entity.standardId,
|
|
name: entity.name,
|
|
label: entity.label,
|
|
icon: entity.icon,
|
|
description: entity.description,
|
|
isCustom: entity.isCustom,
|
|
workspaceId: entity.workspaceId,
|
|
// universalIdentifier: standardId for standard entities, id for custom entities
|
|
universalIdentifier: entity.standardId || entity.id,
|
|
applicationId: entity.applicationId,
|
|
createdAt: entity.createdAt.toISOString(),
|
|
updatedAt: entity.updatedAt.toISOString(),
|
|
};
|
|
};
|
|
```
|
|
|
|
> **Important:** The `universalIdentifier` must be set correctly:
|
|
> - For **standard entities** (shipped with Twenty): use `standardId`
|
|
> - For **custom entities** (created by users): use `id`
|
|
> - Pattern: `entity.standardId || entity.id`
|
|
|
|
---
|
|
|
|
### Step 7: Create Flat Entity Module
|
|
|
|
**File:** `src/engine/metadata-modules/flat-my-entity/flat-my-entity.module.ts`
|
|
|
|
```typescript
|
|
import { Module } from '@nestjs/common';
|
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
|
|
|
import { WorkspaceManyOrAllFlatEntityMapsCacheModule } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module';
|
|
import { WorkspaceFlatMyEntityMapCacheService } from 'src/engine/metadata-modules/flat-my-entity/services/workspace-flat-my-entity-map-cache.service';
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
|
|
@Module({
|
|
imports: [
|
|
TypeOrmModule.forFeature([MyEntityEntity]),
|
|
WorkspaceManyOrAllFlatEntityMapsCacheModule,
|
|
],
|
|
providers: [WorkspaceFlatMyEntityMapCacheService],
|
|
exports: [WorkspaceFlatMyEntityMapCacheService],
|
|
})
|
|
export class FlatMyEntityModule {}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 8: Define Action Types
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type.ts`
|
|
|
|
```typescript
|
|
import { type BaseCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-create-workspace-migration-action.type';
|
|
import { type BaseDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-delete-workspace-migration-action.type';
|
|
import { type BaseUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-update-workspace-migration-action.type';
|
|
|
|
export type CreateMyEntityAction = BaseCreateWorkspaceMigrationAction<'myEntity'>;
|
|
|
|
export type UpdateMyEntityAction = BaseUpdateWorkspaceMigrationAction<'myEntity'>;
|
|
|
|
export type DeleteMyEntityAction = BaseDeleteWorkspaceMigrationAction<'myEntity'>;
|
|
```
|
|
|
|
---
|
|
|
|
### Step 9: Create Validator Service
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service.ts`
|
|
|
|
> **Critical Rules for Validators and Builders:**
|
|
>
|
|
> 1. **Never throw exceptions** - Always fail slow by collecting errors in an array and returning them to the caller. This allows validating all entities and reporting all errors at once, rather than failing on the first error.
|
|
>
|
|
> 2. **Never mutate entity maps** - The validator/builder should only read from the flat entity maps, never modify them. No data transformation should happen here - transformations belong in the input-to-flat-entity utilities before calling the migration service.
|
|
>
|
|
> 3. **No side effects** - Validation logic should be pure: given the same inputs, it should always produce the same outputs.
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { msg, t } from '@lingui/core/macro';
|
|
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
|
|
import { belongsToTwentyStandardApp } from 'src/engine/metadata-modules/utils/is-standard-metadata.util';
|
|
import { MyEntityExceptionCode } from 'src/engine/metadata-modules/my-entity/my-entity.exception';
|
|
import { type FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type';
|
|
import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util';
|
|
import { type FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type';
|
|
import { type FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type';
|
|
|
|
@Injectable()
|
|
export class FlatMyEntityValidatorService {
|
|
public validateFlatMyEntityCreation({
|
|
flatEntityToValidate: flatMyEntity,
|
|
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
|
|
flatMyEntityMaps: optimisticFlatMyEntityMaps,
|
|
},
|
|
}: FlatEntityValidationArgs<
|
|
typeof ALL_METADATA_NAME.myEntity
|
|
>): FailedFlatEntityValidation<'myEntity', 'create'> {
|
|
const validationResult = getEmptyFlatEntityValidationError({
|
|
flatEntityMinimalInformation: {
|
|
id: flatMyEntity.id,
|
|
universalIdentifier: flatMyEntity.universalIdentifier,
|
|
name: flatMyEntity.name,
|
|
},
|
|
metadataName: 'myEntity',
|
|
type: 'create',
|
|
});
|
|
|
|
// Add your validation rules here
|
|
// Example: validate required properties, uniqueness, etc.
|
|
|
|
return validationResult;
|
|
}
|
|
|
|
public validateFlatMyEntityDeletion({
|
|
flatEntityToValidate,
|
|
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
|
|
flatMyEntityMaps: optimisticFlatMyEntityMaps,
|
|
},
|
|
buildOptions,
|
|
}: FlatEntityValidationArgs<
|
|
typeof ALL_METADATA_NAME.myEntity
|
|
>): FailedFlatEntityValidation<'myEntity', 'delete'> {
|
|
const validationResult = getEmptyFlatEntityValidationError({
|
|
flatEntityMinimalInformation: {
|
|
id: flatEntityToValidate.id,
|
|
universalIdentifier: flatEntityToValidate.universalIdentifier,
|
|
name: flatEntityToValidate.name,
|
|
},
|
|
metadataName: 'myEntity',
|
|
type: 'delete',
|
|
});
|
|
|
|
const existingEntity = findFlatEntityByIdInFlatEntityMaps({
|
|
flatEntityId: flatEntityToValidate.id,
|
|
flatEntityMaps: optimisticFlatMyEntityMaps,
|
|
});
|
|
|
|
if (!isDefined(existingEntity)) {
|
|
validationResult.errors.push({
|
|
code: MyEntityExceptionCode.MY_ENTITY_NOT_FOUND,
|
|
message: t`Entity not found`,
|
|
userFriendlyMessage: msg`Entity not found`,
|
|
});
|
|
|
|
return validationResult;
|
|
}
|
|
|
|
// Prevent deletion of standard entities unless it's a system build
|
|
if (!buildOptions.isSystemBuild && belongsToTwentyStandardApp(existingEntity)) {
|
|
validationResult.errors.push({
|
|
code: MyEntityExceptionCode.MY_ENTITY_IS_STANDARD,
|
|
message: t`Cannot delete standard entity`,
|
|
userFriendlyMessage: msg`Cannot delete standard entity`,
|
|
});
|
|
}
|
|
|
|
return validationResult;
|
|
}
|
|
|
|
public validateFlatMyEntityUpdate({
|
|
flatEntityId,
|
|
flatEntityUpdates,
|
|
optimisticFlatEntityMapsAndRelatedFlatEntityMaps: {
|
|
flatMyEntityMaps: optimisticFlatMyEntityMaps,
|
|
},
|
|
buildOptions,
|
|
}: FlatEntityUpdateValidationArgs<
|
|
typeof ALL_METADATA_NAME.myEntity
|
|
>): FailedFlatEntityValidation<'myEntity', 'update'> {
|
|
const fromFlatEntity = findFlatEntityByIdInFlatEntityMaps({
|
|
flatEntityId,
|
|
flatEntityMaps: optimisticFlatMyEntityMaps,
|
|
});
|
|
|
|
const validationResult = getEmptyFlatEntityValidationError({
|
|
flatEntityMinimalInformation: {
|
|
id: flatEntityId,
|
|
universalIdentifier: fromFlatEntity?.universalIdentifier,
|
|
},
|
|
metadataName: 'myEntity',
|
|
type: 'update',
|
|
});
|
|
|
|
if (!isDefined(fromFlatEntity)) {
|
|
validationResult.errors.push({
|
|
code: MyEntityExceptionCode.MY_ENTITY_NOT_FOUND,
|
|
message: t`Entity not found`,
|
|
userFriendlyMessage: msg`Entity not found`,
|
|
});
|
|
|
|
return validationResult;
|
|
}
|
|
|
|
// Add your update validation rules here
|
|
|
|
return validationResult;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 10: Create Builder Service
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service.ts`
|
|
|
|
The builder service orchestrates validation and creates migration actions. It follows the same rules as validators:
|
|
- **Never throws** - delegates to validator and returns fail status with errors
|
|
- **Never mutates** - the base class handles optimistic cache updates after successful validation
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { ALL_METADATA_NAME } from 'twenty-shared/metadata';
|
|
|
|
import { UpdateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
|
import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service';
|
|
import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-update-validation-args.type';
|
|
import { FlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-args.type';
|
|
import { FlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/flat-entity-validation-result.type';
|
|
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
|
|
|
|
@Injectable()
|
|
export class WorkspaceMigrationMyEntityActionsBuilderService extends WorkspaceEntityMigrationBuilderService<
|
|
typeof ALL_METADATA_NAME.myEntity
|
|
> {
|
|
constructor(
|
|
private readonly flatMyEntityValidatorService: FlatMyEntityValidatorService,
|
|
) {
|
|
super(ALL_METADATA_NAME.myEntity);
|
|
}
|
|
|
|
protected validateFlatEntityCreation(
|
|
args: FlatEntityValidationArgs<typeof ALL_METADATA_NAME.myEntity>,
|
|
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.myEntity, 'create'> {
|
|
const validationResult =
|
|
this.flatMyEntityValidatorService.validateFlatMyEntityCreation(args);
|
|
|
|
if (validationResult.errors.length > 0) {
|
|
return {
|
|
status: 'fail',
|
|
...validationResult,
|
|
};
|
|
}
|
|
|
|
const { flatEntityToValidate } = args;
|
|
|
|
return {
|
|
status: 'success',
|
|
action: {
|
|
type: 'create',
|
|
metadataName: 'myEntity',
|
|
flatEntity: flatEntityToValidate,
|
|
},
|
|
};
|
|
}
|
|
|
|
protected validateFlatEntityDeletion(
|
|
args: FlatEntityValidationArgs<typeof ALL_METADATA_NAME.myEntity>,
|
|
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.myEntity, 'delete'> {
|
|
const validationResult =
|
|
this.flatMyEntityValidatorService.validateFlatMyEntityDeletion(args);
|
|
|
|
if (validationResult.errors.length > 0) {
|
|
return {
|
|
status: 'fail',
|
|
...validationResult,
|
|
};
|
|
}
|
|
|
|
const { flatEntityToValidate } = args;
|
|
|
|
return {
|
|
status: 'success',
|
|
action: {
|
|
type: 'delete',
|
|
metadataName: 'myEntity',
|
|
entityId: flatEntityToValidate.id,
|
|
},
|
|
};
|
|
}
|
|
|
|
protected validateFlatEntityUpdate(
|
|
args: FlatEntityUpdateValidationArgs<typeof ALL_METADATA_NAME.myEntity>,
|
|
): FlatEntityValidationReturnType<typeof ALL_METADATA_NAME.myEntity, 'update'> {
|
|
const validationResult =
|
|
this.flatMyEntityValidatorService.validateFlatMyEntityUpdate(args);
|
|
|
|
if (validationResult.errors.length > 0) {
|
|
return {
|
|
status: 'fail',
|
|
...validationResult,
|
|
};
|
|
}
|
|
|
|
const { flatEntityId, flatEntityUpdates } = args;
|
|
|
|
const updateAction: UpdateMyEntityAction = {
|
|
type: 'update',
|
|
metadataName: 'myEntity',
|
|
entityId: flatEntityId,
|
|
updates: flatEntityUpdates,
|
|
};
|
|
|
|
return {
|
|
status: 'success',
|
|
action: updateAction,
|
|
};
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 11: Create Action Handlers (Runner)
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
|
|
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { CreateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
|
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
|
|
|
|
@Injectable()
|
|
export class CreateMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
|
|
'create',
|
|
'myEntity',
|
|
) {
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
async executeForMetadata(
|
|
context: WorkspaceMigrationActionRunnerArgs<CreateMyEntityAction>,
|
|
): Promise<void> {
|
|
const { action, queryRunner, workspaceId } = context;
|
|
const { flatEntity } = action;
|
|
|
|
const repository =
|
|
queryRunner.manager.getRepository<MyEntityEntity>(MyEntityEntity);
|
|
|
|
await repository.save({
|
|
...flatEntity,
|
|
workspaceId,
|
|
});
|
|
}
|
|
|
|
async executeForWorkspaceSchema(
|
|
_context: WorkspaceMigrationActionRunnerArgs<CreateMyEntityAction>,
|
|
): Promise<void> {
|
|
// Only needed for entities that affect the workspace schema (objects, fields)
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
|
|
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { UpdateMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
|
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
|
|
import { fromFlatEntityPropertiesUpdatesToPartialFlatEntity } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/from-flat-entity-properties-updates-to-partial-flat-entity';
|
|
|
|
@Injectable()
|
|
export class UpdateMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
|
|
'update',
|
|
'myEntity',
|
|
) {
|
|
async executeForMetadata(
|
|
context: WorkspaceMigrationActionRunnerArgs<UpdateMyEntityAction>,
|
|
): Promise<void> {
|
|
const { action, queryRunner, workspaceId } = context;
|
|
const { entityId, updates } = action;
|
|
|
|
const repository =
|
|
queryRunner.manager.getRepository<MyEntityEntity>(MyEntityEntity);
|
|
|
|
await repository.update(
|
|
{ id: entityId, workspaceId },
|
|
fromFlatEntityPropertiesUpdatesToPartialFlatEntity({ updates }),
|
|
);
|
|
}
|
|
|
|
async executeForWorkspaceSchema(
|
|
_context: WorkspaceMigrationActionRunnerArgs<UpdateMyEntityAction>,
|
|
): Promise<void> {
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface';
|
|
|
|
import { MyEntityEntity } from 'src/engine/metadata-modules/my-entity/entities/my-entity.entity';
|
|
import { DeleteMyEntityAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/types/workspace-migration-my-entity-action.type';
|
|
import { WorkspaceMigrationActionRunnerArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type';
|
|
|
|
@Injectable()
|
|
export class DeleteMyEntityActionHandlerService extends WorkspaceMigrationRunnerActionHandler(
|
|
'delete',
|
|
'myEntity',
|
|
) {
|
|
constructor() {
|
|
super();
|
|
}
|
|
|
|
async executeForMetadata(
|
|
context: WorkspaceMigrationActionRunnerArgs<DeleteMyEntityAction>,
|
|
): Promise<void> {
|
|
const { action, queryRunner, workspaceId } = context;
|
|
const { entityId } = action;
|
|
|
|
const repository =
|
|
queryRunner.manager.getRepository<MyEntityEntity>(MyEntityEntity);
|
|
|
|
await repository.delete({ id: entityId, workspaceId });
|
|
}
|
|
|
|
async executeForWorkspaceSchema(
|
|
_context: WorkspaceMigrationActionRunnerArgs<DeleteMyEntityAction>,
|
|
): Promise<void> {
|
|
return;
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Step 12: Wire in Orchestrator Service (CRITICAL)
|
|
|
|
> **⚠️ This step is frequently forgotten!** The orchestrator service must be updated to call your builder's `validateAndBuild` method.
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts`
|
|
|
|
1. **Add import:**
|
|
|
|
```typescript
|
|
import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service';
|
|
```
|
|
|
|
2. **Add to constructor:**
|
|
|
|
```typescript
|
|
constructor(
|
|
// ... existing services
|
|
private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService,
|
|
) {}
|
|
```
|
|
|
|
3. **Add to `buildWorkspaceMigration` method** (after extracting from `fromToAllFlatEntityMaps`):
|
|
|
|
```typescript
|
|
const {
|
|
// ... existing extractions
|
|
flatMyEntityMaps,
|
|
} = fromToAllFlatEntityMaps;
|
|
```
|
|
|
|
4. **Add the builder call block:**
|
|
|
|
```typescript
|
|
if (isDefined(flatMyEntityMaps)) {
|
|
const { from: fromFlatMyEntityMaps, to: toFlatMyEntityMaps } =
|
|
flatMyEntityMaps;
|
|
|
|
const myEntityResult =
|
|
await this.workspaceMigrationMyEntityActionsBuilderService.validateAndBuild(
|
|
{
|
|
additionalCacheDataMaps,
|
|
from: fromFlatMyEntityMaps,
|
|
to: toFlatMyEntityMaps,
|
|
buildOptions,
|
|
dependencyOptimisticFlatEntityMaps: undefined, // Or add dependencies
|
|
workspaceId,
|
|
},
|
|
);
|
|
|
|
this.mergeFlatEntityMapsAndRelatedFlatEntityMapsInAllFlatEntityMapsThroughMutation(
|
|
{
|
|
allFlatEntityMaps: optimisticAllFlatEntityMaps,
|
|
flatEntityMapsAndRelatedFlatEntityMaps:
|
|
myEntityResult.optimisticFlatEntityMapsAndRelatedFlatEntityMaps,
|
|
},
|
|
);
|
|
|
|
if (myEntityResult.status === 'fail') {
|
|
orchestratorFailureReport.myEntity.push(...myEntityResult.errors);
|
|
} else {
|
|
orchestratorActionsReport.myEntity = myEntityResult.actions;
|
|
}
|
|
}
|
|
```
|
|
|
|
5. **Add actions to the return statement:**
|
|
|
|
```typescript
|
|
return {
|
|
// ... existing
|
|
workspaceMigration: {
|
|
// ... existing
|
|
actions: [
|
|
// ... existing actions
|
|
|
|
// My Entity
|
|
...aggregatedOrchestratorActionsReport.myEntity.delete,
|
|
...aggregatedOrchestratorActionsReport.myEntity.create,
|
|
...aggregatedOrchestratorActionsReport.myEntity.update,
|
|
],
|
|
},
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
### Step 13: Register in Modules
|
|
|
|
#### 13a. Builder Module
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts`
|
|
|
|
```typescript
|
|
import { WorkspaceMigrationMyEntityActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/my-entity/workspace-migration-my-entity-actions-builder.service';
|
|
|
|
@Module({
|
|
// ...
|
|
providers: [
|
|
// ... existing
|
|
WorkspaceMigrationMyEntityActionsBuilderService,
|
|
],
|
|
exports: [
|
|
// ... existing
|
|
WorkspaceMigrationMyEntityActionsBuilderService,
|
|
],
|
|
})
|
|
export class WorkspaceMigrationBuilderModule {}
|
|
```
|
|
|
|
#### 13b. Validators Module
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts`
|
|
|
|
```typescript
|
|
import { FlatMyEntityValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-my-entity-validator.service';
|
|
|
|
@Module({
|
|
// ...
|
|
providers: [
|
|
// ... existing
|
|
FlatMyEntityValidatorService,
|
|
],
|
|
exports: [
|
|
// ... existing
|
|
FlatMyEntityValidatorService,
|
|
],
|
|
})
|
|
export class WorkspaceMigrationBuilderValidatorsModule {}
|
|
```
|
|
|
|
#### 13c. Action Handlers Module
|
|
|
|
**File:** `src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts`
|
|
|
|
```typescript
|
|
import { CreateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/create-my-entity-action-handler.service';
|
|
import { UpdateMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/update-my-entity-action-handler.service';
|
|
import { DeleteMyEntityActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/my-entity/services/delete-my-entity-action-handler.service';
|
|
|
|
@Module({
|
|
// ...
|
|
providers: [
|
|
// ... existing
|
|
CreateMyEntityActionHandlerService,
|
|
UpdateMyEntityActionHandlerService,
|
|
DeleteMyEntityActionHandlerService,
|
|
],
|
|
})
|
|
export class WorkspaceSchemaMigrationRunnerActionHandlersModule {}
|
|
```
|
|
|
|
---
|
|
|
|
## Using the Entity in Services
|
|
|
|
Here's how to use the entity in a business service.
|
|
|
|
**Important:** All data transformations (sanitization, normalization, default values) must happen **before** calling `validateBuildAndRunWorkspaceMigration`. The builder/validator should receive clean, ready-to-persist flat entities - it only validates business rules and creates actions, it does not transform data.
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
|
|
import { isDefined } from 'twenty-shared/utils';
|
|
|
|
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
|
|
import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps-or-throw.util';
|
|
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
|
|
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
|
|
|
|
@Injectable()
|
|
export class MyEntityService {
|
|
constructor(
|
|
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
|
|
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
|
|
) {}
|
|
|
|
async create(input: CreateMyEntityInput, workspaceId: string) {
|
|
// 1. Transform input to flat entity
|
|
// This is where ALL transformations happen:
|
|
// - Sanitize strings (trim, remove duplicate whitespace)
|
|
// - Compute derived values (e.g., name from label)
|
|
// - Set default values
|
|
// - Generate IDs
|
|
// - Set universalIdentifier and applicationId
|
|
const flatMyEntityToCreate = fromCreateInputToFlatEntity({
|
|
input,
|
|
workspaceId,
|
|
});
|
|
|
|
// 2. Call validate, build, and run
|
|
const validateAndBuildResult =
|
|
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
|
|
{
|
|
allFlatEntityOperationByMetadataName: {
|
|
myEntity: {
|
|
flatEntityToCreate: [flatMyEntityToCreate],
|
|
flatEntityToDelete: [],
|
|
flatEntityToUpdate: [],
|
|
},
|
|
},
|
|
workspaceId,
|
|
isSystemBuild: false,
|
|
},
|
|
);
|
|
|
|
// 3. Throw if validation failed
|
|
if (isDefined(validateAndBuildResult)) {
|
|
throw new WorkspaceMigrationBuilderException(
|
|
validateAndBuildResult,
|
|
'Validation errors occurred while creating entity',
|
|
);
|
|
}
|
|
|
|
// 4. Return freshly cached entity
|
|
const { flatMyEntityMaps } =
|
|
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
|
|
{
|
|
workspaceId,
|
|
flatMapsKeys: ['flatMyEntityMaps'],
|
|
},
|
|
);
|
|
|
|
return findFlatEntityByIdInFlatEntityMapsOrThrow({
|
|
flatEntityId: flatMyEntityToCreate.id,
|
|
flatEntityMaps: flatMyEntityMaps,
|
|
});
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Integration Tests
|
|
|
|
Create integration tests following the existing pattern:
|
|
|
|
```
|
|
test/integration/metadata/suites/my-entity/
|
|
├── __snapshots__/
|
|
│ └── failing-my-entity-creation.integration-spec.ts.snap
|
|
├── failing-my-entity-creation.integration-spec.ts
|
|
├── successful-my-entity-creation.integration-spec.ts
|
|
└── utils/
|
|
├── create-one-my-entity.util.ts
|
|
└── delete-one-my-entity.util.ts
|
|
```
|
|
|
|
Tests should cover:
|
|
- Successful CRUD operations
|
|
- Failing validation scenarios (using `eachTestingContextFilter` pattern)
|
|
- Edge cases and business rules
|
|
|
|
---
|
|
|
|
## Checklist
|
|
|
|
Before considering your syncable entity complete, verify:
|
|
|
|
### Syncable Entity Requirements
|
|
- [ ] TypeORM entity **extends `SyncableEntity`**
|
|
- [ ] Entity has `standardId` column (nullable, for standard entities)
|
|
- [ ] Entity has `isCustom` boolean column
|
|
- [ ] Entity-to-flat transform sets `universalIdentifier` correctly (`standardId || id`)
|
|
|
|
### JSONB Properties and Serialized Relations
|
|
- [ ] All JSONB columns are wrapped with `JsonbProperty<T>`
|
|
- [ ] Foreign key references inside JSONB structures use `SerializedRelation` type
|
|
- [ ] JSONB structure types are properly defined with `SerializedRelation` for `*Id` properties
|
|
|
|
### Registration (twenty-shared)
|
|
- [ ] Metadata name added to `ALL_METADATA_NAME`
|
|
|
|
### Flat Entity Definition
|
|
- [ ] Flat entity type defined using `FlatEntityFrom<T>`
|
|
- [ ] Flat entity maps type defined
|
|
- [ ] Editable properties constant defined
|
|
|
|
### Central Constants Registration
|
|
- [ ] Registered in `AllFlatEntityTypesByMetadataName`
|
|
- [ ] Registered in `ALL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY`
|
|
- [ ] Registered in `ALL_METADATA_RELATIONS`
|
|
- [ ] Registered in `ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION`
|
|
|
|
### Cache Layer
|
|
- [ ] Cache service created with `@WorkspaceCache` decorator
|
|
- [ ] Cache key matches `flat{EntityName}Maps` pattern
|
|
- [ ] Cache service uses `withDeleted: true` when fetching entities
|
|
- [ ] Flat entity module created and exports cache service
|
|
|
|
### Migration Builder
|
|
- [ ] Action types defined (create, update, delete) using base types
|
|
- [ ] Validator service created with correct behavior:
|
|
- [ ] **Never throws** - returns errors array (fail slow)
|
|
- [ ] **Never mutates** flat entity maps
|
|
- [ ] **No transformations** - only validates business rules
|
|
- [ ] Builder service extends `WorkspaceEntityMigrationBuilderService`
|
|
|
|
### Migration Runner
|
|
- [ ] Action handlers created for create/update/delete
|
|
- [ ] Handlers extend `WorkspaceMigrationRunnerActionHandler('action', 'metadataName')`
|
|
|
|
### Orchestrator Wiring (⚠️ COMMONLY FORGOTTEN)
|
|
- [ ] **Builder imported in orchestrator service**
|
|
- [ ] **Builder injected in orchestrator constructor**
|
|
- [ ] **Builder's `validateAndBuild` called in `buildWorkspaceMigration` method**
|
|
- [ ] **Actions added to return statement**
|
|
|
|
### Module Registration
|
|
- [ ] Builder registered in builder module (providers + exports)
|
|
- [ ] Validator registered in validators module
|
|
- [ ] Action handlers registered in action handlers module
|
|
|
|
### Testing
|
|
- [ ] Integration tests written (successful + failing scenarios)
|