fix(data): sort 的点号路径被拒绝(400 INVALID_SORT),不再静默降级为不排序 (#4256) - #4303
Merged
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
This was referenced Aug 1, 2026
Closed
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 三仓做了穷尽调查:
sys_saved_report种子里全部是平面字段名。accessorKey: fieldName),关联数据走独立的$expand参数嵌在同一平面 key 下;SortBuilder 的字段选项来自objectDef.fields的平面枚举,无自由输入。通用表格的列头排序是纯客户端的,不出网。因此不需要 deprecation 轮次,直接拒绝的爆炸半径就是手写请求 —— 恰好是被静默降级误导的那批调用方。
残余风险(grep 覆盖不到的):客户库里的存量视图元数据。
ObjectView会把工具栏排序持久化进保存视图,sys_saved_report存用户排序;但由于没有任何 UI 路径和种子能写出点号值,存量里存在点号 sort 的唯一途径是管理员在 Studio 元数据管理里手改 JSON(SchemaForm的 repeater 是裸文本输入)。若要绝对确定,可在生产库对保存视图/报表做一次一次性查询。实现
assertSortFieldsExist(packages/metadata-protocol/src/protocol.ts)在原有 unknown-field 检查之后加点号检查,消息按头段区分两种误解: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 实测表同法)
测试与文档
query-expression-conformance.test.ts新增 12 个用例:七种 wire 拼写的点号拒绝、关系/非关系两种消息、typo 优先级、裸外键控制组、topfootgun 收口。query-syntax.mdx§3 告警改写(「仍静默降级」的前提已不成立)、queries.mdxinfo callout 与data-api.mdx参数表/示例表补点号形态。@objectstack/metadata-protocolpatch。关联
#4256 / #4226(PR #4240)/ #3821 / #3948 / #4294(joins tombstone)
🤖 Generated with Claude Code