Skip to content

[client] data.find 的 canonical options 探测漏掉 limit;QueryOptionsV2.expand 声明了但从不落到传输参数 #6322

Closed

Description

@hotlong

事实(对 origin/main a682670 实测)

packages/client/src/index.ts 的 ObjectStackClient.data.find(:4081)靠一组「嗅探键」判断传入的是 canonical QueryOptionsV2 还是 legacy QueryOptions:

if ('where' in options || 'fields' in options || 'orderBy' in options || 'offset' in options) {

(:4089;ScopedProjectClient.data.find :4766 是逐字同形的第二份拷贝)

嗅探键只有四个 —— where / fields / orderBy / offset。limit 不在其中。

实测

vitest + stub fetch,读 find 真正发出的 query string(探针跑完即删,未入库):

传入 options 实际发出的 query string
{ where, orderBy, limit, offset, fields } top=20&skip=5&sort=-created_at&select=id%2Camount&contact_id=c1
{ filter, sort, top, skip, select } top=20&skip=5&sort=-created_at&select=id%2Camount&contact_id=c1
{ limit: 20 } (空)
{ top: 20 } top=20
{ offset: 5 } skip=5
{ where, limit: 20 } top=20&contact_id=c1
{ where, expand: ['contact'] } contact_id=c1

前两行是好消息:canonical 与 legacy 的五个配对键产出逐字节相同的传输参数,翻译层本身是对的。问题在另外两处。

A. { limit: N } 单键调用被静默丢弃

limit 不是嗅探键,于是走 legacy 分支的 Object.assign(:4100);而 legacy 分支此后只读 top / skip / sort / select / filter / filters / aggregations / groupBy(:4104-4150)—— limit 从头到尾没有任何一行读它。

调用方拿到的是服务端默认页大小,HTTP 200,无警告无报错。而 data.find('task', { limit: 20 }) 恰恰是 canonical 词汇下最自然的「取前 20 条」写法:不带 filter 的分页读取,一个键都不多写。

offset 单键({ offset: 5 })因为在嗅探键里,是正常的 —— 同一个接口的两个分页键行为不一致。

B. QueryOptionsV2.expand 声明了但从不映射

expand 在 :186 声明,JSDoc 说明是 "Relations to expand (JOIN / eager-load)"。但:

  • V2 分支逐键搬运 where / fields / orderBy / limit / offset / aggregations / groupBy(:4091-4097),没有 expand;
  • legacy 分支也接不住 —— QueryOptions 根本没有对应键(V2 的 JSDoc 说 expand "replaces legacy populate",而 QueryOptions 从来没有 populate)。

所以 expand 在两条分支上都落地为空,一个字符都到不了 wire。这不是服务端不认:REST 列表路由是处理 expand 的(参见 #4240 对 sort/select/expand 的字段校验)。是 SDK 单方面的 declared ≠ delivered(Prime Directive #10 的正例形状)。

为什么现在报

在 #6002(给 content/docs/kernel/runtime-services/data-service.mdx 的 Parameters 补 canonical 词汇)的实现过程中实测出来的。那一单的文件面只有 content/docs/**,且按 contract-first 不该在消费侧或文档侧糊补丁,所以 producer 侧的洞单独立单。

有一点值得 PM 权衡优先级时知道:#6002 的文档页从此会正面推荐 canonical 列(where / fields / orderBy / limit / offset),照抄这页的人会开始直接写 limit —— A 的命中面随之变大,而不是维持现状。

建议

不要逐个往嗅探列表里补键(补完 limit 还有 expand,下一个新键还会漏)。按「存在任一 canonical-only 键」判定 —— 即 where / fields / orderBy / limit / offset / expand 的全集,和 QueryOptionsV2 的声明同源,新增键自动在内。

expand 另按 ADR-0049 enforce-or-remove 二选一:补上映射,或从 QueryOptionsV2 摘掉。

两处 find 拷贝(:4081 / :4761)必须一起改 —— 它们目前逐字相同,只改一处就会分叉。

未认领,交 PM 分诊定级。

Activity

  1. claude commented on Aug 7, 2026

    @claude
    Contributor

    Triage: pm:queue + domain:cli + target:v17.

    Both halves re-verified on origin/main:

    • packages/client/src/index.ts:4089 — if ('where' in options || 'fields' in options || 'orderBy' in options || 'offset' in options). Four sniff keys; limit absent. The second copy at :4766 is byte-identical, confirming the body's warning that a one-site fix forks them.
    • :185-186 — /** Relations to expand (JOIN / eager-load). */ expand?: Record<string, any> | string[]; declared on QueryOptionsV2, and :172 documents it as replacing legacy populate. No transport mapping on either branch.

    Domain — domain:cli, anchored to the file, not the title. packages/client is the REST wire-protocol SDK and sits in the domain:cli package family. The word "client" in the title is not what decides this; packages/client/src/index.ts is.

    Release board (target:v17) — two independent criteria, either sufficient:

    • ② public contract declared ≠ enforced: expand is declared, typed, and JSDoc'd on the canonical options type and reaches the wire on neither branch. That is an API that lies about itself.
    • ① a user hits it today: data.find('task', { limit: 20 }) — the most natural canonical spelling of "first 20" — returns the server default page size with HTTP 200 and no warning. Silent wrong answer, not an error.

    Priority note the maintainer should have: the hit surface is about to grow, not hold. #6002 / PR #6324 puts the canonical vocabulary (where / fields / orderBy / limit / offset) into content/docs/kernel/runtime-services/data-service.mdx as the recommended column, so readers of that page start writing limit directly. Producer-side fix before the docs page teaches the shape at scale.

    Scope guidance carried into the queue (the body's own, endorsed): predicate on "any canonical-only key present" derived from QueryOptionsV2, rather than appending limit to a hand-maintained sniff list — the list has already failed twice (limit, then expand) and a per-key patch guarantees a third. expand separately needs an ADR-0049 call: map it or drop the declaration; a dev should pick "map it" only if the REST list route's expand handling (#4240) covers the shape, otherwise report back rather than inventing a transport spelling.

    Dedup: all 463 open issues and PRs paginated. No duplicate. Three neighbours, all distinct surfaces:

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    Claim: PM loop round 1 (domain:cli seat, 2026-08-08 shift)
    Session: session_017uFVNMmTxLpmfQYiuKM1Yx
    Branch: claude/issue-6322-client-find-canonical-keys
    Worktree: objectstack-issue-6322
    Domain: domain:cli
    File surface: packages/client/src/index.ts (both find copies, :4081 / :4761), client tests, one .changeset/*.md (stop on breach; explain in the report)
    Serial constraints cleared: no in-flight claims in this lane; no open PR touches packages/client/src/index.ts; #6002 / PR #6324 is docs-only (content/docs/**) and file-disjoint.


    Generated by Claude Code

  3. os-project-manager commented on Aug 8, 2026

    @os-project-manager
    Collaborator

    ACCEPT — PR #6480 (fix(client): derive data.find's canonical-key predicate from QueryOptionsV2, and map expand to the wire).

    What shipped. Both defects closed at the producer, in both find copies:

    • A. The branch predicate is no longer a hand-written key list. It is Exclude<keyof QueryOptionsV2, keyof QueryOptions> held as a Record<…, true>, so TypeScript rejects the object when a canonical-only key is missing and when a non-canonical-only key is present. The next key added to QueryOptionsV2 is a compile error until someone states what it puts on the wire. aggregations / groupBy drop out automatically — both option types declare them, so they cannot discriminate. This is what the queue guidance asked for instead of appending limit; a per-key patch would have been the third round of the same mistake.
    • B. expand now reaches the wire as expand=<comma-separated relation names>, and the Record form contributes its keys — the same names the server derives from the comma list.

    The PM's server-side assumption for expand was verified, not assumed — which is the part of this run worth naming. The dispatch said to check whether a documented wire spelling exists and to report back rather than invent one. Three independent pieces of evidence were produced: HttpFindQueryParamsSchema in packages/spec/src/api/protocol.zod.ts declaring expand on the GET list route as a comma-separated relation-name list, the normalizer in packages/metadata-protocol/src/protocol.ts splitting it on commas, and an existing packages/rest/src/rest.test.ts case driving expand: 'order,product'. Nothing was invented.

    Tests satisfy the multi-face clause. One expectation table in packages/client/src/client.test.ts, every row driven through both implementations via a shared driveBoth helper against the same expected string, so a future edit landing on one copy goes red. Expectations are exact full query strings rather than toContain — correctly reasoned, since the defect was a param that never appeared and a substring assertion on the params that did appear stays green through exactly that. The single-key sweep is typed Record<CanonicalOnlyKey, TransportRow> with the key set recomputed in the test from the two exported interfaces, so the anti-rot device exists on the test side too.

    Reverse verification with the direction predicted before running: 6 failed / 253 passed against unmodified origin/main, and the six were exactly the predicted set — including { limit: 20 } reporting expected '' to be 'top=20', reproducing the issue's empty-query-string measurement. The rows pinning the already-correct canonical/legacy translation parity were green before and after, confirming the PR does not touch what was already right.

    Reviewed and accepted as a deliberate behaviour change: a nested per-relation expand ({ contact: { fields: ['name'] } }) now throws instead of being silently trimmed. It has no GET spelling, so trimming would issue a wider read than the caller asked for and say nothing — the same silent-drop shape this PR exists to close, and in family with #4371's engine-side refusal. Blast radius measured at zero callers workspace-wide, and the form never worked, so nothing functioning today starts failing. The alternative reading (narrow the declared type to string[] under ADR-0049) is a public-type call correctly left unmade. Flagged to the maintainer with a veto window rather than buried.

    One residual noted, not blocking. In canonicalExpandParam, an expand value that is neither an array nor an object falls to return undefined — a silent drop, the very shape the function's own doc says it exists to close. It is unreachable for type-correct callers (the declared type is Record<string, any> | string[]) and is not a regression, so it does not hold up a release-board fix; recording it here so the next reader inherits it rather than rediscovering it.

    Verification (read from GitHub, not from the report): changed files are exactly 3 — the changeset (patch), client.test.ts, index.ts — matching the claimed file surface, with no content/docs/releases/ edit. CI converged: 24 check runs, 22 success and 2 skipped by path filter, zero failures; ESLint and TypeScript Type Check both read as job conclusions of success, which is where the check:* gate families run.

    Out-of-scope finding #6485 filed as required (the { limit: 0 } truthiness drop) — correctly left unfixed, since what limit: 0 means is a contract call rather than a drive-by != null edit.

    Marking ready and adding to the merge queue.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions