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

vercelBot commented Jul 24, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specBuildingBuildingPreview, CommentJul 24, 2026 2:49pm

Request Review

@github-actionsgithub-actionsBot 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 mainJul 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude