Skip to content

fix(analytics,rest): analytics 的 filter 拒收带上 ADR-0112 信封,REST 面先读信封 —— 400 INVALID_FILTER 而不是 500 (#5352) - #5366

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5352-analytics-filter-refusal-envelope
Aug 5, 2026
Merged

fix(analytics,rest): analytics 的 filter 拒收带上 ADR-0112 信封,REST 面先读信封 —— 400 INVALID_FILTER 而不是 500 (#5352)#5366
os-zhuang merged 2 commits into
mainfrom
claude/issue-5352-analytics-filter-refusal-envelope

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Fixes #5352

作者在 dashboard widget 的筛选里拼错一个算子,analytics 响亮拒收 —— 这是 #3948 / #5240 / #5325 / #5334 一路建立的姿态,正确。但这个拒收到不了作者:它以 500 ANALYTICS_QUERY_FAILED 落地,读作「平台炸了」而不是「你的筛选写错了」,运维告警按 5xx 计。同一个错误走 find()#3948 起就答 400 INVALID_FILTER —— 一个作者错误,两种线上形状,取决于哪张脸接住了它。

这一单是让前面那几单的拒收真正到达作者的最后一段。


一、现场核对(两次同文件 churn 之后)

worktree 基于 origin/maine18e3da77(含 #5325/PR #5335#5334/PR #5355,即 b8573561e),逐条重核 issue 引用的四处拒收。结论:四条全部仍在,措辞未变,但文件里实际有 9 处拒收,不是 4 处。

# 拒收 改前形状 issue 是否列出
1 Unsupported filter operator "$foo" on "col" Error
2 "col" carries a field constraint with zero operators ({})(#5240) Error
3 "$between" on "col" needs a two-element [min, max] array Error
4 "$and"/"$or" requires a non-empty array Error ✅(第 4 条 bullet)
5 "$and"/"$or" branches must be filter objects Error ✅(同上,一条 bullet 盖两处)
6 "$not" requires a filter object Error
7 Unsupported top-level filter operator "$foo" Error
8 received a 'where' array that is not a filter(#5334) 已带信封
9 isFilterAST/parseFilterAST 分歧(#5334) 已带信封

issue 正文的四条 bullet 覆盖 1–5(第四条 bullet 一句话盖了 4 和 5),漏了 6 和 7。6 和 7 一并信封化了,理由不是顺手:半信封化的模块在 REST 边界上与完全没信封无法区分 —— 写 {$not: 5} 的作者会拿到 500,写 {$nott: {...}} 的作者拿到 400,这正是本单要消灭的「一个条件两种形状」在文件内部复现。两处都在 filter-normalizer.ts 内、都只改 code/status,没有越过硬禁区。

invalidFilterError 沿用 #5334 已落的那个,没有另造第三套;只是把它从「#5334 的 FilterArray 门」小节提到模块顶部,并写明它现在是本模块唯一的拒收方式(新增裸 throw new Error 就是缺陷回归)。

二、半 B —— 生产面

packages/services/service-analytics/src/strategies/filter-normalizer.ts:9 处拒收全部经 invalidFilterErrorINVALID_FILTER / 400。

三、半 A —— 消费面

packages/rest/src/rest-server.ts,analytics dataset 路由的 catch:先读 error.status / error.code,有信封按信封答;正则名单降级为兜底。

两个刻意的收紧,都写进了代码注释:

  • 必须两半齐全(4xx status 非空 code)才走信封分支。只有 status 没有 code 的话,这里就得自己发明一个 code —— 那正是 ADR-0112 要消灭的消费方宽容;半个信封是生产方的 bug,应该被发现,不该在这里被抹平。
  • 只放行 4xx。5xx-status 的错误继续走 ANALYTICS_QUERY_FAILED 信封(并保留 logError),这样内部故障永远不会被重新贴成调用方的错、也不会绕过运维可见的那行日志。

正则名单的处置:整条留着,逐条给了证据

PM 要求「若判断名单里某几条其实已带信封,逐条给证据再删」。逐条核过了 —— 六条全部仍是裸 throw new Error(...),一条都删不掉:

正则片段 生产方 形状
not declared in the dataset dataset-compiler.ts:305 throw new Error(
not backed by a declared relationship strategies/native-sql-strategy.ts:222 throw new Error(
not supported by the v1 dataset runtime dataset-compiler.ts:137 throw new Error(
read-scope-sql read-scope-sql.ts(73/107/113/144/163/171/195/200/205/215) 全部 throw new Error(
not a selected dimension or measure dataset-executor.ts:436 throw new Error(
is not a subset of the selected dimensions dataset-executor.ts:596 throw new Error(

删掉名单会让这六族从 400 DATASET_INVALID 退化成 500。名单原地保留,并加了注释写明:它是过渡态,是这些家族信封化之前的占位,不是第二套分类机制;新增拒收请带 code/status,信封分支免费服务它。

顺带被修好的两条(不是范围外,是同一个缺陷)

信封是泛化读取的,不是 if (code === 'INVALID_FILTER') 白名单 —— 那种写法就是 message 嗅探换了身衣服。于是另外两个生产方早已声明、这条路由一直在丢弃的信封也自动到位了:

也就是说这条路由丢的不是一个信封,是三个

四、只改形状,不改判定 —— 证明

没有动任何拒收的判定条件:没有输入从「通过」变成「被拒」,也没有从「被拒」变成「通过」。

证明方式是 filter-refusal-envelope.test.ts 里两个互相独立的 describe:

反向验证的输出见下表,可以直接读出「只有信封断言在动」。

五、测试

反向验证(stash 掉两处源码改动,重建 dist,跑新用例)

@objectstack/service-analytics(28 例):

 Test Files  1 failed (1)
      Tests  8 failed | 20 passed (28)

AssertionError: a refusal with no `code` lands as 500 ANALYTICS_QUERY_FAILED:
  expected undefined to be 'INVALID_FILTER'
- Expected: "INVALID_FILTER"
+ Received: undefined

8 条失败 = 7 处裸 Error + ...(第 9 处 isFilterAST 分歧分支不可达);第 8 处(#5334 数组门)本来就带信封,所以通过。「拒收集合不变」那 20 条全绿 —— 改前就绿,改后也绿。

@objectstack/rest(19 例):

 Test Files  1 failed (1)
      Tests  9 failed | 10 passed (19)

FAIL > a misspelled operator in a widget filter → 400 INVALID_FILTER (was 500 ANALYTICS_QUERY_FAILED)
AssertionError: expected 500 to be 400

FAIL > a measure over a field the object does not have → 400 INVALID_FIELD (#4437)
AssertionError: expected 500 to be 400

FAIL > an unregistered cube → 404 CUBE_NOT_FOUND (#3867)
AssertionError: expected 500 to be 404

expected 500 to be 400 —— 缺陷本身,一字不差。通过的那 10 条正是三类回归护栏(正则名单仍答 DATASET_INVALID、内部错误仍 500、5xx/半信封不放行)+ 200 正控,它们改前改后都绿。

端到端(本单的验收面)

packages/rest/src/analytics-filter-refusal-envelope.test.ts 的 provider 是真的 AnalyticsService,不是 mock —— 缺陷正在两层的接缝处,任何一侧 mock 掉都等于把另一侧的正确性当假设。为此给 @objectstack/rest 加了 @objectstack/service-analyticsdevDependency(沿用该包已有的先例:@objectstack/objectql / @objectstack/metadata-protocol 也只在集成测试里用,import-job-integration.test.ts / export-integration.test.ts)。无依赖环:service-analytics 只依赖 core + spec。

打进去的是 selection.runtimeFilter —— presentation-scope 筛选,正是 dashboard widget 携带、作者会打错的那个字段。

用例 断言
runtimeFilter: {stage: {$sortOf: 'won'}} HTTP 400,body.code === 'INVALID_FILTER',且显式断言 不是 500 / 不是 ANALYTICS_QUERY_FAILED,message 仍点名 $sortOf
正控:runtimeFilter: {stage: {$eq: 'won'}} HTTP 200 + rows —— 否则上一条可能因为「根本没跑到 normalizer」而假绿
另外 6 种拒收拼法({} / $between 单边 / 空 $or / $or 非对象分支 / $not 非对象 / 顶层未知算子) 各自 400 + INVALID_FILTER
正则名单 6 条各造一个裸 Error 仍然 400 + DATASET_INVALID
ECONNRESET: socket hang up … 仍然 500 + ANALYTICS_QUERY_FAILED
status: 503 + code: 'WAREHOUSE_UNAVAILABLE' 仍然 500 + ANALYTICS_QUERY_FAILED(5xx 不放行)
半信封:status: 400,无 code 仍然 500(本路由不发明 code)
INVALID_FIELD / 400、CUBE_NOT_FOUND / 404 按信封答 400 / 404

连带面全量(合入 origin/main 之后重跑)

@objectstack/service-analytics   Test Files  46 passed (46)   Tests  723 passed (723)
@objectstack/rest               Test Files  41 passed (41)   Tests  627 passed (627)

turbo typecheck --filter=@objectstack/rest --filter=@objectstack/service-analytics --forceTasks: 17 successful, 17 total,0 error TS。
eslint --no-inline-config packages/rest/src packages/services/service-analytics/src → 0 problem,无 as any 新增(测试里的 logger 用了真实 Logger 类型)。
check:error-code-casing ✓ / check:route-envelope ✓(rest-server.ts 不在该 gate 的审计表内)。

本地跳过 / CI 才跑的盲区

如实核查:grep -rn "skipIf|describe.skip|it.skip|test.skip|todo(|.only("packages/rest/srcpackages/services/service-analytics/src零命中。两个包没有条件跳过的用例,上面的数字就是全部。

六、可观察的行为变更(changeset 里也如实写了)

同一个请求,500 ANALYTICS_QUERY_FAILED400 INVALID_FILTER(以及 400 INVALID_FIELD / 404 CUBE_NOT_FOUND)。按 5xx 做告警的会看到 5xx 率下降、4xx 率出现;按 5xx 做重试的会停止重试一个重试多少次都一样的请求。 两者都是本单想要的纠正 —— 这个条件从来就是调用方的错误 —— 但它们是可见的,所以写明而不是埋掉。

七、范围外

  • 未触碰 filter-normalizer.ts 以外的 analytics 编译逻辑、driver-sql/**driver-memory/**memory-analytics.tspackages/spec/**content/docs/releases/**
  • 正则名单那六族的信封化按 PM 指示不在本单范围,名单留作兜底并加注释指向本单。没有另开 issue:这不是新发现,analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500 #5352 正文与 PM 认领评论都已把它记为本单的显式非目标,再开一条只会是重复。
  • 一个构建副作用值得知道(不是本 PR 的改动):在本地 build @objectstack/spec 会让 gen:schemapackages/spec/authorable-surface.base.jsonbaseRev 重锚到当前 HEAD 并删掉 ~110 个 key。每次跑完重命令都已 git checkout -- 还原,最终 diff 不含该文件。属 AGENTS.md §9/§11 已知的生成物类别。

Generated by Claude Code

claude added 2 commits August 5, 2026 01:31
…s 400 INVALID_FILTER (#5352)

A misspelled operator in a dashboard widget's filter is refused by
`filter-normalizer.ts` — correctly — but the refusal never reached the author:
it landed as `500 ANALYTICS_QUERY_FAILED`, read as "the platform is broken"
rather than "your filter has a typo", and counted by ops alerting as a 5xx. The
identical mistake on `find()` has answered `400 INVALID_FILTER` since #3948.

One defect with two halves; either alone leaves it unfixed.

Producer — `filter-normalizer.ts`: seven of its nine refusals were bare
`throw new Error(…)` with no `code`/`status`, so the REST face had nothing to
read. All nine now go through the `invalidFilterError` helper #5334 introduced
(INVALID_FILTER / 400), which becomes the module's only way to refuse. Two of
the seven (`{$not: <non-object>}`, an unsupported TOP-LEVEL operator) were not
among the issue's four bullets; enveloping only the listed five would have left
two spellings of the same authoring mistake answering 500 next to neighbours
answering 400.

Consumer — `rest-server.ts`, `POST /analytics/dataset/query`: the catch
discarded `error.code`/`error.status` and re-derived the classification from a
hardcoded list of message substrings. It now reads the envelope first, and the
substring list is demoted to a documented transitional fallback. All six of its
entries were re-verified as bare `Error`s, so none could be deleted. The
passthrough is 4xx-only and requires both `code` and `status`: an internal fault
can never be re-labelled as the caller's fault, and this route invents no code a
producer failed to supply.

Which inputs are refused did not change — only the shape of the error. Pinned
input-by-input, refusals and accepted inputs (with their compiled trees) alike,
in `filter-refusal-envelope.test.ts`, which is green both before and after.

The REST-side test drives the real `AnalyticsService` rather than a mock,
because the defect lives at the seam: a mock on either side makes the other
half's correctness an assumption.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pbu27iNUfQCHeuS551Rqo7
@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 5, 2026 1:42am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file tests tooling size/l labels Aug 5, 2026
@github-actions

github-actions Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/rest, @objectstack/service-analytics.

15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/connect-mcp.mdx (via @objectstack/rest)
  • content/docs/api/data-api.mdx (via @objectstack/service-analytics)
  • content/docs/api/error-handling-server.mdx (via @objectstack/rest)
  • content/docs/api/index.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/service-analytics)
  • content/docs/permissions/authentication.mdx (via @objectstack/rest)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/service-analytics)
  • content/docs/plugins/index.mdx (via @objectstack/rest)
  • content/docs/plugins/packages.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/rest)
  • content/docs/protocol/kernel/i18n-standard.mdx (via packages/rest)
  • content/docs/releases/implementation-status.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/releases/v12.mdx (via @objectstack/rest)
  • content/docs/releases/v17.mdx (via @objectstack/rest, @objectstack/service-analytics)
  • content/docs/releases/v9.mdx (via @objectstack/service-analytics)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

Copy link
Copy Markdown
Contributor Author

勘误 —— 正文「五、测试」里反向验证那句的计数写糊了

正文写的是「8 条失败 = 7 处裸 Error + ...」,后半句没写完,而且读起来像 8 ≠ 7 的矛盾。准确的对应关系是:

反向验证里 service-analytics 侧 9 条拒收用例失败 8 条,这 8 条覆盖的是 7 个不同的裸 throw new Error 代码点 —— {$and: []}{$or: []} 是两条用例,但打在同一个代码点上(buildNodekey === '$and' || key === '$or' 的那一处空数组拒收)。

剩下的对应:

所以:9 处拒收 = 7 个裸 Error 代码点(8 条用例)+ 1 处已带信封(1 条用例)+ 1 处不可达(无用例)。改动后 9 处全部经 invalidFilterError

数字本身没错,句子没写完。代码与测试均无需改动。


Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review August 5, 2026 01:57
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 2f6516e Aug 5, 2026
25 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-5352-analytics-filter-refusal-envelope branch August 5, 2026 02:05
os-zhuang pushed a commit that referenced this pull request Aug 5, 2026
- filter.zod.ts:按 #5323 同步散文预留的交接("The declaration flips to
  stated contract with that PR"),空组合子单位元从「Deliberately NOT
  declared」段转为正式契约段;{field:{}} 半边保持未宣告(#5376 仍开)。
- filter-refusal-envelope.test.ts(#5366 新到):空数组两行从 REFUSALS 翻入
  ACCEPTED(单位元树断言),同一守卫点的非数组拼写补位 REFUSALS,信封不变。
- filter-logic-conformance.ts:族 1 段落按分工删除(四行已进表),族 2/3 原样。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ErbEDVAg1No9gdg1pgDAGB
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

analytics 的 filter 拒收到不了调用方:service 侧多数拒收没有 ADR-0112 信封,REST 面又用 message 正则嗅探,一律答 500

2 participants