首页
/ Twenty 同步实体开发指南:业务规则校验器与迁移动作构建器(Step 3/6)

Twenty 同步实体开发指南:业务规则校验器与迁移动作构建器(Step 3/6)

2026-09-06 22:14:08作者:房伟宁

本文讲解 Twenty 工作区迁移体系(workspace migration)中「同步实体(syncable entity)」开发六步法里的第 3 步:如何为自定义元数据实体编写业务规则校验器(Validator)与迁移动作构建器(Builder),并把构建器接入编排器(Orchestrator)完成装配。读完后,你能直接参照 Twenty 源码中 WorkspaceEntityMigrationBuilderService 基类与真实校验器实现,为自己的同步实体写出「不抛异常、不产生副作用、O(1) 查重」的校验逻辑,并生成可被 Runner 执行的 create/update/delete 迁移动作。

这一步在同步实体开发中的位置

根据仓库内的开发技能文档 SKILL.md,同步实体的完整开发分为 6 个步骤,本文聚焦第 3 步(Builder & Validation):

  • 前置条件:已完成 Step 1-2(类型定义 Types、缓存 Cache、转换 Transform);
  • 后续依赖:本步产出的 Builder 是 Step 4(Runner & Actions,动作执行器)的前置条件——动作处理器必须消费本步构建出的 actions;
  • 本步交付物:① 校验器服务(Validator service,负责业务逻辑校验);② 构建器服务(Builder service,负责生成动作);③ 编排器接线(Orchestrator wiring,文档特别标注「CRITICAL - often forgotten!」,是最容易被遗漏的一环)。

文档给出的三条核心设计原则,也是 Twenty 迁移体系全局约束:

  1. 校验器永远不抛异常(never throw)——一律返回错误数组,由上层统一聚合失败报告;
  2. 校验器永远不修改数据(never mutate)——只读「乐观实体映射表(optimistic entity maps)」做查询判断;
  3. 使用索引化查找(O(1))而非 Object.values().find()(O(n))

需要说明的是:SKILL 文档以 myEntity 作为通用占位实体来讲解模板写法;在当前仓库中,这套模式已被大量真实元数据实体落地,对应源码位于 workspace-migration-builder 目录,例如 skillagentroleobjectview 等 30+ 种实体的 Builder 与 Validator。下文先继承文档的模板代码,再结合仓库真实实现做纵深对照。

Step 1:创建 Validator Service

按文档约定,校验器放在 src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/ 下(以 flat-my-entity-validator.service.ts 为例)。模板实现覆盖了 create/update/delete 三个校验入口:

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;
  }
}

结合仓库源码印证「never throw、never mutate」

从源码结构看,这个约定在真实实现中被严格执行。以 flat-skill-validator.service.ts 为例,FlatSkillValidatorServicevalidateFlatSkillCreation 方法通过 getEmptyFlatEntityValidationError(...) 初始化一个空的校验结果对象,随后把必填属性校验(validateSkillRequiredProperties)与名称唯一性校验(validateSkillNameUniqueness)的返回值 pusherrors 数组,全程只读乐观映射表,最后 return validationResult——没有任何 throw

错误条目的类型定义在 failed-flat-entity-validation.type.ts,与文档模板中的 WorkspaceMigrationValidationError 一脉相承:

export type FlatEntityValidationError<TCode extends string = string> = {
  code: TCode;                       // 机器可读的错误码,便于前端/日志分类处理
  message: string;                   // 面向开发者的详细信息
  userFriendlyMessage?: MessageDescriptor; // 面向终端用户的 i18n 消息(lingui 描述符)
  value?: unknown;                   // 触发校验失败的具体字段值
};

