Twenty 同步实体开发指南:业务规则校验器与迁移动作构建器(Step 3/6)
2026/9/7 9:14:51 网站建设 项目流程

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

【免费下载链接】twentyThe open alternative to Salesforce, designed for AI.项目地址: https://gitcode.com/GitHub_Trending/tw/twenty

本文讲解 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/afterdiff,随动作一起输出;
  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-integration 与 syncable-entity-testing。

【免费下载链接】twentyThe open alternative to Salesforce, designed for AI.项目地址: https://gitcode.com/GitHub_Trending/tw/twenty

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询