fix(objectql): force the join key into the expand sub-read, strip it when unrequested (#7537) - #7594
Conversation
…when unrequested (#7537) `expand: { account: { object: 'showcase_account', fields: ['name'] } }` answered 200 with `account` still holding the raw foreign-key id: the expansion silently did not happen, and because the fallback is the caller's own id, nothing in the response distinguished "expanded" from "not expanded". `expandRelatedRecords` forwarded `nestedAST.fields` to the sub-read verbatim and then keyed its lookup map on `rec.id`. A projection that did not name `id` produced rows with no `id`, an empty map, and an injection that fell through `recordMap.get(String(val)) ?? val`, writing the original FK back. The failing spelling is the one the platform prescribes: both FIELD_NODE_OBJECT_FORM_REMOVED and QUERY_JOINS_REMOVED (plus their three migration-registry entries) tell authors to write exactly that form. Refusing the mismatch would turn two retirement messages' own migration target into an error, so the prescribed form is made to work: `id` is added to the sub-read unconditionally and stripped back out of the emitted nested record when the caller did not ask for it. The strip runs after the recursive expand, which rebuilds its map on `rec.id` and still needs the key. `id` is the only join key there is — `referenceTargetOf` yields a target OBJECT and a reference field carries no target-column metadata, so the batch filter is `{ id: { $in } }` by construction. Pins: a real-SqlDriver integration test (the defect is invisible to InMemoryDriver, which force-adds `id` to every projection) covering both object pairs from the QA repro plus `findOne`, and four engine unit pins covering the forced projection, the untouched `['id','name']` case, array-valued expansion, and the recursive nested expand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 15 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…quired in a nested `fields` (#7537) The page's own documented output (`account: { company_name: 'Acme Corp' }`) was already the intended behaviour; the implementation was what had drifted. Now that the join key is added to the sub-read and stripped when unrequested, say so explicitly, so an author reading the retirement messages' prescribed spelling can see it is complete. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
) check:query-options-erasure counted the `as any` on the new findOne case as a new untyped-options site (test surface 249 → 250). The input is on-contract — `where` + `expand` are both `EngineQueryOptions` members — so the cast was noise, not a deliberate contract bypass. Removing it satisfies the ratchet the way the check asks for (type the options) rather than by spending the `as unknown as EngineQueryOptions` escape hatch, which is reserved for tests that must build genuinely off-contract input. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
…ck (#7537) check:type-check-debt went red: objectql's TEST_DEBT is a ratchet, and the four new pins added six raw tsc errors to it — three `vi.mocked(SchemaRegistry.getObject)` sites, each reported twice (TS2339, since `getObject` is not on the static type, plus TS7006 on the untyped `name`). The surrounding file is full of that shape, but it is frozen debt, not a pattern to extend. The block now goes through one `mockSchema` helper that names the cast once and types the callback, so the new pins are error-neutral: the test layer measures 353 both with and without them. Measured while isolating this: the ledger's recorded 355 is already 2 above what origin/main's own engine.test.ts reports (353) on this base. That gap is pre-existing and the gate treats it as informational, so it is left for a deliberate --lower rather than ridden down by this PR. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HJVZqLviCkZMJV8Nh3DUao
Fixes #7537
Premise: verified against
origin/main@97b6658, and it still holdsThe issue was filed against
a86db175;engine.tshas moved since. Re-checked on97b6658—expandRelatedRecordsis still at:6168, still forwardsnestedAST.fieldsverbatim at:6292, still keysrecordMaponrec.idat:6301, and the injection still falls throughrecordMap.get(String(val)) ?? valat:6330. The root-cause paragraph in the issue is accurate as written.Empirically reproduced before touching anything. The new integration test failed 3/3 on the pre-fix tree, with exactly the reported symptom:
The two control cases (
fields: ['id','name'], and no projection at all) passed pre-fix — matching the issue's "addingidexpands correctly".Two measurements the issue did not have
1. The defect is driver-conditional, which is why no existing suite caught it.
SqlDriveremits the requested column list verbatim (builder.select(query.fields),sql-driver.ts:3383) so it genuinely dropsid.InMemoryDriver.projectFieldsforce-adds the primary key to every projection ("Always include id if not explicitly listed",memory-driver.ts:1370) so its rows always carry the join key and the map is never empty. The engine's own expand pins all run on mocks that return whole rows. That is why a better-sqlite3 QA run found this and 3157 objectql tests did not — and why the end-to-end pin here is wired to a realSqlDriverrather than a mock.2. The join key is always literally
id— the PM's "is it configurable?" fork resolves.referenceTargetOf(field-value.zod.ts:132) returns a target object name and nothing else; alookup/master_detail/tree/userfield carries no target-column metadata anywhere inspec. (relationshipField/foreignKeyinfield.zod.ts:712names the child's FK field for related lists, not a target key on the parent.) The batch filter is{ id: { $in: uniqueIds } }by construction, so forcingidis correct rather than a guess at a configurable key.Route: add-and-strip, not refuse
The issue offered "add-and-strip or refuse the mismatch". Add-and-strip, because the failing spelling is prescribed verbatim by the platform itself —
FIELD_NODE_OBJECT_FORM_REMOVED(query.zod.ts:322) andQUERY_JOINS_REMOVED(:359) both tell authors migrating off the retired forms to writeexpand: { owner: { object: 'user', fields: ['name'] } }, and the same string appears in three more places:migrations/registry.ts:3062,:3083, and the two semantic migration entries17.query-field-node-object-form-retired.ts/17.query-joins-retired.ts. A refusal would turn five documented migration targets into an error. Making the prescribed form work is the only answer with no collateral.Change
packages/objectql/src/engine.ts—expandRelatedRecords:idappended when the caller's nestedfieldsis a non-empty array that does not already name it. Non-triggering shapes (absentfields,[],fieldsalready containingid) forward exactly as before.fieldsgets. Deliberately after the recursive-expand block, which rebuildsrecordMapkeyed onrec.idand still needs it; stripping at sub-read time would break the inner level the same way the outer one was broken.All four entry points route through this one helper (
find:6805,findOne:6944, the recursion:6307), so the fix covers them all — confirmed by test rather than by reading: thefindOnecase failed pre-fix and passes now, and there is a dedicated recursive-expand pin.Behaviour change worth flagging
A nested projection that omits
idnow emits a nested record withoutid. Previously the outcome was driver-dependent: SQL-backed stores produced the no-op above (so there was no.idto read — the value was a plain FK string), whileInMemoryDriverreturned an expanded record carrying an unrequestedid. Both now emit exactly the projected columns. Callers wanting the key addidto the nestedfields. Recorded in the changeset.Tests
packages/runtime/src/expand-nested-fields-join-key.integration.test.ts(new, realObjectQL+ realSqlDriveron better-sqlite3, on-disk) — 5 cases:fields: ['name']spelling expands, and the nested record equals{ name: 'Acme' }exactly (the unrequestedindustryabsent,idabsent);fields: ['id','name']keeps the key (the issue's workaround spelling stays intact);showcase_task → project, since the report confirmed 2/2;findOne(single-record entry point);packages/objectql/src/engine.test.ts— 4 unit pins under[#7537], asserting the substance at the seam:fields: ['name','id']while the emitted record is{ name: 'Alice' };['id','name']is forwarded untouched;multiple: true) expansion strips too;No refusal path was added, so ADR-0112 envelope assertions do not apply.
Gates
All six card gates run locally, all pass:
check:adr-anchors,check:durability-log-level,check:engine-double-contract(148 pinned / 133 DEBT / 2 exempt),check:stack-collection-maps,scripts/check-engine-split-ratio.mjs,check:nul-bytes(7015 files). Build closure: fullpnpm build, 71/71 tasks. Suites: objectql 3157/3157, runtime 1995/1995, rest 1344/1344.typecheckclean on both changed packages;eslintclean on all three changed files. CI owns the rest of the farm.Generated by Claude Code