Skip to content

fix(data): sort 的点号路径被拒绝(400 INVALID_SORT),不再静默降级为不排序 (#4256) - #4303

Merged
os-zhuang merged 3 commits into
mainfrom
claude/sort-dot-path-fallback-fc72e9
Jul 31, 2026
Merged

fix(data): sort 的点号路径被拒绝(400 INVALID_SORT),不再静默降级为不排序 (#4256)#4303
os-zhuang merged 3 commits into
mainfrom
claude/sort-dot-path-fallback-fc72e9

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #4256

TL;DR

#4226 收口后唯一漏网的 sort 形态补上了:?sort=account.company_name(点号路径)现在在共享 normalizer 处被拒 —— 400 INVALID_SORT,消息说明它想跨的关系、以及正确做法(用公式/汇总字段把值反范式化到本对象再排)。此前它是 200 + 未排序行:头段是真实字段所以过闸,然后 SqlDriver 渲染出 "account"."company_name" 撞上从未 join 的表,#3821 兜底丢掉排序重跑;Mongo / memory driver 则把路径解析到行内,而外键是标量 id,排序照样无效。sort + top 的「最新 N 条」于是变成任意 N 条,响应里没有任何信号。

采用 issue 的方案 1(ingress 拒绝),决策依据

Issue 要求先量影响面再定。已对 framework / objectui / cloud 三仓做了穷尽调查:

  • 真实调用方发出点号 sort 的数量:0。 视图/报表/仪表盘元数据、examples、seeds、sys_saved_report 种子里全部是平面字段名。
  • objectui 结构上产生不了点号 sort key:lookup 列按自己的平面字段名作为列 key(accessorKey: fieldName),关联数据走独立的 $expand 参数嵌在同一平面 key 下;SortBuilder 的字段选项来自 objectDef.fields 的平面枚举,无自由输入。通用表格的列头排序是纯客户端的,不出网。
  • cloud 全部 ~25 处 sort 均为平面单字段。
  • 测试无一依赖点号 sort(无论断言丢弃还是依赖功能),文档也没有任何一页在教它 —— 仅有的两处提及都是警告。

因此不需要 deprecation 轮次,直接拒绝的爆炸半径就是手写请求 —— 恰好是被静默降级误导的那批调用方。

残余风险(grep 覆盖不到的):客户库里的存量视图元数据。ObjectView 会把工具栏排序持久化进保存视图,sys_saved_report 存用户排序;但由于没有任何 UI 路径和种子能写出点号值,存量里存在点号 sort 的唯一途径是管理员在 Studio 元数据管理里手改 JSON(SchemaForm 的 repeater 是裸文本输入)。若要绝对确定,可在生产库对保存视图/报表做一次一次性查询。

实现

assertSortFieldsExistpackages/metadata-protocol/src/protocol.ts)在原有 unknown-field 检查之后加点号检查,消息按头段区分两种误解:

  • 头段 reference 字段(project_id.name)→ 指名它想跨的关系,处方是反范式化(公式/汇总字段);
  • 头段不是title.length)→ 陈述契约:sort 只作用于整列,不进入列值内部。

未知头段(no_such.title)保持 #4226 的 typo 答案且优先报告 —— 与 expand gate 的 unknown > not-a-reference 优先级一致。仅注册表有字段表时生效(与所有 #4226 gate 相同的分层);直接调 engine.find() 的内部调用方不受影响。expand 子查询的嵌套 orderBy 不经过顶层 gate,无误伤。

顺带查证:QueryAST 的显式 joins(点号排序唯一可能合法的场景)已在 #4294 被 tombstone —— 「join 别名排序」这个合法形态不存在。

实测(真引擎 + 真 registry,showcase fresh 实例,与 issue 实测表同法)

?sort=project.name      -> 400 INVALID_SORT  「follows the relationship 'project'…Denormalise…」  ✅(修复前:200 + 未排序)
?sort=no_such.title     -> 400 INVALID_SORT  「not a field on object…」                           ✅(不变)
?sort=-title            -> 200,正确排序                                                          ✅(控制组)
?sort=project           -> 200(裸外键列仍可排)                                                   ✅(控制组)
?sort=title.length      -> 400 INVALID_SORT  「whole columns, not values inside them」            ✅

测试与文档

  • query-expression-conformance.test.ts 新增 12 个用例:七种 wire 拼写的点号拒绝、关系/非关系两种消息、typo 优先级、裸外键控制组、top footgun 收口。
  • 受影响包全绿:metadata-protocol 122 / objectql 1357 / rest 521。全仓套件在本机 turbo 并行下有资源型超时抖动(五次失败为五个不同包,含 auth 注册测试 10s 超时,transform 124s —— 均单独复跑全绿),以远端 CI 为准。
  • 文档:query-syntax.mdx §3 告警改写(「仍静默降级」的前提已不成立)、queries.mdx info callout 与 data-api.mdx 参数表/示例表补点号形态。
  • changeset:@objectstack/metadata-protocol patch。

