mcpField
O
objectstack-formula

objectstack-formula

github.com/objectstack-ai开发76225 次安装今天更新
代码仓库

关于 objectstack-formula

objectstack-formula 是供 ObjectStack 元数据编写者使用的表达式参考技能,规范以 CEL 编写计算值和布尔谓词,并说明 cron 与 template 方言。它着重规定表达式格式、空值处理及可用标准库函数。

主要功能

  1. 01统一使用 CEL 编写公式与谓词
  2. 02说明 cel、cron、template 方言
  3. 03规范表达式元数据封装格式
  4. 04提供日期、数字与字符串函数参考
  5. 05列出空值和谓词编写规则

适用场景

  1. 01编写字段计算公式和条件谓词
  2. 02为元数据创建动态种子值
  3. 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_fieldrecord.bare_field — 流程条件中除外,见下文
OLD.xprevious.x
NEW.xrecord.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 场景(谓词 + 计算值)
场景字段方言
Fieldexpression(当 type: 'formula' 时)cel
FieldvisibleWhen / readonlyWhen / requiredWhencel
View / PagevisibleWhen(表单分区/字段、页面组件)cel
FielddefaultValue(仅 envelope;裸字符串 = 字面值)cel
ConditionalValidationwhencel
Actiondisabledcel(或布尔值)
Hookconditioncel
SharingRuleconditioncel
Flow.decisionexpression / 边的 conditioncel(使用 vars.<step>.<key>)
Seed.records[*]任意值cel(通过 cel\``)
audit / metrics / tracingcondition / successCriteriastructured | 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;两者都接受裸字符串(自动封装)或各自的辅助函数,并读取相同的变量作用域。

方言辅助函数语法承载位置
croncron`0 6 * * MON`5 或 6 字段的 cron,或 @yearly、@annually、@monthly、@weekly、@daily、@hourly、@reboot 之一Job.schedule.expression(规范位置)
templatetmpl`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

评论

支持 Markdown