「不修改数据」这一原则的另一面,是乐观映射表(optimistic maps)的写权限被集中在基类:基类 WorkspaceEntityMigrationBuilderService单个实体校验通过之后,才调用 addUniversalFlatEntityToUniversalFlatEntityAndRelatedEntityMapsThroughMutationOrThrow / replaceUniversalFlatEntityInUniversalFlatEntityMapsThroughMutationOrThrow 等 mutation 工具更新映射表(见 workspace-entity-migration-builder.service.ts)。这样同一批次内第 N 个实体的校验能看到前 N-1 个实体写入后的最新状态(例如同批创建的两个同名实体,第二个会被唯一性检查拦下),而校验器本身始终保持纯查询语义。

性能原则:用索引化映射表做 O(1) 查重

文档给出了明确的性能反例与正例,这一点在大批量迁移(如应用导入数百条元数据)场景下尤其重要:

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

原理在于「flat entity maps」是按多个键(byIdbyNamebyUniversalIdentifier 等)预建索引的映射结构,直接下标访问即可命中候选实体;若退化成 Object.values().find(),每个待校验实体的查重成本都会线性放大。仓库中真实校验器也遵守该约定,例如 skill 唯一性校验被抽成专用工具 validate-agent-name-uniqueness.util.ts 同目录的 validate-skill-name-uniqueness.util.ts,在工具函数内完成查重判断。

Step 2:创建 Builder Service

构建器负责「校验 + 动作生成」,按文档约定继承基类 WorkspaceEntityMigrationBuilderService,模板实现如下(文件约定路径: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,
      },
    };
  }
}

基类 validateAndBuild 的真实执行流水线

文档中的模板展示了三个 buildXxxAction 方法,而当前仓库中基类的实现(workspace-entity-migration-builder.service.ts)已经把这一步演化得更完整:子类只需实现三个抽象校验方法 validateFlatEntityCreation / validateFlatEntityDeletion / validateFlatEntityUpdate(见 L562-L578),其余流水线全部由基类统一编排:

  1. 变更矩阵计算:从 from(当前状态)与 to(目标状态)两组 flat entity maps 出发,经 flatEntityDeletedCreatedUpdatedMatrixDispatcher 计算出 create/update/delete 三组差集;
  2. 删除校验:逐个校验待删除实体(可按 shouldInferDeletionFromMissingEntities 选项从「目标态缺失的实体」推断删除),通过后才从乐观映射表中真正移除;
  3. 创建校验:先对自引用外键做拓扑排序(topologicallySortUniversalFlatEntitiesForSelfReferentialFks,保证父实体先于子实体创建),再逐个调用校验;基类还内置了集中式校验——universalIdentifier 必须是合法的小写 UUIDv4,且不能与当前映射表中已有实体冲突(validateUniversalIdentifier / validateUniversalIdentifierNotAlreadyInCurrentMetadataMaps);
  4. 更新校验:对每个变更实体调用 validateFlatEntityUpdate,通过后合并出完整新实体并计算 before/after diff,随动作一起输出;
  5. 结果聚合:任一实体校验失败即累积进 allValidationResult,最终统一返回 { status: 'fail', errors }{ status: 'success', actions }(actions 按 create/update/delete 分桶)。

基类还内建了可观测性:每个阶段(matrix-computationdeletion-validationcreation-validationupdate-validation)都会通过 logger.perfTime 打点,并记录 WorkspaceMigrationBuildEntityDurationMs 等直方图指标。因此实现新实体的 Builder 时,无需自行处理计时与打点,专注业务校验即可。

Step 3:接入 Orchestrator(CRITICAL,最易遗漏)

文档反复强调:校验器和 Builder 都写完后,还必须把 Builder 注入并调用编排器,否则「Your entity won't sync without orchestrator wiring」。文档给出的接线模板:

@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,
      ],
    };
  }
}

在仓库中的对应实现是 workspace-migration-build-orchestrator.service.ts。接线时要做满三件事(对应文档 Checklist 的最后几条):① 在构造函数中注入新 Builder;② 在构建流程中调用其 build/validateAndBuild 并传入该实体的三组操作列表与全部 flat entity maps;③ 把返回的 actions 合并进最终返回的 actions 数组,同时聚合失败错误。缺任何一环,该实体的变更都不会进入迁移动作集,Runner 侧也就无动作可执行。

四种典型校验模式详解

文档归纳了 4 个可复用的校验模式,配合真实校验器逐一说明:

Pattern 1:必填字段校验

if (!isDefined(field) || field.trim() === '') {
  errors.push({ code: ..., message: ..., userFriendlyMessage: ... });
}

仓库中将此类检查抽成专用工具,如 validate-skill-required-properties.util.ts 中按字段逐项产出 FlatEntityValidationErrorflat-skill-validator.service.ts 的创建校验直接 push(...validateSkillRequiredProperties({ flatSkill }))

Pattern 2:唯一性校验(O(1) 索引查找)

const existing = optimisticMaps.byName[entity.name];
if (isDefined(existing) && existing.id !== entity.id) {
  errors.push({ ... });
}

注意 existing.id !== entity.id 这个排除自身判断:更新场景下实体本身已存在于 byName 索引中,不排除自己会误报重复。skill 名称唯一性校验 validate-skill-name-uniqueness.util.ts 即采用同一思路。

Pattern 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, ... });
  }
}

外键校验包含两级判断:父实体不存在(NOT_FOUND)、父实体已软删除(DELETED)。由于传入的是乐观映射表,这里校验的是「本批次执行完所有已接受动作之后的世界状态」,因此同批次内先删除再引用的场景也能被正确拦截。

Pattern 4:标准实体保护

if (entity.isCustom === false) {
  errors.push({ code: STANDARD_ENTITY_PROTECTED, ... });
  return errors; // Early return
}

系统预置实体不允许被第三方应用创建/修改/删除。模板以 isCustom === false 判断;仓库中 skill 实体的落地方式略有差异——flat-skill-validator.service.ts 通过 belongsToTwentyStandardApp + isCallerTwentyStandardApp(buildOptions) 判断「该 skill 属于 Twenty 标准应用、且调用方不是标准应用」,即 SKILL_IS_STANDARD 错误。这体现了同一模式下的实现变体:保护对象可以从实体字段改为应用归属维度,但「标准实体受保护」的语义与早返回(early return)写法保持一致。

完成度检查清单(Checklist)

文档要求进入 Step 4 前逐项确认,建议原样使用:

  • [ ] Validator service 已创建
  • [ ] Validator 从不抛异常(返回错误数组)
  • [ ] Validator 从不修改数据(使用乐观映射表)
  • [ ] 所有唯一性检查使用索引化映射表(O(1))
  • [ ] 必填字段校验已实现
  • [ ] 外键校验已实现
  • [ ] 标准实体保护已实现
  • [ ] Builder service 继承 WorkspaceEntityMigrationBuilderService
  • [ ] Builder 使用 universal 实体创建动作
  • [ ] Builder 已接入 Orchestrator(CRITICAL)
  • [ ] Builder 已在 Orchestrator 构造函数中注入
  • [ ] Builder 已在 buildWorkspaceMigration 流程中被调用
  • [ ] actions 已加入 Orchestrator 的返回语句

仓库中已有测试可参考命名与断言风格:校验器单元测试位于 validators/services/__tests__(如 flat-index-metadata-validator.service.spec.tsflat-row-level-permission-predicate-validator.service.spec.ts),校验工具函数测试位于 validators/utils/__tests__

下一步

Builder 与校验完成后,进入第 4 步为动作实现执行器:Syncable Entity: Runner & Actions (Step 4/6)。完整六步链路还可对照同一技能目录下的 syncable-entity-types-and-constants(Step 1)、syncable-entity-cache-and-transform(Step 2)、syncable-entity-integrationsyncable-entity-testing

登录后查看全文
热门项目推荐
相关项目推荐