从 #4674 的第 4 项拆出来——那一项写的是「值得单独决定」,这就是那个单独决定。#4720 修了两个内部站点,没有动这一类。
类修复保护的不是同一批人
#4674 提了两条路:
- 让 QueryAST 的排序 schema 在这条轴上拒绝未知键(
SortNodeSchema 加 .strict()),direction 变成 400;
- 或者让 normalizer 专门识别
direction,以一条点名 order 的消息拒绝它。
两条都住在入站 findData normalizer 上。而 #4674 自己的「为什么没被抓住」第 2 条已经确认:那条路径对这次的两个站点根本不运行——它们是协议直接调 this.engine.find,在自己的守卫内侧而非后面。
所以这个类修复不会抓住催生它的那个 bug。这不构成放弃它的理由,但它把要解决的问题换了一个:
| 调用方 |
被执行的渠道 |
现状 |
| 内部(协议、插件、包内代码) |
tsc — EngineQueryOptions.orderBy 是 SortNodeSchema[] |
#4720 已恢复(两处 as any 删除) |
外部(REST / RPC 上的 orderBy) |
normalizer |
仍然静默丢弃 direction |
对内部调用方,真正的教训是「别擦除类型」,而不是「加个运行时闸」。本单只关心第二行。
决定点
SortNodeSchema 不是 .strict(),所以任何未知键都被丢掉,而不是被标记。给外部调用方发一个 { field: 'updated_at', direction: 'desc' },他们得到的是一个看起来成功的升序响应——带 limit 时还意味着返回了另一批行。没有任何信号。
两个选项的代价不同:
(a) SortNodeSchema 加 .strict() —— 覆盖面完整,但这是对已发布线协议的破坏性变更:今天任何在排序节点里多带一个键的客户端会开始收到 400。而且它把所有未知键一视同仁,而 direction 是唯一一个有已知正确译法的。
(b) 专门识别 direction,以点名 order 的消息拒绝 —— 窄,不破坏无关的多余键,并且送达一条处方而不只是一个拒绝。这正是 retiredKey() 的做法(通过一次 parse 把修复建议交到调用方手里),区别在于 direction 从来不是我们这条轴上的键,所以「墓碑」不是准确的词——它是一条外来词汇提示。
倾向 (b),但 (a) 的「未知键一律拒绝」在别处是这个仓的既有姿态(#4371 让引擎拒绝未声明的选项键——只是在顶层,没有递归进排序节点),所以这里存在一致性论据,值得一并权衡。
为什么值得做
direction 不是一个凭空的拼写错误,它是同一个概念的两套活词汇:
SortNodeSchema — { field, order } — QueryAST / EngineQueryOptions(query.zod.ts:12)
IReportService.orderBy — { field, direction? } — 一份真正不同的契约(report-service.ts:29)
plugin-auth/objectql-adapter.ts:536 已经显式地在两者间翻译。也就是说这个翻译是已知必需的,只是没有在任何地方被强制;调用方忘了翻译时,结果是一个静默的错误答案而不是一个错误。这就是 ADR-0049 的形状。
顺带一提:一个更便宜的内部护栏
如果要收紧内部这一侧,能想到的最小手段是一条 lint 规则,禁止对引擎查询选项做 as any / : any —— 是类型擦除而不是缺少运行时闸让 #4674 溜过去的。这是否值得单开一单,取决于树里这种擦除还有多少。
关联:#4674、#4720、#4363、#4371、ADR-0049
从 #4674 的第 4 项拆出来——那一项写的是「值得单独决定」,这就是那个单独决定。#4720 修了两个内部站点,没有动这一类。
类修复保护的不是同一批人
#4674 提了两条路:
SortNodeSchema加.strict()),direction变成 400;direction,以一条点名order的消息拒绝它。两条都住在入站
findDatanormalizer 上。而 #4674 自己的「为什么没被抓住」第 2 条已经确认:那条路径对这次的两个站点根本不运行——它们是协议直接调this.engine.find,在自己的守卫内侧而非后面。所以这个类修复不会抓住催生它的那个 bug。这不构成放弃它的理由,但它把要解决的问题换了一个:
tsc—EngineQueryOptions.orderBy是SortNodeSchema[]as any删除)orderBy)direction对内部调用方,真正的教训是「别擦除类型」,而不是「加个运行时闸」。本单只关心第二行。
决定点
SortNodeSchema不是.strict(),所以任何未知键都被丢掉,而不是被标记。给外部调用方发一个{ field: 'updated_at', direction: 'desc' },他们得到的是一个看起来成功的升序响应——带limit时还意味着返回了另一批行。没有任何信号。两个选项的代价不同:
(a)
SortNodeSchema加.strict()—— 覆盖面完整,但这是对已发布线协议的破坏性变更:今天任何在排序节点里多带一个键的客户端会开始收到 400。而且它把所有未知键一视同仁,而direction是唯一一个有已知正确译法的。(b) 专门识别
direction,以点名order的消息拒绝 —— 窄,不破坏无关的多余键,并且送达一条处方而不只是一个拒绝。这正是retiredKey()的做法(通过一次 parse 把修复建议交到调用方手里),区别在于direction从来不是我们这条轴上的键,所以「墓碑」不是准确的词——它是一条外来词汇提示。倾向 (b),但 (a) 的「未知键一律拒绝」在别处是这个仓的既有姿态(#4371 让引擎拒绝未声明的选项键——只是在顶层,没有递归进排序节点),所以这里存在一致性论据,值得一并权衡。
为什么值得做
direction不是一个凭空的拼写错误,它是同一个概念的两套活词汇:SortNodeSchema—{ field, order }— QueryAST /EngineQueryOptions(query.zod.ts:12)IReportService.orderBy—{ field, direction? }— 一份真正不同的契约(report-service.ts:29)plugin-auth/objectql-adapter.ts:536已经显式地在两者间翻译。也就是说这个翻译是已知必需的,只是没有在任何地方被强制;调用方忘了翻译时,结果是一个静默的错误答案而不是一个错误。这就是 ADR-0049 的形状。顺带一提:一个更便宜的内部护栏
如果要收紧内部这一侧,能想到的最小手段是一条 lint 规则,禁止对引擎查询选项做
as any/: any—— 是类型擦除而不是缺少运行时闸让 #4674 溜过去的。这是否值得单开一单,取决于树里这种擦除还有多少。关联:#4674、#4720、#4363、#4371、ADR-0049