Twenty 同步实体开发指南:业务规则校验器与迁移动作构建器(Step 3/6)
本文讲解 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 迁移体系全局约束:
- 校验器永远不抛异常(never throw)——一律返回错误数组,由上层统一聚合失败报告;
- 校验器永远不修改数据(never mutate)——只读「乐观实体映射表(optimistic entity maps)」做查询判断;
- 使用索引化查找(O(1))而非
Object.values().find()(O(n))。
需要说明的是:SKILL 文档以 myEntity 作为通用占位实体来讲解模板写法;在当前仓库中,这套模式已被大量真实元数据实体落地,对应源码位于 workspace-migration-builder 目录,例如 skill、agent、role、object、view 等 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 为例,FlatSkillValidatorService 的 validateFlatSkillCreation 方法通过 getEmptyFlatEntityValidationError(...) 初始化一个空的校验结果对象,随后把必填属性校验(validateSkillRequiredProperties)与名称唯一性校验(validateSkillNameUniqueness)的返回值 push 进 errors 数组,全程只读乐观映射表,最后 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」是按多个键(byId、byName、byUniversalIdentifier 等)预建索引的映射结构,直接下标访问即可命中候选实体;若退化成 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),其余流水线全部由基类统一编排:
- 变更矩阵计算:从
from(当前状态)与to(目标状态)两组 flat entity maps 出发,经flatEntityDeletedCreatedUpdatedMatrixDispatcher计算出 create/update/delete 三组差集; - 删除校验:逐个校验待删除实体(可按
shouldInferDeletionFromMissingEntities选项从「目标态缺失的实体」推断删除),通过后才从乐观映射表中真正移除; - 创建校验:先对自引用外键做拓扑排序(
topologicallySortUniversalFlatEntitiesForSelfReferentialFks,保证父实体先于子实体创建),再逐个调用校验;基类还内置了集中式校验——universalIdentifier必须是合法的小写 UUIDv4,且不能与当前映射表中已有实体冲突(validateUniversalIdentifier/validateUniversalIdentifierNotAlreadyInCurrentMetadataMaps); - 更新校验:对每个变更实体调用
validateFlatEntityUpdate,通过后合并出完整新实体并计算before/afterdiff,随动作一起输出; - 结果聚合:任一实体校验失败即累积进
allValidationResult,最终统一返回{ status: 'fail', errors }或{ status: 'success', actions }(actions 按 create/update/delete 分桶)。
基类还内建了可观测性:每个阶段(matrix-computation、deletion-validation、creation-validation、update-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 中按字段逐项产出 FlatEntityValidationError,flat-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.ts、flat-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。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00