发现于 #3431 的读路径排查(该 PR 只动 packages/core/src/utils/filter-converter.ts,本条越界,单独记录)。
位置
packages/plugin-list/src/UserFilters.tsx,specOperatorToAst()(约 105 行),被 normalizeTabPresets() 用来把 spec ViewTab.filter 的 ViewFilterRule[] 折成 ObjectQL 三元组:
case 'not_in': case 'nin': return 'not in';
'not in'(中间带空格)不在 @objectstack/spec/data 的 VALID_AST_OPERATORS 里 —— 该集合有 not_in / notin / nin,没有 not in。于是 isFilterAST 判否,协议层直接 400。
实证(真实后端,@objectstack/*@17.0.0-rc.2 + app-showcase)
GET /api/v1/data/showcase_task?$filter=[["status","not in",["done"]]]
-> HTTP 400 {"error":"Request failed","code":"INVALID_FILTER","object":"showcase_task"}
GET /api/v1/data/showcase_task?$filter=[["status","not_in",["done"]]]
-> HTTP 200(正常返回)
即:任何作者在 userFilters.tabs[].filter / ViewTab.filter 里写 operator: 'not_in'(spec 的 VIEW_FILTER_OPERATORS canonical 拼法之一)或历史拼法 nin,该 tab 一点就是空列表 + 400。showcase / CRM 现有 metadata 都没用到 not_in,所以线上没人踩到 —— 但这是作者面上一个正常拼法,不是内部值。
更根本的一层:这是第二张手工算子表
specOperatorToAst 与 spec 自己导出的 normalizeFilterOperator(@objectstack/spec/ui)职责重叠。写入侧 app-shell/src/views/viewFilterFold.ts 已经明确改成只走 spec 的这一个出口,并在注释里写清了原因:Studio inspector 里那张私有表曾经比 builder 的下拉落后四个算子。specOperatorToAst 就是同一类私有表的又一份,not in 正是它已经漂出去的证据(另外 ends_with / contains / between 等只靠 default: return op 兜底,能过纯属巧合)。
顺带说明为什么 canonical 表其实不需要存在:spec 的 19 个 VIEW_FILTER_OPERATORS 全部已经是 VALID_AST_OPERATORS 的成员,所以 rule → AST 的下降是纯结构性的,算子只需过一遍 normalizeFilterOperator 即可,不需要任何映射表。#3431 的 core 侧折叠就是这么做的。
建议方向(不预设结论,交 PM/维护者裁决)
把 normalizeTabPresets 里的下降改为复用 @objectstack/spec/ui 的 normalizeFilterOperator(与写入侧、与 #3431 的读侧同一个出口),删掉 specOperatorToAst;spec 不认识的拼法原样透传,让服务端 400 大声拒绝,而不是被 default 悄悄放行成别的东西。
需要留意的一处差异:现表把 before/after 映射成 </>,而 before/after 本身也在 VALID_AST_OPERATORS 里 —— 改成透传前要确认服务端对这两个算子的日期语义与 </> 一致,否则是行为变更而不是纯修复。
相关:#3431(读路径的 ViewFilterRule[] 折叠)、objectstack#5159(写入侧折叠)、objectstack#4121(数组型 $filter 的 400)。
发现于 #3431 的读路径排查(该 PR 只动
packages/core/src/utils/filter-converter.ts,本条越界,单独记录)。位置
packages/plugin-list/src/UserFilters.tsx,specOperatorToAst()(约 105 行),被normalizeTabPresets()用来把 specViewTab.filter的ViewFilterRule[]折成 ObjectQL 三元组:'not in'(中间带空格)不在@objectstack/spec/data的VALID_AST_OPERATORS里 —— 该集合有not_in/notin/nin,没有not in。于是isFilterAST判否,协议层直接 400。实证(真实后端,
@objectstack/*@17.0.0-rc.2+ app-showcase)即:任何作者在
userFilters.tabs[].filter/ViewTab.filter里写operator: 'not_in'(spec 的VIEW_FILTER_OPERATORScanonical 拼法之一)或历史拼法nin,该 tab 一点就是空列表 + 400。showcase / CRM 现有 metadata 都没用到not_in,所以线上没人踩到 —— 但这是作者面上一个正常拼法,不是内部值。更根本的一层:这是第二张手工算子表
specOperatorToAst与 spec 自己导出的normalizeFilterOperator(@objectstack/spec/ui)职责重叠。写入侧app-shell/src/views/viewFilterFold.ts已经明确改成只走 spec 的这一个出口,并在注释里写清了原因:Studio inspector 里那张私有表曾经比 builder 的下拉落后四个算子。specOperatorToAst就是同一类私有表的又一份,not in正是它已经漂出去的证据(另外ends_with/contains/between等只靠default: return op兜底,能过纯属巧合)。顺带说明为什么 canonical 表其实不需要存在:spec 的 19 个
VIEW_FILTER_OPERATORS全部已经是VALID_AST_OPERATORS的成员,所以 rule → AST 的下降是纯结构性的,算子只需过一遍normalizeFilterOperator即可,不需要任何映射表。#3431 的 core 侧折叠就是这么做的。建议方向(不预设结论,交 PM/维护者裁决)
把
normalizeTabPresets里的下降改为复用@objectstack/spec/ui的normalizeFilterOperator(与写入侧、与 #3431 的读侧同一个出口),删掉specOperatorToAst;spec 不认识的拼法原样透传,让服务端 400 大声拒绝,而不是被default悄悄放行成别的东西。需要留意的一处差异:现表把
before/after映射成</>,而before/after本身也在VALID_AST_OPERATORS里 —— 改成透传前要确认服务端对这两个算子的日期语义与</>一致,否则是行为变更而不是纯修复。相关:#3431(读路径的
ViewFilterRule[]折叠)、objectstack#5159(写入侧折叠)、objectstack#4121(数组型$filter的 400)。