关于 objectstack-data
objectstack-data 是面向 ObjectStack 数据模型设计者的协议参考技能,涵盖对象、字段、关系、权限、生命周期及数据源等定义。它还说明字段条件规则和已有数据库的架构演进约束。
主要功能
- 01参考对象与字段定义规范
- 02说明关系、索引和验证规则
- 03涵盖权限控制与数据生命周期
- 04介绍数据源和联邦对象配置
- 05说明字段条件规则与架构演进
适用场景
- 01设计 ObjectStack 对象及字段模型
- 02配置数据访问权限和保留策略
- 03为字段定义条件显示或必填规则
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ objectstack-ai/ objectstack --skill objectstack-data安装后在 Claude Code 中输入/objectstack-data确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
evals/hooks-security-seeds-datasources.jsonREADME.mdobjects-fields-relationships.jsonreferences/seeds.md_index.mdlint-rules.mdexamples-objects.mddata-hooks.mdrules/datasources.mdfield-types.mdnaming.mdindexing.mdvalidation.mdsecurity.mdlifecycle.mdrelationships.md详细介绍
数据建模 — ObjectStack 数据协议
技能边界
| 需求 | 改用 |
|---|---|
| 查询、筛选或聚合记录 | objectstack-query |
| 定义 REST API 端点或身份验证 | objectstack-api |
| 构建视图、仪表板或应用 | objectstack-ui |
| 创建插件或注册服务 | objectstack-platform |
快速参考 — 详细规则
如需查看包含错误/正确示例的完整文档:
- 命名约定 — snake_case 规则、选项值、配置属性
- 字段类型 — 全部 49 种字段类型及其配置
- 关系 — lookup 与 master_detail、联结模式、删除行为
- 验证规则 — 全部验证类型、脚本反转、严重级别
- 索引策略 — btree/gin/gist/fulltext、复合索引、部分索引
- 数据生命周期与保留 —
lifecycle类别(record/audit/telemetry/transient/event)、保留/TTL/轮换/归档策略;❗ 仅追加对象必须声明一个类别(与下方的生命周期 钩子 不同) - 生命周期钩子 — 8 种生命周期事件、
handler与沙盒化body(ctx + capability 契约)、注册、规范模式 - 数据源与联邦 —
defineDatasource、外部/联邦对象(remoteName/columnMap)、自动连接门控、凭据;❌ 外部对象上不得设置field.columnName - 安全与访问控制 — 权限集、分配行、RLS 策略、
secret/requiredPermissions、tenancy、平台全局安全态势
核心概念
对象定义
对象是 ObjectStack 中的基本数据实体。它映射到数据库表,并自动提供 CRUD API。
必需属性:
| 属性 | 类型 | 命名约定 | 描述 |
|---|---|---|---|
name | string | snake_case | 不可变的机器标识符(/^[a-z_][a-z0-9_]*$/) |
fields | map | 键使用 snake_case | 字段定义 |
sharingModel | enum | 以下四种之一 | 组织范围内的默认记录可见性(OWD)。Zod 将其标记为可选,但若未显式定义 sharingModel,发布将被拒绝,并返回 422 lint 信封 security-owd-unset——未设置不代表做出了决定(维护者裁定,2026-08-13)。每个对象都必须显式定义该属性 |
sharingModel——四种规范值(ADR-0090 D4;已移除旧别名)。缺少该属性的自定义对象在运行时会解析为 private,但发布入口会先拒绝它:
| 值 | 读取 / 写入权限 |
|---|---|
private | 仅所有者(可通过共享规则、RLS 或 readScope/writeScope 扩大权限) |
public_read | 所有人可读取;所有者可写入 |
public_read_write | 所有人可读写 |
controlled_by_parent | 继承自主记录(master_detail 子记录) |
重要的可选属性:
| 属性 | 默认值 | 描述 |
|---|---|---|
label | 根据 name 自动生成 | 便于人阅读的单数标签 |
pluralLabel | — | 复数形式(例如,“Accounts”)——参考应用中的 31/31 个对象都设置了该属性;请与 label 一并定义 |
icon | — | 用于导航、列表标题和查找选择器的图标名称——参考应用中的 31/31 个对象都设置了该属性 |
highlightFields | 推导得出 | 有序字段键,用作记录的精简展示:相关列表在父记录详情页中呈现的列,以及查找选择器显示的内容。在子对象上声明此属性(参见关系) |
namespace | — | 不是架构键——ObjectSchema.create() 会拒绝未知键,因此定义此属性会导致构建错误。请改为将前缀直接嵌入 name(例如 name: 'crm_account') |
datasource | 'default' | 虚拟化数据的目标数据源 ID |
nameField | 推导得出(例如 'name'/'title') | 规范的记录标题字段——用作记录显示名称的存储字段。使用单个文本 / 电子邮件字段,或用于组合标题的公式字段(returnType: 'text') |
displayNameField | — | nameField 的已弃用别名(仍会作为回退选项使用) |
titleFormat | — | 已弃用但未移除(ADR-0079)——仅用于渲染:服务端无法返回或查询该属性。请使用 nameField(优先级更高);如需组合标题,请将 returnType: 'text' 的公式字段指定为 nameField |
enable | — | 功能标志(trackHistory、searchable、apiEnabled 等) |
fieldGroups | — | 表单 / 详情页的有序逻辑字段组列表(参见字段组) |
lifecycle | record 语义(永久) | 数据保留 / 轮换 / 归档约定。仅追加、高写入速率的对象必须设置此属性——telemetry/transient/event/audit 类必须声明边界策略,否则解析会失败(参见数据生命周期与保留) |
对象能力(enable)
按对象切换系统行为:
| 标记 | 默认值 | 用途 |
|---|---|---|
trackHistory | false | 字段级审计跟踪 |
searchable | true | 为记录编制索引,供全局搜索使用 |
apiEnabled | true | 通过自动生成的 REST + MCP API 暴露 |
apiMethods | all | 为六种基本操作(get、list、create、update、delete、bulk)设定白名单;派生动词(search/export/upsert/…)会自动遵循 |
files | false | 附件和文档管理 |
feeds | true | 社交动态、评论、提及 — 选择退出:显式设为 false 会隐藏动态界面并拒绝新评论 |
activities | true | 活动时间线(sys_activity 中的 CRUD 镜像)— 选择退出:显式设为 false 会停止镜像并隐藏时间线 |
clone | true | 深度克隆记录 |
搜索字段(searchableFields)
对象上的 searchableFields 是 $search 扫描的规范字段集(ADR-0061)。
若不设置,搜索会自动默认为 nameField 加上对象的短文本和枚举列(text / email / phone / url / autonumber /
textarea / markdown / select / status);设置后则会明确固定字段集。视图可以缩小字段集,但不能扩大。
⛔ 搜索关联记录的标题 — 始终镜像该值
$search 扫描被查询对象自身的列。带点号的路径永远不是搜索目标:与 fields / sort / filters 不同,搜索轴不会解析遍历路径;searchableFields(或 $searchFields 覆盖项)中的 project_id.name 会被拒绝,而不是静默丢弃。
这是唯一的规定 — 每次都要给出。 若要按关联记录的标题搜索,请将该标题复制到此对象上的存储字段,并将该字段声明为可搜索。按项目名称搜索任务列表:
// `project_name` is a stored, denormalized mirror of the parent's title.
{
name: 'task',
enable: { searchable: true },
fields: {
name: { type: 'text', required: true },
project_id: { type: 'lookup', reference: 'project' },
project_name: { type: 'text', label: 'Project Name' }, // ← the mirror
},
searchableFields: ['name', 'project_name'],
}
?search=apollo 会展开为 name $contains 'apollo' OR project_name $contains 'apollo' — 在所有驱动程序上执行单表扫描,无需遍历。(如果对象未声明 searchableFields,text 镜像字段也会自动纳入默认字段集。)
❌ 绝不要镜像到 formula 字段。 公式字段是虚拟的 — 没有驱动程序会为它实体化列,因此针对它的 $contains 谓词无从扫描(SQL 驱动程序会生成针对不存在列的 WHERE 条件)。CEL 也只读取此记录自身的字段(record.<field>),因此公式字段本身无法获取关联标题。此错误会被明确拒绝,而非静默处理:任何 searchableFields 中的 formula 条目(包括对象自身的字段集)都会导致 os validate 报错(searchable-field-unsearchable),请求中指定该字段则会返回 400 INVALID_FIELD。过去会清空这两者,之后却始终匹配不到。
镜像维护是需要权衡的地方 — 镜像是反规范化数据,其新鲜程度取决于写入方式。请覆盖两种写入路径:
| 时机 | 维护镜像的方式 |
|---|---|
| 创建任务,或将任务重新关联到另一个项目 | 在 task 上使用 beforeInsert / beforeUpdate 钩子 — 读取传入 project_id 对应父记录的 name,并写入 project_name |
| 重命名项目 | 在 project 上使用 afterUpdate 钩子 — 为该项目的任务重新写入 project_name |
通过绕过钩子的路径写入的行(批量导入、直接 SQL)需要一次性回填。请参阅生命周期钩子。
两种写法都会被明确拒绝:os validate 会报告
searchable-field-unknown,请求中指定带点号的路径则会返回 400 INVALID_FIELD。每条消息都会附带相应的处理指引。
跨对象搜索路径是出于设计而被拒绝,并非尚待实现。不要为此另设按项目划分的约定 — 镜像字段就是解决方案。
→ 原文已移至 references/examples-objects.md § 字段组(MVP)。
条件字段规则
当规则属于数据模型,且应在所有编辑字段的场景中生效时,应将条件 UI/数据录入规则放在字段定义上:默认表单、通过 Studio 编写的表单、内联主从网格、公开表单,以及由 API 支持的写入。
import { P } from '@objectstack/spec';
import { ObjectSchema, Field } from '@objectstack/spec/data';
export const Invoice = ObjectSchema.create({
name: 'invoice',
sharingModel: 'private',
fields: {
status: Field.select({
options: [
{ label: 'Draft', value: 'draft' },
{ label: 'Sent', value: 'sent' },
{ label: 'Paid', value: 'paid' },
{ label: 'Void', value: 'void' },
],
}),
paid_at: Field.datetime({
visibleWhen: P`record.status == 'paid'`,
requiredWhen: P`record.status == 'paid'`,
}),
locked_total: Field.currency({
readonlyWhen: P`record.status == 'paid'`,
}),
},
});
- 使用
visibleWhen在 ObjectUI 表单中隐藏无关字段。 - 使用
readonlyWhen锁定特定状态下的字段;当谓词为TRUE时,ObjectQL 写入路径会忽略传入的更改。 readonly: true约束的是终端用户界面,不约束受信任的系统写入方。非系统写入(REST/UI,以及任何runAs:'user'流程——默认值)会从所有非系统写入中剥离该字段;写入会报告成功,但该值不会生效。系统上下文写入——runAs:'system'流程、系统钩子、种子数据、导入、迁移——不受此限制,确实会写入该字段。因此,“用户无法编辑此字段,但自动化可以维护它”这种模式,应通过声明字段为readonly,并且使用runAs:'system'运行维护流程来实现(参见 objectstack-automation),而不是移除readonly。从runAs:'user'的update_record节点写入readonly字段属于构建时错误(os validate/os build)。- 使用
requiredWhen设置条件必填;ObjectQL 验证器会在提交时强制执行。conditionalRequired别名已在 protocol 17 中移除——发出该别名会导致解析错误。 - **根据意图选择——不变量还是转换门槛。**对每条已存储记录都成立的事实属于不变量:使用
validations[]script规则。违反该规则的行在修复前,任何编辑都会被拒绝。转换条件(“记录达到paid状态后必填”)则使用requiredWhen/字段边界,它们判断的是写入,而不是已存储的行。“在 X 条件下必填”听起来像不变量,但它并不是;将不变量写成门槛,永远无法让它自行生效。 - 对于内联
master_detail网格,谓词会针对子行的record逐行求值,因此行项目规则应放在子字段上。 - 对于复杂谓词,加载 objectstack-formula 并通过
P\...`发出 CEL;不要使用 Salesforce 风格的AND、IN (...)或{field}` 语法。
→ 已原样移至 references/examples-objects.md § 快速入门模板。
现有数据库的架构演进
元数据→数据库同步仅支持增量添加:启动时会创建新表和新列,但绝不会修改或删除已有列。对已有数据的对象进行非增量变更,会导致其与物理架构悄然不一致;写入时以数据库列为准:
| 变更 | 重启时的现有数据库行为 |
|---|---|
| 添加对象/字段/索引 | ✅ 自动应用(增量添加) |
移除 storage: { notNull: true }(放宽 NOT NULL) | ⚠️ 绝不自动应用——此差异的 category: 'needs_confirm'(relax_not_null),需通过 os migrate apply 确认。仅放宽 required 不会改变任何列 |
将 unique 的范围从全局改为按租户 | 开发环境会自动修复;否则使用 os migrate apply(replace_unique_index) |
| 类型/长度变更、删除字段、重命名 | 使用 os migrate apply(删除/收紧约束时使用 --allow-destructive) |
| 移除声明的索引,或更改其列 | 使用 os migrate apply(删除索引或将其重建为 UNIQUE 时使用 --allow-destructive) |
required 不是控制 NOT NULL 的开关(ADR-0113)。 required 是写入契约——如果插入时未提供该值,引擎会拒绝操作——它与列本身无关。物理约束需要通过单独的显式选项 storage: { notNull: true } 启用,差异检测也会检查此项。因此,在已部署的对象上收紧 required 是安全的(现有空值行仍可读取);而在存在空值行时声明 storage.notNull 则属于破坏性迁移。两者不能与 requiredWhen 组合使用——条件契约不能成为无条件的列约束。
一个明显的迹象:/meta 报告字段为可选(没有 required,也没有 storage.notNull),但省略该字段的写入会失败,并返回原始驱动错误,而不是明确的 400 验证错误——这说明列的 NOT NULL 约束已过时,而不是验证器有问题。运行 os migrate plan 预览,再运行 os migrate apply 进行协调;或者声明 storage: { notNull: true },以确认该列约束。CLI 详情请参阅 objectstack-platform。
常见模式
命名规则摘要
| 上下文 | 约定 | 示例 |
|---|---|---|
对象 name | snake_case | project_task |
| 字段键 | snake_case | first_name、due_date |
| 架构属性 | camelCase | maxLength、lookupFilters |
选项 value | 小写 | in_progress |
错误/正确示例请参阅 rules/naming.md。
字段类型选择
共有 49 种类型。快速分类:
- 文本:
text、textarea、email、url、phone、password、markdown、html、richtext— ⚠️ 通用对象上的password以明文形式静态存储(读取时会遮蔽,绝不哈希);凭据请优先使用secret - 密钥:
secret— 通过已注册的ICryptoProvider加密静态存储的凭据(可逆)(数据库密码、API 密钥、令牌);读取时会遮蔽,并采用失败关闭策略(ADR-0100)。凭据的推荐类型 - 数字:
number、currency、percent - 日期/时间:
date、datetime、time - 逻辑:
boolean、toggle - 选择:
select、multiselect、radio、checkboxes - 关系:
lookup、master_detail、tree、user—user是人员选择器(专用于sys_user的查找字段;存储方式与lookup相同) - 媒体:
image、file、avatar、video、audio - 计算:
formula、summary、autonumber—formula字段使用expression中的 CEL 表达式(从@objectstack/spec使用F\...``);请参阅 objectstack-formula 技能 - 嵌入式:
composite、repeater、record— 存储在父行上的嵌入式 JSON 子对象(无独立表/外键) - 增强:
location、address、code、json、color、rating、slider、signature、qrcode、progress、tags、vector
完整参考请参阅 rules/field-types.md。
关系模式
| 模式 | 实现方式 |
|---|---|
| 一对多(独立) | 子对象上的 lookup 字段 |
| 一对多(拥有) | 子对象上的 master_detail 字段 |
| 多对多(简单) | 多值 lookup(multiple: true)——存储 ID 的数组列 |
| 多对多(带属性) | 使用两个 lookup 字段的关联对象 |
| 层级关系 | tree 字段(自引用) |
详细示例请参阅 rules/relationships.md。
multiple: true的 lookup ≠ 关联对象。 多值 lookup ({ type: 'lookup', reference: 'x', multiple: true })在记录中以 ID 数组的形式存储和读取—— 按位置引用元素(在流程值中使用{record.tags.0})。它不是关联表。只有当关系本身带有属性(角色、添加时间等)时,才使用关联对象(两个 lookup)。
验证模式
⚠️ 脚本验证的逻辑是反向的:当表达式为 true 时,验证失败。
执行插入时,载荷中省略的可选字段在验证谓词中读取为
null,因此record.due_date == null匹配省略字段的情况,效果与显式设为null相同。(更新时,会使用先前记录中的值。)
验证类型的完整集合(ValidationRuleSchema 判别字段):
script— 公式表达式(逻辑反向)state_machine— 合法的状态转换format— 正则表达式或内置格式cross_field— 比较多个字段的值json_schema— 根据 JSON Schema 验证 JSON 字段conditional— 仅当谓词成立时才应用嵌套规则
没有
unique验证类型(已从规范中移除)。 使用唯一索引强制唯一性(包括复合唯一性),并注明其范围(ADR-0120):indexes: [{ fields: ['department', 'email'], unique: 'organization' }]。
所有类型和示例请参阅 rules/validation.md。
索引模式
声明中仅有 fields / unique / name。 unique
默认为 false;如果这就是你需要的值,可以省略。
indexes: [
{ fields: ['status', 'created_at'] }, // composite
{ fields: ['email'], unique: 'organization' }, // unique per organization
{ fields: ['hostname'], unique: 'global' }, // unique platform-wide
{ name: 'idx_acct_status', fields: ['status'] }, // custom name
]
type和partial已在协议 17 中弃用:没有任何驱动读取过它们,因此编写type不会选择访问方法,而编写partial会生成完整索引并丢弃谓词。现在两者都会导致tsc错误和解析错误;os migrate meta --from 16会列出需要修改的内容;--write会应用能够确认的修改,其余部分需手动处理。
唯一索引必须注明范围——
'organization'(每个组织一个持有者,NULL 安全)或'global'(整个安装环境中一个持有者)。 对于声明的索引,单独写unique: true是'global'的弃用写法:它看起来像是“每个组织”,实际效果却相反,因此os lint会发出警告,协议 18 会拒绝它。对于字段,unique: true表示'organization',仍然有效。
有关复合索引、唯一性范围,以及如何在数据库层构建 partial / gin / gist 索引,请参阅 rules/indexing.md。
→ 内容原样移至 references/examples-objects.md § 生命周期钩子(钩子参考文档见 references/data-hooks.md)。
对象扩展模型
要向不归你所有的对象添加字段/验证/索引,请编写
defineObjectExtension({ extend, fields, priority }),并在
defineStack({ objectExtensions: [...] }) 中注册。priority 用于设置合并顺序(默认值
为 200,范围为 0–999);扩展可以添加内容,但不能移除内容。⛔ 这与对象级别的 ownership 不同(记录所有权枚举
'user' | 'business_unit' | 'org' | 'none')。架构:
node_modules/@objectstack/spec/src/data/object.zod.ts(ObjectExtensionSchema)。
安全与访问控制
对象级访问控制定义在权限集中,而不是对象架构中。对象级别没有 permissions 键(也没有 hooks 键)——
ObjectSchema.create() 会将这两个键作为未知键拒绝。
完整规则:安全与访问控制。
访问深度(scope-depth)——ERP 中“查看我的单位/我的单位及下级”轴
对于所有者范围(private)对象,权限集中的对象级授权可以携带
readScope / writeScope,以声明式方式扩大所有者匹配范围,而无需手写 RLS 策略(ADR-0057 D1):own(默认) · own_and_reports ·
unit · unit_and_below · org。它会在请求时解析为
owner_id IN (…) 集合,并像 RLS 一样以 AND 方式注入。⚠️ 开源核心边界(ADR-0016):
开源版仅支持 own 和 org——另外三个基于层级的选项需要付费的 @objectstack/security-enterprise 插件;没有该插件时会默认拒绝并回退到 own,且除非授权声明
requires: ['hierarchy-security'],否则 defineStack 会报错。架构:PermissionSetSchema.objects.*。
→ 已原样移至 references/examples-objects.md § 元数据保护(protection)。
→ 种子数据与固定数据(defineSeed())已原样移至 references/seeds.md(快速入门 · Seed 字段 · 导入模式 · externalId 选择 · 关系引用 · 动态值(CEL)· 种子最佳实践)。
→ lint 与生成质量已原样移至 references/lint-rules.md。
验证你的工作
编写或编辑任何 *.object.ts / *.seed.ts 后,报告完成前请运行作者时校验门禁:
os validate # Zod schema + CEL predicates (record.<field> existence) + bindings
# or: os build # the same gates, plus emits dist/
它会捕获那些原本会在运行时静默失败的问题:在
requiredWhen / readonlyWhen / visibleWhen、验证规则、公式或
行级安全/共享谓词中使用裸字段引用(done 而不是 record.done),导致其计算结果为 null,永远不会触发。os lint 是一个单独的
检查步骤,另外还会根据本技能中的约定检查数据模型(关系、主从关系、汇总)——也请运行它,但它不能
替代 os validate。(提醒:连续运行两次 os build 且源代码没有更改时,生成的字节必须完全相同——参见上面的确定性门禁。)在脚手架生成的项目中,门禁命令为 npm run validate。
参考
完整的 Zod 架构列表(附单行说明)请参阅 references/_index.md——其中包含指向
node_modules/@objectstack/spec/src/ 的引用。请务必Read源代码以确认准确的字段结构;不要凭记忆推断属性名称。