mcpField
S
syncable-entity-builder-and-validation

syncable-entity-builder-and-validation

github.com/twentyhq開発60.0k51 回インストール今日更新
リポジトリ

syncable-entity-builder-and-validation について

NestJSで同期可能なエンティティを実装する開発者向けに、移行アクションビルダーと業務ルール検証を解説します。例外を投げないバリデーター、インデックス検索、オーケストレーターへの接続が中心です。

主な機能

  1. 01エラー配列を返すバリデーターの作成
  2. 02楽観的エンティティマップを使った検証
  3. 03インデックスマップによる検索
  4. 04移行アクションの構築
  5. 05オーケストレーターへのビルダー接続

活用シーン

  1. 01必須フィールドと一意性の検証追加
  2. 02外部キーの検証と標準エンティティの保護
  3. 03移行ビルドへのエンティティアクション追加

インストール

コマンドをコピーしてターミナルで実行します。

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

インストール後、Claude Code で/syncable-entity-builder-and-validationを入力して読み込まれたか確認します。

詳細

同期可能エンティティ: ビルダーとバリデーション (ステップ 3/6)

目的: ビジネスルールのバリデーションを実装し、マイグレーションアクションビルダーを作成します。

使用するタイミング: ステップ 1〜2 (型、キャッシュ、変換) の完了後。アクションハンドラーの実装前に必要です。


クイックスタート

このステップでは以下を作成します:

  1. バリデータサービス (ビジネスロジックのバリデーション)
  2. ビルダーサービス (アクションの作成)
  3. オーケストレーターへの配線 (重要 - しばしば忘れられます!)

主要な原則:

  • バリデータは決してスローしません - エラー配列を返します
  • バリデータは決してミューテーションしません - 楽観的エンティティマップを渡します
  • インデックス付きルックアップ (O(1)) を使用し、Object.values().find() (O(n)) は使わないでください

ステップ 1: バリデータサービスを作成する

ファイル: 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;
  }
}

パフォーマンスに関する警告: Object.values().find() は避けてください - 代わりにインデックス付きマップを使用してください!

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

ステップ 2: ビルダーサービスを作成する

ファイル: 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,
      },
    };
  }
}

ステップ 3: オーケストレーターに配線する (重要)

ファイル: 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,
      ],
    };
  }
}

⚠️ このステップが最も忘れられやすいです! オーケストレーターへの配線がなければ、エンティティは同期されません。


バリデーションパターン

パターン 1: 必須フィールド
if (!isDefined(field) || field.trim() === '') {
  errors.push({ code: ..., message: ..., userFriendlyMessage: ... });
}
パターン 2: 一意性 (O(1) ルックアップ)
const existing = optimisticMaps.byName[entity.name];
if (isDefined(existing) && existing.id !== entity.id) {
  errors.push({ ... });
}
パターン 3: 外部キーのバリデーション
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, ... });
  }
}
パターン 4: 標準エンティティの保護
if (entity.isCustom === false) {
  errors.push({ code: STANDARD_ENTITY_PROTECTED, ... });
  return errors; // Early return
}

チェックリスト

ステップ 4 に進む前に:

  • バリデータサービスを作成した
  • バリデータは決してスローしない (エラー配列を返す)
  • バリデータは決してミューテーションしない (楽観的マップを使用する)
  • すべての一意性チェックでインデックス付きマップ (O(1)) を使用している
  • 必須フィールドのバリデーションを実装した
  • 外部キーのバリデーションを実装した
  • 標準エンティティの保護を実装した
  • ビルダーサービスが WorkspaceEntityMigrationBuilderService を拡張している
  • ビルダーがユニバーサルエンティティを使用してアクションを作成する
  • ビルダーをオーケストレーターに配線した (重要)
  • ビルダーをオーケストレーターのコンストラクターに注入した
  • ビルダーを buildWorkspaceMigration で呼び出した
  • アクションをオーケストレーターの return ステートメントに追加した

次のステップ

ビルダーとバリデーションが完了したら、次に進みます: 同期可能エンティティ: ランナーとアクション (ステップ 4/6)

完全なワークフローについては、@creating-syncable-entity ルールを参照してください。

コメント