关于 objectstack-formula
objectstack-formula 是供 ObjectStack 元数据编写者使用的表达式参考技能,规范以 CEL 编写计算值和布尔谓词,并说明 cron 与 template 方言。它着重规定表达式格式、空值处理及可用标准库函数。
主要功能
- 01统一使用 CEL 编写公式与谓词
- 02说明 cel、cron、template 方言
- 03规范表达式元数据封装格式
- 04提供日期、数字与字符串函数参考
- 05列出空值和谓词编写规则
适用场景
- 01编写字段计算公式和条件谓词
- 02为元数据创建动态种子值
- 03检查 CEL 空值处理及表达式错误
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ objectstack-ai/ objectstack --skill objectstack-formula安装后在 Claude Code 中输入/objectstack-formula确认已加载。
附带文件references/_index.mdevals/cel-predicates-formulas.json
详细介绍
表达式(CEL)—— ObjectStack 公式协议
ObjectStack 在所有需要计算或布尔谓词的领域中统一使用一种表达式语言:CEL(Google Common Expression Language)。本技能是 AI 作者编写公式/条件/谓词/动态种子元数据的权威参考。
谓词/公式必须是裸 CEL——绝不要用
{…}花括号包裹字段引用。 作者最常犯的错误是写出{record.rating} >= 4这样的条件:在 CEL 中,{…}是映射字面量,因此会导致解析错误。应写成裸 CEL:record.rating >= 4。花括号仅用于{{ … }}文本模板(参见 Cron 和模板相关位置)。
技能边界
| 需求 | 改用 |
|---|---|
定义 type: 'formula' 字段 | objectstack-data(并通过 F\...`` 嵌入 CEL) |
| 定义种子记录 | objectstack-data(使用 cel\...`` 表示动态日期) |
| 编写流程/自动化步骤 | objectstack-automation(为 condition 使用 P\...``) |
| 编写 L2 钩子主体(TS 代码) | objectstack-data |
| Cron 计划 | objectstack-automation |
| SQL 片段 | 驱动程序原生支持;不纳入统一的表达式注册表 |
核心约定
元数据中的所有表达式都使用同一种封装结构——dialect 加 source,并可选包含 ast 和 meta——在 shared/expression.zod.ts 中声明为 Expression / ExpressionInput。已注册三种方言:
| 方言 | 引擎 | 用途 | 辅助函数 | 示例 |
|---|---|---|---|---|
cel | @marcbachmann/cel-js | 计算值 + 布尔谓词 | cel`...` / F`...` / P`...` | cel`record.amount * 1.1` |
cron | 内置验证器 | 周期性计划 | cron`...` | cron`0 6 * * MON` |
template | 内置插值器 | {{path}} 文本插值(通知/提示词/标题) | tmpl`...` | tmpl`Hello {{record.first_name}}` |
不存在 js 方言——它已被弃用。过程式 JavaScript 属于 L2 ScriptBody { language: 'js' } 编写接口(钩子主体、映射转换——参见 objectstack-data),而不是一种表达式方言。
**AI 作者:**生成用于元数据的结构化输出 JSON 时,始终输出完整封装
{ dialect, source }——不要只输出字符串。
CEL 语法速查表
| 概念 | CEL |
|---|---|
| 当前记录字段 | record.first_name |
| 上一条记录(更新钩子、验证规则) | previous.status — §5 |
| 主从明细行项目的表头 | parent.status — 单元格 readonlyWhen / requiredWhen |
| 元数据编辑表单行 | data — 正在编辑的行,也包括重复器行 |
| 钩子输入载荷 | input.amount |
| 身份上下文 | os.user.id、os.org.id、os.org.tier、os.env |
| 相等 | == / != |
| 逻辑运算 | && / || / ! |
| 三元运算 | cond ? a : b |
| 字符串字面量 | 'abc' — CEL 也能解析 "abc" |
| 成员关系 | record.region in ['us', 'eu'] |
| 键存在性(不用于空值安全检查) | has(record.foo) |
| 空值检查 | record.foo == null 或 isBlank(record.foo) |
组织上下文为 { id, tier }——不存在 os.org.slug 或 os.org.name。根据 ADR-0068,求值器还会将当前用户绑定为 current_user(别名为 user)——规范字段文档中的谓词写作 current_user.positions。
has() 不是空值检查
has(record.x) 只要键存在就会返回 true,即使它的值是
null。若要检查“值存在且非空白”,请使用标准库辅助函数
isBlank(),或显式与 null 比较。
每个谓词都会读取一个 record,并且在 §5 绑定时也会读取 previous;它们对对象声明的字段
都是全定义的:驱动程序未返回的已声明列读取时会得到 null,而不会报错。因此,has(record.<declared_field>)
和 has(previous.<declared_field>) 总是 true,完全不能告诉你任何信息。看起来像守卫条件的惯用写法并不是守卫条件:
# WRONG — both has() calls are true on a NULL row, so this reaches `null < null`,
# CEL has no overload, the predicate aborts and the write is rejected.
has(record.start_date) && has(record.end_date) && record.end_date < record.start_date
# RIGHT
record.start_date != null && record.end_date != null && record.end_date < record.start_date
这会在发布时拒绝,而不只是建议。 os build /
os validate / os lint 和运行时发布门禁会拒绝任何对已声明且可为 null 的字段应用排序运算符(< <= > >=)或算术运算符(+ - * / %)的验证规则或钩子谓词,除非同一布尔分支中有显式的 != null / == null /
!isBlank() 测试优先约束该运算。has() 特意不满足此门禁。对未声明键使用 has() 仍然合法:这才是它的实际用途——区分“本次 PATCH 中不存在”和“显式设为 null”。
null 与字符串相加会抛出异常
CEL 不会隐式转换 null。null + 'foo' 会抛出
no such overload: dyn<null> + string。每个可为 null 的字符串操作数都要用
coalesce(..., '') 包装。
ObjectStack CEL 标准库
由 @objectstack/formula 自动注册,该包仅包含 dist —
已安装的应用中没有可供查看的 src/。其导出的
CEL_STDLIB_FUNCTIONS 是公开的裸调用函数子集,并由两个
测试固定:每个条目都能在运行时解析,且本表记录了全部条目。
日期
| 函数 | 返回值 | 说明 |
|---|---|---|
now() | 时间戳 | 当前时刻。每次求值运行中固定;在构建中具有确定性 |
today() | 时间戳 | 参考时区的日历日期,以 UTC 午夜表示(而不是普通的 UTC 当日开始时刻) |
daysFromNow(n) | 时间戳 | 日历日:today() + n 天,时间为 UTC 午夜(不会保留当天时间) |
daysAgo(n) | 时间戳 | 日历日:today() − n 天,时间为 UTC 午夜 |
daysBetween(a, b) | int | 从 a 到 b 的整天数(若 b 早于 a,则为负数)。daysBetween(today(), record.due) = 剩余天数 |
addDays(d, n) | 时间戳 | 将任意日期前后移动 n 天(允许负数)。addDays(record.last_service, record.cycle_days) = 下次到期日期 |
addMonths(d, n) | 时间戳 | 将任意日期前后移动 n 个月;超出时截到月末(addMonths(date('2026-01-31'), 1) → 2 月 28 日) |
date(s) / datetime(s) | 时间戳 | 将 ISO 日期 / 日期时间字符串解析为时间戳 |
isoDate(t) / isoDatetime(t) | 字符串 | 将时间戳转换为 UTC ISO 文本:2026-10-08 / 2026-10-08T17:55:06.123Z。输入文本?isoDate(date(s)) |
不支持日期算术。 日期与数字混合运算会报错,且构建会拒绝;
end - start不会报错——它会生成一个存储为{}的duration。
| 需求 | 写法——切勿使用 end - start、date + n、today() + 30 |
|---|---|
| 天数跨度 | daysBetween(start, end) |
| 含首尾的跨度 | daysBetween(record.start_date, record.end_date) + 1 |
| 日期偏移 | daysFromNow(n) / addDays(d, n) / addMonths(d, n) |
| 任职年限 | daysBetween(record.hire_date, today()) / 365 |
| 小于一天的偏移 | now() + duration("3h") — 日历辅助函数会将时间设为 UTC 午夜 |
数字
| 函数 | 返回值 | 说明 |
|---|---|---|
abs(x) | double | 绝对值 |
round(x) | int | 四舍五入到最近整数 |
floor(x) / ceil(x) | int | 向 −∞ / +∞ 方向取整(floor(-1.2) = −2,而不是 −1) |
min(a, b) / max(a, b) | dyn | 较小 / 较大的操作数(数值比较) |
字符串
| 函数 | 返回值 | 说明 |
|---|---|---|
upper(s) / lower(s) | string | 大小写转换 |
trim(s) | string | 去除首尾空白(null 时返回 '') |
contains(s, sub) | bool | 子字符串测试 |
startsWith(s, p) / endsWith(s, p) | bool | 前缀 / 后缀测试 |
matches(s, re) | bool | 正则表达式测试 |
joinNonEmpty(list, sep) | string | 拼接,并丢弃 null / 空条目 |
集合 / 类空值
| 函数 | 返回值 | 说明 |
|---|---|---|
isBlank(v) | bool | 对 null、undefined、''、[] 返回 true |
isEmpty(v) | bool | 对 null、undefined、空字符串 / 列表 / 映射返回 true |
coalesce(v, fallback) | dyn | 非 null 时返回 v,否则返回 fallback |
len(v) | int | 字符串 / 列表 / 映射的长度 |
此外还有 CEL 内置函数:has(x)、size(x)、int(x)、string(x)、bool(x)、
double(x)、timestamp(s)、duration(s)。
如果需要的辅助函数不存在,优先将其添加到标准库(小型、纯函数、无依赖),而不是内联复杂的 CEL 表达式。
以上列表是
CEL_STDLIB_FUNCTIONS——可直接调用的公开子集: 可以写作fn(x)的函数名称。接收者方法(s.split(',')、list.map(...))需要在值上调用,不能直接调用,也不在此表中; 有四个可直接调用的基本函数(bytes、dyn、type、uint)可以解析,但基于作者决策不列入目录——不要使用它们。未知 函数——PRIOR()、旧版ISBLANK()、拼写错误的isBlnk()——会导致os build失败,并报出“没有匹配的重载”类型错误,而不是在运行时静默地不执行谓词。使用previous.x(而非PRIOR())、isBlank()(而非ISBLANK())。
AI 输出的强制模式
1. 计算文本公式 — 始终对可空操作数使用 coalesce
✅ 正确
F`coalesce(record.salutation, '') + ' '
+ coalesce(record.first_name, '') + ' '
+ coalesce(record.last_name, '')`
❌ 错误(CEL 遇到 null + 字符串时会抛出异常)
F`record.salutation + ' ' + record.first_name + ' ' + record.last_name`
2. 条件数值公式 — 对除数进行保护
✅
F`coalesce(record.cost, 0) > 0
? ((coalesce(record.revenue, 0) - record.cost) * 100) / record.cost
: 0.0`
3. 谓词(字段规则 / 可见性 / 验证)
✅
import { P } from '@objectstack/spec';
P`record.status == 'qualified'`;
P`record.amount > 10000 && record.region in ['us', 'eu']`;
P`!isBlank(record.po_number)`;
对于字段级条件规则,请输出规范字段属性:
visibleWhen、readonlyWhen 和 requiredWhen。绝不要输出
conditionalRequired——它已在协议 17 中移除,使用它会导致解析错误。
❌ Salesforce 风格 — CEL 编译失败:os build 会报错并指出错误位置;如果流程引擎在运行时执行到该表达式,也会抛出异常:
"status = 'qualified'"
"amount > 10000 AND region IN ('us', 'eu')"
"NOT(ISBLANK(po_number))"
4. 动态种子值 — 使用 cel\`,而不是 new Date()`
✅
{ close_date: cel`daysFromNow(45)`, created_at: cel`now()` }
❌ 编译时求值 — 每位客户都会使用软件包作者的时钟:
{ close_date: new Date(Date.now() + 45 * 86400000), created_at: new Date() }
这是确定性门槛:只有当每个动态种子值都使用
cel`...`——不使用 new Date()、Date.now()、随机数或其他
非纯值来源时,连续两次 os build 运行才会生成字节完全相同的 dist/objectstack.json。标准库辅助函数会遵循 EvalContext 中固定的 now,因此可以在其中安全使用。
5. 更新钩子条件 — previous 与 record
✅
P`previous.status != 'escalated' && record.status == 'escalated'`
不存在 ISCHANGED 风格的函数;请使用显式的 previous
比较。
record 是记录的状态,而不是本次写入的差异:已存储的行 ⊕
负载,因此对于已经处于升级状态的记录,每次更新时
record.status == 'escalated' 都为真。与 previous 比较是唯一能表达
“刚刚变为”的方式。钩子 condition 和验证谓词绑定相同的两个根对象——
一个作用域,一种含义,无论从哪个界面读取。
previous 的绑定位置,以及未绑定的位置:
| 界面 / 事件 | previous |
|---|---|
更新钩子 condition(单记录写入)、更新时的验证规则 | 写入前已存储的行 |
插入事件(beforeInsert / afterInsert)、插入时的验证规则 | null — previous == null || … 会判断插入,previous != null && … 会跳过插入;读取其列(previous.x)没有值,并会出错 |
| 对现有行的写入,但无法取得其先前状态 | 未绑定 — 会安全失败,因此 previous == null 绝不会把更新误判为插入 |
对谓词(multi: true)写入触发的 after* 钩子 condition / 记录变更流触发器 | 该行写入前的行 — 批量写入会在每个匹配行上分别触发一次 after 钩子 |
| 谓词批量更新时的验证规则 | 该行写入前的行 — 按行执行 |
对谓词(multi: true)写入触发的 before* 钩子 condition | 该行写入前的行 — beforeUpdate / beforeDelete 也会在每个匹配行上分别分发一次,每次都使用单记录形态的上下文,其中 input.id 指向该行(ADR-0058 Addendum II,D1/D2);与任何更新一样,record 是该行的状态,即已存储内容 ⊕ 负载。只有负载仍限定在批次范围内 — 见下表 |
⚠️ 无法求值的条件会中止操作。 插入时读取 previous 的列
(previous 为 null,例如 previous.status)——与键名拼写错误
(record.stauts)、已弃用字段,或 CEL 没有对应重载的比较一样——
不会退化为“钩子未触发”,而是会导致写入失败,并报错指出钩子和
键名——before* 和 after* 都一样,且没有 onError 兜底
(onError 适用于处理器抛出异常,而条件会在任何处理器运行前求值)。
无法编译的条件也会以相同方式中止操作。因此,在插入事件中只应将
previous 与 null 比较,并将对其列的读取放在 previous != null &&
之后。
转换条件无需对批量写入进行特殊处理。
在 before* 或 after* 事件上编写一次,无论写入携带 id 还是谓词,
其含义都相同:
// Fires once per row that ACTUALLY transitioned — on `update(id)` and on
// `update({multi: true})` alike.
P`previous.status != 'done' && record.status == 'done'`
谓词(multi: true)写入会产生 N 次记录变更,因此其中每个记录级声明
都会按行求值——previous 是该行自身写入前的状态,record 是该行
的真实状态,而非单独的负载(ADR-0058,批量写入补充 I 和 II)。记录变更
流触发器也使用相同的分发方式。before* 在条件方面并无例外;不同之处
在于 before* 处理器写入的内容:一次 updateMany 只使用一个 SET 子句,
因此每行分发都携带同一个负载,并且在任意行上做出的重写都会应用于所有
匹配行(D3)。如果按行决定重写,则必须在每一行原地赋入相同的键集合——
赋到负载对象上,而不是替换该对象——因为引擎会记录每行处理链写入的键,
并在两行不一致时拒绝整个批次,且发生在任何写入之前
(MULTI_UPDATE_HOOK_KEY_DIVERGENCE,状态码 400;ADR-0058 Amendment II.3)。
如果匹配行超过 10 000 条,平台会拒绝对具有任一阶段按行钩子的对象执行
谓词写入——before* 和 after* 共用一个上限,并在首次分发前检查
(ADR-0058 Addendum II,D6)——而不是在一次写入中分发那么多次处理器;
请对写入分页。这是拒绝操作,绝不会静默降级为仅调用一次钩子。
机械翻译表(旧版 → CEL)
迁移 Salesforce 风格的元数据时,请按以下顺序应用这些规则:
| 旧版 | CEL |
|---|---|
bare_field | record.bare_field — 流程条件中除外,见下文 |
OLD.x | previous.x |
NEW.x | record.x |
=(比较) | == |
<> | != |
AND | && |
OR | || |
NOT(x) | !x |
"abc" | 'abc' |
IF(c, a, b) | c ? a : b |
ISBLANK(x) | isBlank(record.x) |
CONCAT(a, b) | coalesce(a, '') + coalesce(b, '') |
TODAY() / NOW() | today() / now() |
IN (a, b, c) | in [a, b, c] |
ISCHANGED(x) | previous.x != record.x |
MONTH_DIFF、MID、LEFT、RIGHT、SUBSTITUTE | 不在标准库中 — 建议添加 |
⚠️
OLD.x和ISCHANGED(x)都会映射到previous.x,而previous.x只有在previous是行时才有值 — 见 §5。在插入事件中,previous为null,因此读取不会悄悄跳过钩子,而是会导致写入失败,除非使用previous != null &&进行保护。在更新和删除事件中,它会按匹配行绑定,无论是before*还是after*,也无论是批量写入还是单条记录写入。
⚠️ 流程条件是第 1 条规则的例外。 自动化引擎会将记录变量展开到顶层,因此裸写的
status在流程启动条件中可以解析。record.status在那里也能解析,并且是其他所有场景中唯一有效的写法 — 建议优先使用它。
接受 Expression 的场景
以下所有规范字段都接受 string | Expression。构建过程会将其规范化为 envelope。
CEL 场景(谓词 + 计算值)
| 场景 | 字段 | 方言 |
|---|---|---|
Field | expression(当 type: 'formula' 时) | cel |
Field | visibleWhen / readonlyWhen / requiredWhen | cel |
View / Page | visibleWhen(表单分区/字段、页面组件) | cel |
Field | defaultValue(仅 envelope;裸字符串 = 字面值) | cel |
ConditionalValidation | when | cel |
Action | disabled | cel(或布尔值) |
Hook | condition | cel |
SharingRule | condition | cel |
Flow.decision | expression / 边的 condition | cel(使用 vars.<step>.<key>) |
Seed.records[*] | 任意值 | cel(通过 cel\``) |
audit / metrics / tracing | condition / successCriteria | structured | cel |
⚠️ formula 字段是虚拟字段 — 没有驱动程序会为其实体化列,因此三种查询轴都会拒绝它:where 和 searchableFields 返回 400 INVALID_FIELD,orderBy 返回 400 INVALID_SORT。它仍然可以正确读取,这正是拒绝它很重要的原因:过去对它使用 where 会返回 200,但结果为零行。请将其反规范化到存储字段上,再对该字段查询。summary 和 autonumber 有实际列,无需这样处理。
⚠️ 表单视图的 visibleWhen 是这里唯一默认故障时保持开放的谓词。 它仅在客户端运行,未绑定的根变量会回退为 true,因此控件会向所有人显示 — 绝不可将其用作访问控制;访问控制应使用权限集字段级安全性。此表中的其他所有场景都恰好相反:无法求值的 Hook / SharingRule condition 或验证谓词会中止写入(§5)。
视图列表筛选器不是 CEL 场景 — 它们是结构化 JSON 筛选规则(ViewFilterRuleSchema),因此不要在其中生成 CEL。对于“最近 30 天”这类时间范围,请使用日期宏标记(data/date-macros.zod.ts)— objectstack-query rules/filters.md 中列出了这些标记。
Cron 和模板表面
另外两种方言使用相同的封装。它们都不是 CEL;两者都接受裸字符串(自动封装)或各自的辅助函数,并读取相同的变量作用域。
| 方言 | 辅助函数 | 语法 | 承载位置 |
|---|---|---|---|
cron | cron`0 6 * * MON` | 5 或 6 字段的 cron,或 @yearly、@annually、@monthly、@weekly、@daily、@hourly、@reboot 之一 | Job.schedule.expression(规范位置) |
template | tmpl`Hello {{ record.first_name }}` | {{ path }} 或 {{ path | formatter[:arg] }} — 仅支持双大括号,不支持条件语句;格式化器白名单为 TEMPLATE_FORMATTERS,从 @objectstack/formula 导出 | system/email-template 的 subject / bodyHtml / bodyText,ai/model-registry 的 promptTemplate.system / .user,Object.titleFormat(已弃用 → nameField,ADR-0079) |
shared/expression.zod.ts 声明了这两种表面及其承载位置。
缺失的模板路径会渲染为空字符串。将逻辑移入 CEL 字段:
模板只保存路径和格式化器,不包含其他内容。
验证你的工作
格式错误的表达式不会被静默处理(ADR-0032)。过去它会求值为
null / false — 流程“触发”了,却什么也没做;现在 os validate 和
os build 会对整个技术栈中的每个公式和谓词运行共享验证器 — 包括 CEL 语法,以及目标对象上
record.<field> 的存在性 — 并在出错时以非零状态退出,同时提供错误位置和“你是否想输入”提示;运行时,引擎会抛出异常,并明确报告规则失败。使用 os validate 进行快速的编辑后检查(不会生成任何产物;在搭建的项目中使用 npm run validate)。若要在保存表达式之前检查单个表达式,请调用 Agent 工具 validate_expression,它会以内联方式运行相同的验证器。
另请参阅
- references/_index.md — 上述每个表面背后的 Zod 架构
node_modules/@objectstack/spec/src/shared/expression.zod.ts—Expression、ExpressionInput、cel/F/P