关于 syncable-entity-builder-and-validation
面向在 NestJS 中实现可同步实体的开发者,介绍迁移操作构建器与业务规则验证。重点包括不抛异常的验证器、索引查找,以及将构建器接入编排器。
主要功能
- 01创建返回错误数组的验证器
- 02使用乐观实体映射进行校验
- 03通过索引映射执行查找
- 04构建迁移操作
- 05将构建器接入编排器
适用场景
- 01为实体添加必填字段与唯一性校验
- 02验证外键引用和标准实体保护
- 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 步(类型、缓存、转换)之后。在实现操作处理器之前必须完成。
快速开始
此步骤会创建:
- 校验器服务(业务逻辑校验)
- 构建器服务(操作创建)
- 编排器接线(关键 - 经常被遗忘!)
关键原则:
- 校验器从不抛出异常 - 返回错误数组
- 校验器从不修改数据 - 传入乐观实体映射
- 使用索引查找(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中调用 - 操作已添加到编排器返回语句中
下一步
构建器和校验完成后,继续: 可同步实体:运行器与操作(第 4/6 步)
完整工作流请参见 @creating-syncable-entity 规则。