Skip to content

fix(trigger-record-change): 让 record-change 流程种子记录携带公式(formula)字段 (#3426)#3445

Merged
os-zhuang merged 1 commit into
mainfrom
claude/notify-node-formula-lookup-fields-ovzioh
Jul 24, 2026
Merged

fix(trigger-record-change): 让 record-change 流程种子记录携带公式(formula)字段 (#3426)#3445
os-zhuang merged 1 commit into
mainfrom
claude/notify-node-formula-lookup-fields-ovzioh

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

问题

notify 节点模板里的 {record.full_name}(一个 formula 公式字段)在 record_change 流程里被解析成 空字符串,于是收件箱里出现 "New lead to assign: " 这类缺名字的通知,而且没有任何构建期/运行期信号。同一条记录上的普通标量列({record.company}{record.first_name})却能正常插值。见 #3426

根因

formula读取时(read-time)虚拟字段:引擎只在 find/findOne 取数后用 applyFormulaPlan 计算它,写入路径不会计算、也不落库。而 record-change 触发器给流程种子的 record 直接来自生命周期钩子的原始行(ctx.result 叠加输入 doc,见 #1872),这一行根本不含公式字段。因此流程上下文里 record.full_nameundefined,插值时被渲染为 ''

这不是消费端(notify 节点)的问题,而是 record 这个生产者(触发器)喂进来的数据本身就缺字段——所以修复放在触发器,整条流程(开始条件、所有节点、notify 的 title/body)都受益,而不是只给 notify 打补丁。

方案

RecordChangeTrigger 在组装种子 record 后,通过数据引擎回读刚写入的记录,让种子 record 与「用数据 API 读这条记录」拿到的计算字段保持一致。

刻意保守:

  • afterInsert / afterUpdate 生效(行已处于写后状态);before*(行尚未落库)与 afterDelete(行已删除)保持原始钩子记录不变。
  • 系统(isSystem)身份回读,因此只会新增计算字段,绝不会因回读侧的 RLS/FLS 把流程本来能看到的(未脱敏的写路径行)字段抹掉
  • 合并时原始钩子字段优先,保留触发时刻的标量值与 [P2] record-change context does not hydrate multi-lookup (junction) fields #1872 的多值 lookup 输入叠加;回读只补齐原始行缺失的键(公式虚拟字段)。
  • 任何失败(引擎无 findOne、无 id、抛错、读到空)都回退到原始记录——补水绝不能弄坏它所服务的流程。

取舍:lookup 遍历暂不处理

{record.account.name} 这类 lookup 跨表遍历有意做补水:数据 API 的默认读取本身也不展开关联;而一旦展开,record.account 就会从标量外键 id 变成对象,反而破坏那些把它当 id 用的模板/条件(例如 #1872{record.target_channels.0})。因此本 PR 让种子记录严格对齐「数据 API 默认读取」的语义——公式字段补齐、关联不展开。lookup 遍历(以及构建期对不可解析路径的告警)仍留在 #3426 跟踪。

测试

  • 新增 7 个针对 hydrateComputedFields 的单元测试:after-create 补齐公式字段、原始字段优先(触发时刻保真 + [P2] record-change context does not hydrate multi-lookup (junction) fields #1872)、before*/afterDelete/无 id/无 findOne 时不回读、回读抛错时回退。
  • 新增一个端到端集成测试 formula-context.test.ts:用真实 ObjectQL + automation + record-change 栈注册一个公式字段,证明 {record.full_name} 在种子流程记录里能被解析(修复前会渲染成空)。
  • @objectstack/trigger-record-change(33 tests)与 @objectstack/service-automation(347 tests)全绿;类型构建 + lint 通过;附带 changeset。

关联 #3426

🤖 Generated with Claude Code

https://claude.ai/code/session_01StfHcpZduPXA6zSAG6beFR


Generated by Claude Code

… seeded flow record (#3426)

A `formula` field is a read-time virtual — evaluated post-fetch on
`find`/`findOne`, never on the write path — so it was absent from the raw
after-create/after-update row a record-change flow is seeded with. A notify
node template like `{record.full_name}`, or a start condition on the same
field, therefore resolved to an empty string, silently producing
notifications such as `"New lead to assign: "` with the name missing.

The record-change trigger now re-reads the just-written record through the
data engine, so the seeded `record` carries the same computed fields a
data-API read returns. The fix lives at the trigger — the producer of the
flow's `record` — so it benefits the whole flow (start condition, every node,
and notify `title`/`body` templates), not just the notify node.

Conservative by design:
- Only for `afterInsert` / `afterUpdate` (the row exists post-write);
  `before*` and `afterDelete` keep the raw hook record.
- Reads as an elevated system principal, so it only ADDS computed fields and
  never lets RLS/FLS on the re-read shrink the snapshot the flow already saw.
- Raw hook fields win on merge, preserving trigger-time scalars and the #1872
  multi-lookup input overlay; the re-read only fills in the formula virtuals.
- Any failure (no read surface, no id, a throw, an empty read) falls back to
  the raw record — hydration never breaks the flow it feeds.

Lookup traversal (`{record.account.name}`) is intentionally not hydrated: a
default data-API read does not expand relations either, and expanding would
turn `record.account` from its scalar FK id into an object, breaking templates
that use the bare id (e.g. #1872's `{record.target_channels.0}`).

Adds seven focused unit tests plus an end-to-end integration test that boots a
real ObjectQL + automation + record-change stack with a formula field and
proves `{record.full_name}` resolves in a seeded flow record.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01StfHcpZduPXA6zSAG6beFR
@vercel

vercel Bot commented Jul 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Building Building Preview, Comment Jul 24, 2026 2:49pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 24, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

No hand-written docs reference the 1 changed package(s). ✅

@os-zhuang
os-zhuang marked this pull request as ready for review July 24, 2026 15:06
@os-zhuang
os-zhuang merged commit 1dc94f0 into main Jul 24, 2026
15 of 16 checks passed
@os-zhuang
os-zhuang deleted the claude/notify-node-formula-lookup-fields-ovzioh branch July 24, 2026 15:06
os-zhuang added a commit that referenced this pull request Jul 25, 2026
…+ guard the #3426 re-read (#3426) (#3472)

Two follow-ups to #3426, after #3445 closed the formula half.

Build-time signal (the issue's fallback ask): `os validate` now flags a
record-change flow node whose `{record.<path>}` template can't resolve — the
previous SILENT blank becomes an advisory warning. New @objectstack/lint rule
`validateFlowTemplatePaths` with two findings: flow-template-unknown-field
(a typo) and flow-template-lookup-traversal (a cross-object hop still
unsupported). Wired into `os validate` beside the flow-trigger-wiring check.

Hydration re-read guards: the trigger-record-change computed-field re-read
(#3445) is now skipped when the object declares no formula field (via the
engine's optional getObjectConfig) and memoized per write on the shared
HookContext, so N flows on one written record share one re-read instead of N.

Lookup expansion ({record.account.name}) is intentionally NOT implemented here:
scoping shows it needs a read-identity design decision — expanding in the
trigger's elevated (system) re-read would bypass the triggering user's RLS/FLS
on the referenced object. Left for an ADR; the lint rule flags it unsupported.

Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants