fix(objectql): 写路径回包水合 formula 虚拟列,与 GET 等价 (#5504) - #5698
Merged
os-zhuang merged 2 commits intoAug 6, 2026
Conversation
… on reads (#5504) `applyFormulaPlan` had exactly two call sites — the `find` result and the `findOne` result — so `POST /data/:object` and `PATCH /data/:object/:id` answered with the stored document, in which a `formula` field is not `null` but ABSENT (formulas are virtual; no driver returns a column for one). The next `GET` of the same row carried every one of them: read-your-write broken in the direction hardest to notice, since the response calls itself `record` and consumers render it directly. Every object whose `nameField` points at a formula rendered blank until a second round-trip. `engine.insert` and `engine.update` now hydrate through one shared helper that reuses the read path's plan builder and evaluation — same formula semantic on both verbs, no write-path dialect. It evaluates over the row the driver already returned (`create` uses `RETURNING *`, `update` re-reads), so there is no extra round-trip and no formula sees a partial record. Coverage falls out of the placement rather than being enumerated per call site: single insert, batch insert, insertMany / createManyData / insertManyData and single-id update all pass through one hydration point per verb. A predicate (`multi`) update is unchanged — `driver.updateMany` resolves to an affected-row count and names no row. Ordering is pinned on both sides: after the same-day write-path strips and refusals (#5503 runtime-owned autonumber, #2948 readonly, #5126 strictReadonlyWrites), and before the afterInsert/afterUpdate dispatch, which mirrors the read path's applyFormulaPlan → afterFind order. `packages/rest` needed no production change; its end-to-end test proves the handlers pass the hydrated record through, so a future reshaping of the write response fails there instead of silently reopening this. Also corrects two comments in other packages that asserted the now-inverted fact ("the write result does not carry formula fields"): plugin-audit's computed-field exclusion is keyed on the field TYPE and is unaffected, and trigger-record-change's re-read still earns its keep for summary/rollup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01V7WetGmnfoXNn8cLieKKmx
…te-response-formula-hydration
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 6, 2026
os-zhuang
marked this pull request as ready for review
August 6, 2026 02:05
os-zhuang
deleted the
claude/issue-5504-write-response-formula-hydration
branch
August 6, 2026 02:18
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5504
问题
applyFormulaPlan只有两个调用点 ——find结果与findOne结果。写路径回包原样返回入库文档,而 formula 是虚拟列,任何 driver 都不会为它返回一列,所以POST /data/:object与PATCH /data/:object/:id的record里这些键整个缺失(不是 null),紧接着的 GET 却每个都有值。失效方向恰好是最难察觉的那一种:回包自称
record,消费方会直接拿来渲染 —— HotCRM 里 account / product / case / campaign / quote / knowledge_article / forecast / lead / contact 的nameField全部指向 formula,于是「创建后显示标题」一律是空,必须再多一次 GET 才能拿到派生值。而且缺失是静默的:键不存在,容易被当成「字段没配」误排查。改动
engine.insert与engine.update通过一个共享的水合点,把 formula 虚拟列求值到各自的回包上。刻意复用读路径的同一个 plan 构建器与同一套求值(planFormulaProjection(schema, undefined)就是 find 的无投影分支),不新造写路径方言;执行上下文按 find 现在的方式穿透,os.user/os.org两侧解析一致(扩展 context 承载的内容是 #1979 的工作,本单不碰)。求值对象是 driver 已经返回的整行(
create用RETURNING *,update有回读),因此不需要额外往返,也不存在对半行求值的问题。覆盖面由落点决定,不靠逐个调用点枚举:单条 insert、批量 insert、
insertMany/createManyData/insertManyData、以及单条 update,都经过每个动词唯一的那个水合点。谓词(multi)update 维持原状 ——driver.updateMany解析出的是受影响行计数、不指名任何行,没有 record 可以物化;这一点用测试钉住,免得后来的读者把「没接」误读成漏掉的调用点。落点的两侧顺序(都有测试钉住)
autonumber([17.0-rc2验收] autonumber 字段可被普通调用者改写:POST 提交显式值绕过序列、PATCH 直接改号落库 —— readonly 剥离不保护 type:'autonumber' #5503)或readonly(security: 服务端 readonly 字段在 UPDATE 时未强制(可被用户上下文覆盖) #2948)字段时,报告的是入库值;strictReadonlyWrites([决策] readonly 剥离的 strict/reject 模式落在哪一层 —— B(WriteObservabilityOptions)推荐,A/C/D 各有代价(#4903 后续) #5126)拒绝掉的写根本没有回包可水合。applyFormulaPlan→afterFind的顺序对齐,hook 在写路径上看到的记录和在读路径上看到的一样完整。packages/rest无生产码改动POST/PATCH handler 原样透传协议层结果,拿到的就是水合后的记录。但「这一层不用改」是一个关于层的断言,唯一诚实的证明方式是驱动它:
rest-write-response-formula.test.ts用真实ObjectQL+ 真实ObjectStackProtocolImplementation+ 已注册路由走一遍 issue 的原始复现形状,这样将来若有人在回包上加投影/白名单/序列化而丢掉虚拟列,失败点在那里,而不是悄悄把本 issue 重新打开。反向验证(方向先判,后测)
这是新增水合,不属于「倒置」或「诊断变多」两个家族,预判就是普通的 RED:把两处水合删掉后,断言这些键存在的 pin 必须转红。
实测 objectql 侧 15 红 / 3 绿,3 个绿全部是刻意留的对照组 —— 谓词 update 的计数契约、strict 拒绝、以及无 formula 对象不求值 —— 三者断言的都是本改动不触碰的行为。REST 侧 3 个全红。
首轮反向验证曾出现 5 绿,多出来的 3 个是假绿:测试用的内存 driver 从
find/findOne直接返回了 store 里的引用,而读路径的水合是就地改写 driver 返回的行 —— 于是第一次 GET 就把display_title写进了自己的 store,后续写回包跟着「有值」,删掉写路径水合也照样通过。这正是driver-memory用整段注释写明的契约(「Return shallow copies, never live references into the backing table」)。改成返回副本后假绿消失,反向验证才可信。顺带修正的两处过期注释
本改动把「写结果不带 formula 字段」这个事实反转了,有两处别的包的注释还在陈述旧事实(纯注释,无逻辑改动):
plugin-audit/audit-writers.ts:计算字段排除是按字段类型判定的,不依赖键的缺失,因此逻辑不受影响;注释改为把「派生值的变化已由来源字段蕴含」作为留下它的正当理由,并记下 [17.0-rc2验收] REST create/update 回包的 record 缺所有 formula 字段(GET/LIST 有)—— applyFormulaPlan 只挂在 find/findOne 上,写路径回包不做公式水合 #5504 之后两侧不再不对称。顺带一提,fieldDefs缺失的降级路径反而变好了:原先 before 有值、after 没有会记一条幻影 diff,现在两侧一致。trigger-record-change/formula-context.test.ts:触发器的回读依然有价值(summary/rollup虚拟列仍只在读路径求值),注释说明了这一点。验证
pnpm --filter @objectstack/objectql test— 122 files / 1988 passedpnpm --filter @objectstack/rest test— 52 files / 754 passedpnpm --filter @objectstack/plugin-audit test— 7 files / 112 passedpnpm --filter @objectstack/trigger-record-change test— 5 files / 55 passedtypecheck(objectql / plugin-audit / trigger-record-change)全部 Donepnpm check:query-options-erasure— ratchet holds,test surface 267 未增长node scripts/check-nul-bytes.mjs— OK;改动文件另做了越过该门的控制字符自查origin/main(含 feat(spec,runtime,metadata-protocol)!: discovery 两个生产者统一到一个 schema —— capabilities 正名、features/endpoints 退役、scoping 声明 (#4828) #5682 对 metadata-protocol 的改动)后重跑上述全部Generated by Claude Code