Skip to content

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

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-6322-client-find-canonical-keys
Aug 8, 2026
Merged

fix(client): derive data.find's canonical-key predicate from QueryOptionsV2, and map expand to the wire#6480
os-project-manager merged 1 commit into
mainfrom
claude/issue-6322-client-find-canonical-keys

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #6322

What was wrong

data.find() accepts two vocabularies — canonical QueryOptionsV2 and legacy QueryOptions — and chose its branch with a hand-written condition naming four keys:

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

duplicated byte-for-byte in both implementations (ObjectStackClient.data.find, ScopedProjectClient.data.find). That condition is a second, independent statement of what QueryOptionsV2 declares, and it had already fallen behind the interface twice.

A. { limit: N } was silently dropped. limit was not one of the four, so client.data.find('task', { limit: 20 }) — a canonical key as the only key, and the most natural spelling of "first 20" — fell to the legacy branch. That branch reads only top / skip / sort / select / filter / filters / aggregations / groupBy; nothing there reads limit. The request went out with an empty query string: server default page size, HTTP 200, no warning. Its pagination twin { offset: 5 } worked, because offset happened to be listed — one interface, two pagination keys, opposite behaviour.

B. expand was declared but never delivered. Declared on QueryOptionsV2 and documented as replacing a legacy populate that QueryOptions never had — carried by neither branch. Not one character reached the wire, on either copy.

Premise verification (against origin/main eb7613c)

The issue verified against a682670; both halves still hold, and the PM's server-side mechanism assumption for expand holds as well:

  • packages/spec/src/api/protocol.zod.tsHttpFindQueryParamsSchema declares expand on the GET list route as "Comma-separated list of lookup/master_detail field names to expand. Resolved to populate array and passed to the engine for batch $in expansion."
  • packages/metadata-protocol/src/protocol.ts (findData) splits that string on commas and folds each name into { [name]: { object: name } }, then gates it through assertExpandTargetsExist.
  • packages/rest/src/rest.test.ts (findData handler expand/populate forwarding) drives exactly query: { expand: 'order,product', top: '10' }.

So the accepted wire spelling is a comma-joined relation-name list. Nothing was invented.

What changed

One predicate, derived from the interface. The canonical-only key set is now computed, not restated:

type QueryOptionsV2OnlyKey = Exclude< keyof QueryOptionsV2, keyof QueryOptions >;

const QUERY_OPTIONS_V2_ONLY_KEYS: Record< QueryOptionsV2OnlyKey, true > = {
  where: true, fields: true, orderBy: true, limit: true, offset: true, expand: true,
};

TypeScript rejects this object if a canonical-only key is missing and rejects it if a non-canonical-only key is present, so the next key added to QueryOptionsV2 is a compile error here until it is listed. aggregations / groupBy fall out automatically — both options types declare them, so shared vocabulary cannot discriminate. Appending limit to the old list would have been the third round of the same mistake, which is why the ruling forbade it.

expand mapped to the spelling the server acceptsexpand=contact,owner. The Record form contributes its keys, which are the same relation names the server derives from the comma list, so that form maps losslessly too.

Both copies read the one shared predicate and the one shared mapping.

Judgement call worth a maintainer's eye

A nested per-relation query (expand: { contact: { fields: ['name'] } }) has no spelling on a GET. Trimming it away would send a wider read than the caller asked for and say nothing — the same silent-drop defect this PR exists to close, and the one the engine itself refused inside expand in #4371. So it is now refused with an error naming the relation and the keys it could not carry, pointing at data.query() (which carries a QueryAST body and is where nested expand detail belongs).

This is the only behaviour in the PR that turns a silent no-op into a throw. Blast radius measured: the Record form has zero callers anywhere in the workspace, and it never worked, so nothing that functions today starts failing. Flagging it explicitly rather than burying it — the alternative reading (narrow QueryOptionsV2.expand to string[] and drop the Record form under ADR-0049) is a public-type call I did not make unilaterally.

Reverse verification — direction predicted before running

Predicted: the rows for the two defects go red on unmodified source, the rows pinning the already-correct translation layer stay green. Ran the new table against origin/main's index.ts with the new tests in place — 6 failed / 253 passed, and the six were exactly the predicted set:

FAIL  canonical single key: { limit } → `top=20` on both copies
FAIL  canonical single key: { expand } → `expand=contact` on both copies
FAIL  canonical: where + expand → `contact_id=c1&expand=contact` on both copies
FAIL  canonical: expand as a name array → `expand=contact%2Cowner` on both copies
FAIL  canonical: expand as a relation map → `expand=contact%2Cowner` on both copies
FAIL  refuses a nested per-relation expand on both copies, before any request

with { limit: 20 } reporting expected '' to be 'top=20' — the empty query string from the issue's measurement table, reproduced. { offset: 5 }, { where }, { fields }, { orderBy }, { top }, { skip } and both full five-key rows were green before and after: the translation layer itself was always correct, and this PR does not touch it.

Tests — one table, both copies

Per the multi-face test clause — 测试放在未来的分叉会被抓住的地方,不是放在一个独立测试文件里 — the cases live in packages/client/src/client.test.ts beside the existing find coverage, and every row is driven through both implementations against the same expected query string, so a future edit landing on only one copy goes red.

Expectations are exact full query strings, not toContain substrings: the defect was a param that never appeared at all, and a substring assertion on the params that did appear stays green through exactly that. This supersedes the two previous QueryOptionsV2 tests, which asserted toContain on one copy only.

The single-key sweep is typed Record< CanonicalOnlyKey, TransportRow > with CanonicalOnlyKey recomputed in the test from the two exported interfaces — so a new canonical key is a compile error in the test too, until someone states what it puts on the wire.

The refusal case is a client-side pre-flight guard, so there is no ADR-0112 code/status envelope to assert. Two independent bits are asserted instead: the message names the offending relation and the nested keys it could not carry, and no request was issued — so a refusal firing after the read went out, or one resolving instead of throwing, both go red.

Verification run

  • pnpm --filter @objectstack/client test259 passed (259), 20 files.
  • pnpm --filter @objectstack/client typecheck — clean (tsc --noEmit + check:test-typecheck, 0 debt entries).
  • pnpm lint — clean.
  • All 29 remaining check:* steps enumerated from .github/workflows/lint.yml's ESLint job — every one PASS, including check:query-options-erasure, check:route-envelope, check:error-code-casing, check:engine-double-contract, check:nul-bytes.
  • Type-check job: check:type-check-coverage, check:type-check-debt, @objectstack/client-react, @objectstack/cli, @objectstack/example-todo and @objectstack/downstream-contract typechecks — all clean after building the dependency closure (the first pass showed TS2307 reds in cli and a root-package debt drift; both were the AGENTS.md §9 stale-artefact trap, confirmed by measuring pristine origin/main content in the same worktree and getting the identical count).

A .changeset/*.md (patch) is included — this is a user-visible fix.


Generated by Claude Code

…ionsV2 (#6322)

`data.find()` picked its canonical-vs-legacy branch with a hand-written
condition naming four keys. That condition restated what `QueryOptionsV2`
declares, and had fallen behind it twice: `limit` was absent, so
`find('task', { limit: 20 })` fell to the legacy branch and reached the
server with an empty query string (default page size, HTTP 200, no
warning), while its twin `{ offset: 5 }` worked; and `expand` was absent
AND unmapped on both branches, so it never reached the wire at all.

The predicate is now `Exclude<keyof QueryOptionsV2, keyof QueryOptions>`
held as a `Record<…, true>` — a new canonical key is a compile error
until listed. `expand` maps to `?expand=<comma-separated names>`, the
spelling `HttpFindQueryParamsSchema` declares for the GET list route; a
nested per-relation query, which has no GET spelling, is refused rather
than silently trimmed.

Both `find` copies (ObjectStackClient / ScopedProjectClient) read the one
shared predicate and mapping, and are driven through one expectation
table in the tests so a future fork goes red.

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

vercel Bot commented Aug 8, 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 8, 2026 1:33am

Request Review

@github-actions github-actions Bot added the size/m label Aug 8, 2026
@github-actions

github-actions Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/client.

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

  • content/docs/ai/skills-reference.mdx (via packages/client)
  • content/docs/api/client-sdk.mdx (via @objectstack/client)
  • content/docs/api/data-flow.mdx (via @objectstack/client)
  • content/docs/api/environment-routing.mdx (via @objectstack/client)
  • content/docs/api/error-catalog.mdx (via @objectstack/client)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/client)
  • content/docs/kernel/runtime-services/data-service.mdx (via @objectstack/client)
  • content/docs/kernel/runtime-services/index.mdx (via packages/client)
  • content/docs/permissions/authentication.mdx (via @objectstack/client)
  • content/docs/plugins/packages.mdx (via @objectstack/client)
  • content/docs/protocol/kernel/realtime-protocol.mdx (via @objectstack/client)
  • content/docs/releases/implementation-status.mdx (via @objectstack/client)
  • content/docs/releases/v16.mdx (via @objectstack/client)
  • content/docs/releases/v17.mdx (via @objectstack/client)

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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Aug 8, 2026
@os-project-manager
os-project-manager marked this pull request as ready for review August 8, 2026 01:49
@os-project-manager
os-project-manager added this pull request to the merge queue Aug 8, 2026
Merged via the queue into main with commit ec3dfd7 Aug 8, 2026
25 checks passed
@os-project-manager
os-project-manager deleted the claude/issue-6322-client-find-canonical-keys branch August 8, 2026 02:06
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.

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

2 participants