关联

#4256 / #4226(PR #4240)/ #3821 / #3948 / #4294(joins tombstone)

🤖 Generated with Claude Code

os-zhuang and others added 2 commits July 31, 2026 12:40
The one sort shape #4226 deliberately left open. `?sort=account.company_name`
passed the gate on its head segment (a real field) and was then unusable by
every driver: SqlDriver rendered `"account"."company_name"` against a table
that was never joined and the #3821 backstop retried WITHOUT the sort; Mongo
and the memory driver resolved the path against the row, where a foreign key
is a scalar id. 200, every row present, arbitrary order — which `top` then
sliced into an arbitrary "latest N".

Now `400 INVALID_SORT` at the shared normalizer, with the message split by
what the head segment is: a relationship head names the relation it tried to
cross and prescribes denormalising the value onto the queried object (formula
/ rollup field); a non-reference head states the contract (sort reaches whole
columns, not values inside them). Unknown heads keep the #4226 typo answer,
reported first — the same precedence the expand gate uses.

A survey of framework, objectui and cloud found zero callers emitting a
dotted sort; objectui's column-header sort structurally cannot produce one
(lookup columns are keyed by their flat field name, relations load via
$expand). Blast radius is hand-authored requests — exactly the callers the
silent degradation was misleading.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 31, 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 Jul 31, 2026 5:25am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)

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.

@os-zhuang
os-zhuang merged commit a62bd9e into main Jul 31, 2026
17 checks passed
@os-zhuang
os-zhuang deleted the claude/sort-dot-path-fallback-fc72e9 branch July 31, 2026 05:35
os-zhuang pushed a commit that referenced this pull request Aug 1, 2026
…ECORD_NOT_FOUND, not 200 (#4435)

The READ path was already honest — `getData` on an unknown id answers `404
RECORD_NOT_FOUND`. Both single-record WRITE paths reported success for a record
that does not exist:

  PATCH  /data/showcase_task/definitely_not_a_row  → 200 {"record":null}
  DELETE /data/showcase_task/definitely_not_a_row  → 200 {"success":true}

REST is a pass-through here (`res.json(await p.deleteData(...))`), so these are
the protocol's answers and this is where they are fixed.

What it cost: a client that PATCHed a concurrently deleted record was told the
write landed, and had to null-check a SUCCESS payload to find out otherwise;
`DELETE` said `success: true` for any string in the path, so a typo'd id, an
already-deleted row and a real deletion were indistinguishable — including in
bulk, where `deleteMany {"ids":["nonexistent_1"]}` answered `succeeded: 1`. It
is the same silent-no-op shape the v17 train removed everywhere else this
window (#4240/#4303/#4315, #4169, #4190), one level up.

- `updateData` asks existence BEFORE the write, via the same `findOne` +
  caller context `getData` uses. Deliberately not a post-check on the returned
  row: the engine returns the post-write READBACK, which is also `null` when
  the row still exists but the write moved it out of the caller's row scope
  (reassigning `owner_id` away from yourself under an owner-scoped policy) —
  reading that as "not found" would 404 a write that succeeded.
- `deleteData` and `deleteManyData` read the driver's own answer. The contract
  (`IDataDriver.delete` — "True if deleted, false if not found") already
  carried it; the code discarded it and pushed a literal `success: true`.
  Read as `=== false` on purpose: that is the contract's positive not-found
  value, while a driver returning the deleted row or an off-contract
  `undefined` gives no such signal, and inventing a 404 from a falsy return
  would break deletes against third-party drivers instead of reporting
  honestly. `success` on the 200 now means what it says.
- The 404 envelope is extracted as `recordNotFoundError` so the read and the
  two write paths cannot drift apart again.

Note on the issue's second half: the spec's `DeleteDataResponseSchema` declares
`success`, not `deleted`, so the existing key is correct as-is and nothing
renames.

Tests: new `protocol.record-not-found.test.ts` (12) covers PATCH/DELETE/
deleteMany, the read/write agreement on the same id, delete-twice, mixed
batches, the `=== false` reading, and that the existence probe is asked with
the caller's context. Three `protocol.dropped-fields.test.ts` fixtures stubbed
`findOne → null` while PATCHing — under the new contract that IS a 404, so they
now describe an engine that has the row (they are about the strip channel, not
about missing records). Suites green: metadata-protocol 169, rest + objectql
unchanged.

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sort 的点号路径(?sort=account.company_name)仍然静默降级为「不排序」——#4226 收口后唯一漏网的 sort 形态

1 participant