fix(metadata-protocol): refuse a sort naming a formula field instead of dropping it silently (#6994) - #7096
Conversation
`assertSortFieldsExist` refused an unknown field (#4226) and a dotted path (#4256) but not a real, non-dotted field whose TYPE materialises no column. A `formula` field is in the object's field map, so it passed the unknown check, and carries no dot, so it passed the dotted check — then reached a driver with no column for it, where the #3821 backstop retried the query without the ORDER BY and answered 200 in arbitrary order. Adds the axis' third verdict, the one `assertSearchFieldsExist` (`unsearchable`) and `assertExpandFieldsExist` (`notRelations`) already have: known field, wrong type for this axis. Precedence stays `unknown` > `dotted` > unmaterializable, so both older verdicts are unchanged. `summary`/`autonumber` are deliberately NOT in the set — they get real columns and sort correctly. Also copies rows out of the conformance stub driver: it handed back live references into its own store, so `applyFormulaPlan` persisted computed formula values back into "the database" and a second read really sorted by a column no driver has — the double contradicting the driver it stands in for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
`check:query-options-erasure` counts `as any` on engine query options and
the ratchet went red (test surface 256 -> 258). The options here are NOT
off-contract — `{ orderBy: [{ field, order }] }` is a legitimate
`EngineQueryOptionsParsed` — so the remedy is typing them, not the
`as unknown as EngineQueryOptions` escape the gate offers for deliberately
bad input. Ratchet back to 256, none new.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W6bLax4KMrSfnE1ydFU8Dw
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
|
ACCEPT — PM step-7 review ( CI on The routing question this card carried is settled, and by structural evidence rather than preference. I dispatched it with the routing live — I had suggested The question I made mandatory was answered honestly, and the answer is "no, it does not close it". An ingress-only fix covers everything reaching Three more things worth putting on the record:
The missed prediction is the most useful paragraph in the report. The known-hole pin went red on first write, and the cause was not product code: the conformance stub driver handed back live references into its own store, so Also noted and appreciated: the self-criticism about taking the fix out with Out-of-lane finding objectui#3950 (ObjectGrid disables the sort header only for reference types, so a Generated by Claude Code |
Fixes #6994
结论先说:定位在 ingress,但只关上了一半门
排序轴上
assertSortFieldsExist已经会拒绝不存在的字段(#4226)和带点的路径(#4256),但对一个「确实存在、不带点、然而类型上根本不落列」的字段没有任何判定 ——
formula字段就在对象的字段表里,所以它过了 unknown 检查;它不带点,所以它过了 dotted 检查;然后它抵达一个
没有这一列的 driver,排序被静默丢弃。
这次在本分支 base(
2f3e79351)上用真实SqlDriver(better-sqlite3,落盘)+ 真实ObjectQL+ 本协议重新测过,五行按C A E B D插入,sort_key是record.title的formula:
asc与desc逐字节相同,这才说明是排序被丢掉而不是巧合。机制与 issue 描述一致:SqlDriver.createColumn对formula直接return(虚拟字段,读时计算),sqlite 报no such column,#3821 的未知列兜底重试时去掉了 ORDER BY。响应里甚至原样带着调用方要求排序的那一列的值,乱序摆着,还报 200 —— 请求与应答当面矛盾,却自称成功。
为什么修在 ingress,而不是 driver 或查询编译器
派单把「路由归属」当作未决问题带了进来,所以先给证据再给结论。
同一扇门上的两个邻居已经就是这么做的。 这不是给 engine 侧问题打的补丁,而是补上这个 gate
自己四条轴里缺的第三种判定:
unknownunsearchable(已存在,消息里直接写type '${meta.type}')unknownnotRelations(已存在,"only lookup / master_detail / tree fields can be expanded")unknowndotted「字段存在,但这条轴用不了它的类型」在
protocol.ts里早就是 ingress 的职责,已落地、已测试。sort 是这一族里唯一没长出这条判定的成员,
formula正是从这个缺口漏过去的。不选 driver(方案 2)的理由是可测的。 #3821 兜底对真正未知的列是刻意且正确的
(注释写明它保护的是无 registry 的 cloud 多租户运行时),把它改成拒绝就破坏了它存在的理由;
而 driver 手上只有一条 SQL 错误,分不清「formula,契约上就不落列」和「这台 host 没有 registry
所以列查不到」—— 这个知识在 schema 里,不在错误消息里。更关键的是
driver-memory根本不报错(行上没有这个 key,比较全相等 → 稳定序),所以 driver 定位必须在 sql / memory / turso /
mongodb / sqlite-wasm 各写一遍,没有共享接缝:一个契约 N 份实现,正是 contract-first 的反面。
不选 materialize(方案 3)的理由。 与代码库多处声明的「formula 是虚拟的」契约冲突。还有一个
更隐蔽的陷阱:engine 其实可以在
applyFormulaPlan之后事后排序,看起来像修好了,但 driver早已分页,事后排的只是那一页任意顺序的行 —— 小结果集上像能用,一加
limit/offset就错。没有走 STOP 的边界在哪。 剩下的一半确实在 engine:
engine.find()直连的内部调用方(hook / flow / report / expand 子查询)不经过本 gate。但把那一半关上,要先决定
engine.find是拒绝还是保持它有文档记载的内部调用方宽容 —— 那是 engine-core 的契约决策,不是 gate 修复。已按 #7095 单独立项(含三个选项、各自代价,以及一个「engine 事后排序」的陷阱
说明),没有在本 PR 里替它做主。
ingress-only 留下的洞:实测,不是推测
同一个脚本在打了补丁之后再跑一次,两半同时可见:
所以这条不能当成「已关闭」来读。测试里对它有一条明确标注为
RECORD OF A KNOWN HOLE的 pin:它记录的是未修好的行为,并写明 engine-core 关掉它的那天这条测试应该变红,到时候一起更新 —— 而不是被后来的人当成「直连路径没问题」的证据。
顺带在 objectui 侧留了 objectstack-ai/objectui#3950:grid 目前只对引用型字段关掉排序表头
(
isExpandableFieldType),formula 列仍可点击排序 —— 本 PR 之后那一次点击会从「什么也没发生」变成一个 400。两者错因相同:UI 提供了平台做不到的排序。
summary/rollup不在这一族summary拿到的是真实且被维护的table.float列,排序真的有效(#6924 已测:orderBy一个 summary 字段 desc → E D C B A,值为 5 4 3 2 1),autonumber同理是真实字符串列。所以拒绝集是「不落列」而不是「computed」:spec 里的
COMPUTED_VALUE_TYPES(
formula/summary/autonumber)是写入契约(never client-written),拿它来卡排序会误伤两个本来能用的类型。测试里为此留了一条会因过度拒绝而变红的对照。
判定优先级
unknown>dotted> 不落列 —— 与 expand gate 的unknown>not-a-reference同序:先身份,再形状,最后类型。前两条判定逐条未变,并各有一条 pin 固定。
消息措辞
与 #6924(dotted)和 #6673(search 轴)逐字一致:
「Denormalise the value onto '...' (a stored field, written when the source changes) and sort
by that.」有一条测试专门 pin 这个一致性本身,任一扇门单独改词就会变红 —— #4256 与 #6673
当初正是这样走散的。
改了哪些文件
packages/metadata-protocol/src/protocol.tsUNMATERIALIZED_SORT_TYPES(今天就是{formula},附三种 computed 类型的落列对照表与五处既有拼写的账);assertSortFieldsExist的 dotted 分支由 early-return 改为块,其后新增第三条判定;assertSortFieldsExist的文档补 #6994 段与 SCOPE 段(原文里「NOT dotted, so this gate lets it through」在本 PR 后已不成立,一并修正)packages/objectql/src/query-expression-conformance.test.tssort_key(formula)与subtask_total(summary,取值刻意让 asc / desc / 插入序 / 标题序两两不同,避免对照空转);新增 8 种 wire 拼写的拒绝用例 + 消息契约 + 两扇门措辞一致性 + 两条优先级 pin + 1 条 known-hole pin + 1 条 summary 对照;stub driver 的find改为拷贝返回行.changeset/sort-formula-field-refusal.md其中 stub driver 那一处值得单说:它原本把自己 store 里的行引用直接交出去,而
applyFormulaPlan会把算出来的 formula 值写回它拿到的那条记录 —— 于是读一次就把虚拟值持久化进了「数据库」,第二次读真的按一个没有任何 driver 拥有的列排了序。真实
SqlDriver上asc / desc 是逐字节相同的,这个 double 与它所替身的 driver 结论相反。这是我在反向验证里
预测失败发现的,不是顺手美化。
验证
pnpm --filter @objectstack/objectql test→ 162 files / 2800 tests passedpnpm --filter @objectstack/metadata-protocol test→ 65 files / 827 tests passedpnpm --filter @objectstack/objectql typecheck→ cleaneslint --no-inline-config(两个改动文件)→ cleancheck:nul-bytes/check:error-code-casing/check:route-envelope/check:empty-changeset/check:doc-authoring/check:adr-anchors→ OKcheck:query-options-erasure—— 本地先红后绿:pin 里两处as any让 test surface 从256 涨到 258。这里的 options 并非故意越界(
{ orderBy: [{ field, order }] }本就是合法的EngineQueryOptionsParsed),所以按 gate 给的第一条路补类型,而不是用它为越界输入准备的as unknown as EngineQueryOptions出口。现已回到 256,none new。反向验证(方向先预测,后测量)
一处预测失败,连同原因一并留档: known-hole pin 第一次写成「asc 与 desc 相同」时变红了
(desc 回来
E D C B A)。原因不是产品代码,而是上面那条 stub driver 的引用泄漏 —— 真实 driver的实测(asc === desc)反过来证明了 double 是错的那一方,于是修 double 而不是改断言迁就它。
没有留下未测的预测。
corpus 计数(按 widening 纪律,先数再改)
例子仓里共 6 个 formula 字段(showcase
budget_remaining/f_formula,CRMis_closed/expected_revenue/days_to_close/full_name),没有任何一处按它们排序;例子应用里真正声明的排序目标是
budget(currency)、estimate_hours(number)、due_date(date),都是存储类型。所以这条拒绝在 corpus 上零误伤,它只拒绝本来就已经坏掉的调用。
Generated by Claude Code