Twenty App 数据模型开发指南:对象、字段、关系与角色权限建模(基于 twenty-codex-plugin 开发参考)
本指南以 data-model.md 为核心,系统讲解在 Twenty 应用中如何定义对象(Object)、字段(Field)、关系(Relation)、角色(Role)与权限,并给出"建模即可用"的完整落地模式。读完你将掌握 defineObject / defineField / defineRole 的规范写法、Select 字段的取值约定、最小可用对象所需的 5 类注册文件,以及如何用 yarn twenty CLI 校验并同步数据模型。
数据模型在 Twenty 应用中的作用域
在 Twenty 的应用开发模型里,对象和字段定义的是用户将要创建、查看、搜索和自动化的记录结构,是视图(view)、导航(navigation)、记录页布局(page layout)、工作流(workflow)与逻辑函数(logic function)共同作用的基础。数据模型设计的好坏,直接决定后续过滤、视图与自动化功能是否顺滑。
本参考面向的场景是:你在开发一个 Twenty App(例如 rich-app 夹具 或 hello-world 示例),需要为某个用户工作流新增对象、字段和权限。配套的周边主题可参考同一开发参考目录下的 app-structure.md(文件组织与实体创建)、layout.md(视图/导航/页面布局)、logic.md(逻辑函数与副作用)、tests.md(测试约定)等文档。
对象与字段:先建模工作流,再加最小字段集
设计对象与字段时,首要原则是先梳理用户的工作流,再添加让该工作流可用所必需的最小字段集。多余的字段不会让应用更强,只会抬高配置与维护成本。具体准则如下:
- 优先使用脚手架生成的应用实体模式:新增对象或字段时优先执行
yarn twenty dev:add,从生成模板中获得一致的配置形状(详见 app-structure.md 对交互式生成的描述)。 - 使用清晰、映射用户语言的对象名与字段名:例如对象
postCard(labelPost card)比抽象的内部命名更容易被业务方理解。 - 仅在用户需要跨记录导航或跨记录报表时才添加关系:关系不是数据建模的"默认动作",而是有明确需求才引入的结构。
- 避免复制核心 Twenty 对象上已有的数据:例如时间线动态(
timelineActivity)这类标准对象可直接复用,不应在你的应用对象里重复造一份。 - 保持字段类型足够具体,以支撑过滤、视图和自动化。从 rich-app 的真实对象可以看到,一个"收件人"被建模为
EMAILS(邮件)、ADDRESS(地址)等专用类型,而不是塞进一个宽泛的文本字段。
关于字段类型枚举本身:twenty-sdk/define 导出的 FieldType 实际是 twenty-shared/types 中 FieldMetadataType 的再导出(见 field-type.ts)。仓库应用中实际出现过的类型包括 TEXT、SELECT、FULL_NAME、ADDRESS、EMAILS、DATE_TIME、RELATION 等,选取时应与字段的语义一一对应。
让对象"建模即可用":可用的对象模式
当用户要求新增一个对象、且没有明确限定只做 schema 时,不应只交付一个孤立的 defineObject,而应把数据模型工作与最小布局/导航表面配套完成,让对象真正进入应用可见、可操作:
注册文件(相对 src/) |
作用 |
|---|---|
objects/<name>.ts |
定义对象及其字段(数据模型的载体) |
views/all-<plural>.ts |
对象的默认表格视图(记录列表入口) |
navigation-menu-items/<name>.ts |
侧边导航中的对象入口 |
page-layouts/<name>-record-page-layout.ts |
记录页布局(展示记录当前状态与下一步动作) |
views/<name>-record-page-fields.ts |
记录页上的字段小部件视图 |
其中视图、导航、页面布局的具体细节,原文档明确指引到 layout.md 处理;需要承载整页自定义 UI 时则参考 standalone-pages.md。
对象颜色属于导航项,而不是对象本身
defineObject() 上只支持 icon,不支持定义对象颜色。颜色的正确归宿是对象导航项。原因在于导航项才是用户"看到颜色"的表面,把颜色放在对象定义上会造成语义错位。导航菜单项示例:
import {
defineNavigationMenuItem,
NavigationMenuItemType,
} from 'twenty-sdk/define';
export default defineNavigationMenuItem({
universalIdentifier: '<uuid>',
name: '<name>',
icon: '<IconName>',
color: '<color>',
position: 0,
type: NavigationMenuItemType.OBJECT,
targetObjectUniversalIdentifier: '<object-uuid>',
});
对照仓库实现,post-cards 导航项 与文档示例完全一致:通过 type: NavigationMenuItemType.OBJECT 声明导航项类型,用 targetObjectUniversalIdentifier 指向对象(此处直接复用从 post-card.object.ts 导出的 POST_CARD_UNIVERSAL_IDENTIFIER 常量)。这暴露了一个关键实践——对象的 universalIdentifier 应导出为具名常量,供视图、导航、字段、角色等文件以 import 方式引用,避免多个文件手写 UUID 导致不一致。
最小对象示例:一份可复制的 defineObject
下面是原文档给出的最小对象模板。一个对象至少需要一个"标识字段"(labelIdentifierFieldMetadataUniversalIdentifier 指向的字段),它负责在列表、引用与搜索中代表该记录:
import { defineObject, FieldType } from 'twenty-sdk/define';
export const OBJECT_UNIVERSAL_IDENTIFIER = '<uuid>';
export const NAME_FIELD_UNIVERSAL_IDENTIFIER = '<uuid>';
export default defineObject({
universalIdentifier: OBJECT_UNIVERSAL_IDENTIFIER,
nameSingular: '<name>',
namePlural: '<names>',
labelSingular: '<Name>',
labelPlural: '<Names>',
icon: '<IconName>',
labelIdentifierFieldMetadataUniversalIdentifier:
NAME_FIELD_UNIVERSAL_IDENTIFIER,
fields: [
{
universalIdentifier: NAME_FIELD_UNIVERSAL_IDENTIFIER,
type: FieldType.TEXT,
name: 'name',
label: 'Name',
icon: 'IconAbc',
},
],
});
字段级别的细节可以从仓库的富对象中验证。以 post-card.object.ts 为例:
- 每个字段拥有独立的
universalIdentifier(如CONTENT_FIELD_UNIVERSAL_IDENTIFIER、STATUS_FIELD_UNIVERSAL_IDENTIFIER),并作为具名常量导出;对象级与字段级 ID 彼此独立、互不混用。 - 复合类型直接内嵌建模:如
FieldType.FULL_NAME的recipientName、FieldType.ADDRESS的recipientAddress,无需再展开成多个子字段。 - 可空/可选字段显式声明:
isNullable: true配合defaultValue: null(例如deliveredAt的DATE_TIME字段)。 - 可选
description为对象与字段补充业务语义,便于界面提示与后续维护。
对象级同样支持 description(如 rich-app 中 'A person or organization that receives post cards'),它并不改变行为,但能显著提升团队协作与 AI 辅助开发时的可理解性。
Select 字段:取值必须为大写蛇形
对于单选(SELECT)与多选字段,选项的 value 字符串必须是 UPPER_SNAKE_CASE(如 PLANNED、IN_BUILD)。label 是展示给用户的文案(如 Planned、In build),value 是存储与逻辑引用的稳定键,二者必须分开。示例:
{
type: FieldType.SELECT,
name: 'status',
label: 'Status',
defaultValue: "'PLANNED'",
options: [
{ position: 0, label: 'Planned', value: 'PLANNED', color: 'sky' },
{ position: 1, label: 'In build', value: 'IN_BUILD', color: 'orange' },
],
}
最容易踩的坑是默认值写法:Select 字段的 defaultValue 必须是带引号的字符串表达式,即 "'PLANNED'"(单引号是表达式语法的一部分),而不是裸字符串 'planned' 或 "PLANNED"。defaultValue 本质上是一个取值表达式,因此要求值与选项 value 完全一致且为大写蛇形。
仓库实现给出了更完整的生产级写法。post-card.object.ts 用 TypeScript enum 集中定义状态常量,再以模板字符串注入默认值,杜绝手写字符串导致的拼写漂移:
enum PostCardStatus {
DRAFT = 'DRAFT',
SENT = 'SENT',
DELIVERED = 'DELIVERED',
}
// ...
defaultValue: `'${PostCardStatus.DRAFT}'`,
options: [
{ value: PostCardStatus.DRAFT, label: 'Draft', position: 0, color: 'gray' },
{ value: PostCardStatus.SENT, label: 'Sent', position: 1, color: 'orange' },
// ...
],
其中 position 决定选项顺序,color 取自颜色语义值(gray/orange/green/red 等)。选项的 id 可省略——从源码注释("No id — exercises addMissingFieldOptionIds")可以推断,省略 id 的选项会由对象同步逻辑补齐缺失的选项 ID。状态机语义靠不同颜色区分(如 DELIVERED 用绿、LOST 用红),这样无需编码即可让用户扫一眼看懂记录状态。
关系字段:仅在需要跨记录导航与汇总时添加
原文档把关系定位为"需求驱动"而非"默认行为":只有当用户需要跨记录导航(从一张明信片看到它的收件人)或做跨对象报表时,才添加关系。判断标准是:这段数据能否在某个单一对象内完整表达?如果能,就不要引入第二个对象与关系。
当确需关系时,rich-app 给出了两种关系的落地路径:
- 普通对象上的关系字段通过
defineField+FieldType.RELATION表达;更多关系方向与命名可参考 recipient.object.ts 以及对象间的field文件。 - 多对多(含连接对象) 需要声明
RelationType.ONE_TO_MANY并通过junctionTargetFieldUniversalIdentifier指定连接对象另一侧字段。见 post-card-recipients-on-post-card.field.ts:
export default defineField({
universalIdentifier: POST_CARD_RECIPIENTS_ON_POST_CARD_ID,
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
type: FieldType.RELATION,
name: 'postCardRecipients',
label: 'Post Card Recipients',
relationTargetObjectMetadataUniversalIdentifier:
POST_CARD_RECIPIENT_UNIVERSAL_IDENTIFIER,
relationTargetFieldMetadataUniversalIdentifier:
POST_CARD_ON_POST_CARD_RECIPIENT_ID,
universalSettings: {
relationType: RelationType.ONE_TO_MANY,
junctionTargetFieldUniversalIdentifier: RECIPIENT_ON_POST_CARD_RECIPIENT_ID,
},
});
注意关系两侧(父侧 postCardRecipients 与子侧反向字段)各自拥有独立的 universalIdentifier 常量,且互相通过 relationTargetFieldMetadataUniversalIdentifier 配对。这也是为什么 schema-only 改动极少见——一旦引入关系,就需要同步设计视图与记录页,否则用户无法实际"跨越"对象。
角色与权限:按职责而非实现便利建模
角色的划分应当匹配"操作职责"(operational responsibility),而不是"实现上的便利"。也就是说,角色反映的是"谁在真实业务中负责做什么",而不是"这段代码刚好需要什么权限"。添加权限时遵循三条原则:
- 授予最小的有用范围(smallest useful scope):能只读就不给写,能限定到对象就不放开到全量。
- 把敏感对象和字段挡在宽泛角色之外:例如全员角色不应默认可读高敏字段的值。
- 授予写权限前,检查应用是否通过逻辑函数引入了副作用:逻辑函数(logic function)会在特定时机执行代码,若某角色可写某对象、该对象上又挂了有副作用的逻辑函数,那么"写权限"实际会放大成"触发副作用"的能力。这一点要结合 logic.md 一起评估。
仓库中的角色实现展示了权限的三种粒度。系统级"万能"角色见 root.role.ts:通过 canReadAllObjectRecords、canUpdateAllSettings、canBeAssignedToUsers 等开关整体放权;而真正遵循最小权限原则的示例是 default-function.role.ts:
export default defineRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: 'Default function role',
description: 'Default role for function Twenty client',
canReadAllObjectRecords: false,
canUpdateAllObjectRecords: false,
// 不可赋给用户/Agent/API Key,仅供逻辑函数内部客户端使用
canBeAssignedToAgents: false,
canBeAssignedToUsers: false,
canBeAssignedToApiKeys: false,
objectPermissions: [
{
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
},
{
// 复用标准对象,而非复制其数据
objectUniversalIdentifier:
STANDARD_OBJECTS.timelineActivity.universalIdentifier,
canReadObjectRecords: true,
canUpdateObjectRecords: true,
},
],
fieldPermissions: [
{
objectUniversalIdentifier: POST_CARD_UNIVERSAL_IDENTIFIER,
fieldUniversalIdentifier: CONTENT_FIELD_UNIVERSAL_IDENTIFIER,
canReadFieldValue: false,
canUpdateFieldValue: false,
},
],
permissionFlagUniversalIdentifiers: [SystemPermissionFlag.APPLICATIONS],
});
这份源码把原文档的三条原则落实为可执行结构:
- 对象级权限
objectPermissions按objectUniversalIdentifier逐对象指定canReadObjectRecords/canUpdateObjectRecords/canSoftDeleteObjectRecords/canDestroyObjectRecords,代替全局放开。 - 字段级权限
fieldPermissions精确到"某对象的某字段"上控制canReadFieldValue/canUpdateFieldValue—— 这正是"敏感字段不进入宽泛角色"的实现手段。 - 角色可赋给谁由
canBeAssignedToAgents/canBeAssignedToUsers/canBeAssignedToApiKeys控制,另有SystemPermissionFlag级别的功能开关。将"仅供逻辑函数客户端使用"的角色设为三者皆不可赋,可从源头防止权限被误扩散到交互用户。
顺带一提,STANDARD_OBJECTS 来自 twenty-shared/metadata,它印证了"避免重复核心 Twenty 对象数据"的准则——标准对象(如 timelineActivity)可以直接以权限对象身份参与你的角色配置。
验证:改完数据模型后如何确认可用
数据模型改动完成后,需要运行应用,验证对象、字段和角色出现在用户将要管理与使用它们的地方:
- 对象是否出现在导航与对象列表中,字段是否在表格视图、记录页布局与字段小部件中可见;
- 角色是否能在预期的位置被分配给目标主体,权限是否如配置般生效;
- 不要停留在"代码能编译"层面——用户可感知的路径(导航 → 视图 → 记录详情)必须真实可达。视图与记录页路径的验证细则见 layout.md。
在进入运行验证之前,一次性的静态校验是低成本保障。按 app-structure.md 的约定,全部编辑完成后只跑一轮(不要在每步编辑后反复执行):
yarn twenty dev:typecheck # 校验生成的类型
yarn lint # 本地 lint 规则
yarn twenty apply # 构建应用并把实体定义推送到活动 remote
yarn twenty apply 会构建应用并把实体定义同步到活动 remote;任一对象、字段或角色定义非法,同步都会报告错误。若同步或 remote 出现异常,转由管理指南处理,见 cli-and-sync.md。
小结
Twenty 应用的数据模型不是孤立的 schema 文件,而是一套"对象 → 字段 → 视图/导航/页面布局 → 角色权限"的完整注册体系:
- 先建模用户工作流,只添加让工作流可用的最小字段集,避免复制核心对象已有数据;
- 新增对象默认做到"建模即可用",补齐表格视图、导航项、记录页布局与字段小部件这 5 类文件;对象颜色放在导航项而非对象定义上;
- Select 选项
value统一为大写蛇形,defaultValue必须写成"'PLANNED'"形式的带引号表达式; - 关系仅在需要跨记录导航或汇总时引入,多对多通过连接对象与
RelationType.ONE_TO_MANY配对实现; - 角色匹配业务职责而非实现便利,优先使用
objectPermissions+fieldPermissions的最小作用域,并在授予写权限前评估逻辑函数副作用。
写完定义后运行应用、并结合 dev:typecheck / lint / apply 完成一轮验证,数据模型即可安全地进入视图布局(layout.md)、逻辑函数(logic.md)与测试(tests.md)等后续环节。
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 StartedRust0627
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