mcpField
S
syncable-entity-builder-and-validation

syncable-entity-builder-and-validation

github.com/twentyhqDevelopment60.0k51 installsUpdated today
Repository

About syncable-entity-builder-and-validation

A guide for developers implementing syncable entities in NestJS, covering migration action builders and business-rule validation. It emphasizes non-throwing validators, indexed lookups, and wiring the builder into the orchestrator.

Key features

  1. 01Create validators that return error arrays
  2. 02Validate against optimistic entity maps
  3. 03Use indexed maps for lookups
  4. 04Build migration actions
  5. 05Wire the builder into the orchestrator

Use cases

  1. 01Add required-field and uniqueness checks
  2. 02Validate foreign keys and protect standard entities
  3. 03Include entity actions in migration builds

Install

Copy the command and run it in a terminal.

$npx skills add https://github.com/twentyhq/twenty --skill syncable-entity-builder-and-validation

After installing, type/syncable-entity-builder-and-validationin Claude Code to confirm it loaded.

Details

From the project README

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

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<FlatMyEntity>,
    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!

// ❌ 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

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<UniversalCreateMyEntityAction> {
    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<UniversalFlatMyEntity>,
    flatEntityMaps: AllFlatEntityMapsByMetadataName,
  ): BuildWorkspaceMigrationActionReturnType<UniversalUpdateMyEntityAction> {
    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<UniversalDeleteMyEntityAction> {
    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

@Injectable()
export class WorkspaceMigrationBuildOrchestratorService {
  constructor(
    // ... existing builders
    private readonly workspaceMigrationMyEntityActionsBuilderService: WorkspaceMigrationMyEntityActionsBuilderService,
  ) {}

  async buildWorkspaceMigration({
    allFlatEntityOperationByMetadataName,
    flatEntityMaps,
    isSystemBuild,
  }: BuildWorkspaceMigrationInput): Promise<BuildWorkspaceMigrationOutput> {
    // ... 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
if (!isDefined(field) || field.trim() === '') {
  errors.push({ code: ..., message: ..., userFriendlyMessage: ... });
}
Pattern 2: Uniqueness (O(1) lookup)
const existing = optimisticMaps.byName[entity.name];
if (isDefined(existing) && existing.id !== entity.id) {
  errors.push({ ... });
}
Pattern 3: Foreign Key Validation
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
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)

For complete workflow, see @creating-syncable-entity rule.

Comments

Markdown supported