Skip to content

fix: type a foreign key by the model it points at, and raise on a cross-type id - #66

Merged
Azerothian merged 1 commit into
mainfrom
fix/fk-global-id-target
Sep 7, 2026
Merged

fix: type a foreign key by the model it points at, and raise on a cross-type id#66
Azerothian merged 1 commit into
mainfrom
fix/fk-global-id-target

Conversation

@Azerothian

Copy link
Copy Markdown
Owner

Fixes #65.

The bug

belongsToMany drops a join model's own id and makes its two foreign keys the composite primary key, so RoleUser.roleId / RoleUser.userId are a primary key and a foreign key at once. Both the encoder (create-basic-fields) and the decoder (globalKeyTargets) tested primaryKey first:

targets[key] = field.primaryKey ? defName : (field.foreignTarget || defName);
//                                ^ RoleUser, for a column holding a User key

So RoleUser.userId was minted and demanded as a RoleUser id — an id no client can produce from anywhere else in the schema. foreignTarget now wins wherever it exists. A shared-primary-key 1:1 table has the same shape and is fixed by the same rule.

Adapters that could not name the target at all

  • valkeyensureJoinModel marked the auto-created join model's two keys foreignKey: true with no foreignTarget, so they fell through the || defName fallback. It now takes the two ends.
  • sequelize — a through model declared as its own definition but without explicit belongsTos threw at startup: sequelize builds a join model's two BelongsTos with new BelongsTo(...), which never registers them on through.model.associations. The attribute still carries references, so the model owning that table is used before falling back to the throw.

Cross-type ids now raise

Previously an id minted for the wrong type failed to decode; the opaque string was then compared literally and matched nothing — indistinguishable from a filter that legitimately found nothing.

gqlize: "RoleUser.userId" expects a "User" id, but the id given is a "RoleUser" id

GraphQLError, extensions.code = "GLOBAL_ID_TYPE_MISMATCH". The check moved out of the codecs into decodeGlobalId: a codec's null already means "not one of mine, pass it through" (which is what lets a raw primary key survive a filter untouched), so folding a forged id into the same answer made the two the same answer — and only the caller knows which field to name. An out-of-tree codec gets the check for free; a codec declaring carriesType: false is exempt.

node(id:) is unaffected — it decodes with no expected type, since the id is the type declaration there. It now filters on the already-decoded key rather than handing the global id back for a second decode, which a pk-and-fk column would have failed.

Breaking changes (both noted in docs/migration-6-to-7.md)

  • A join-model / shared-pk foreign key now carries the target's global ID. Ids persisted client-side across the upgrade need re-fetching.
  • A cross-type global ID is an error rather than an empty result set. Custom IdCodecs should stop returning null for a recognised id whose type does not match.

Verification

pnpm test — 9/9 packages, 1593 tests green (incl. the valkey suite against redis-memory-server). pnpm typecheck and pnpm lint clean.

New coverage: end-to-end Role -> RoleUser <- User round-trip in gqlize/__tests__/codecs/id-regressions.test.ts; both join-model shapes (with and without belongsTo on the join) in the sequelize adapter's replace-id-codec.test.ts; a cross-adapter parity assertion in the valkey relations.test.ts. Every test that asserted the old "silently matches nothing" behaviour now asserts the error message.

🤖 Generated with Claude Code

https://claude.ai/code/session_013aahHVCcqUpyw8TfWM1gSs

…mismatch

A join model's foreign keys are also its primary key — `belongsToMany` drops
the model's own `id` and makes `roleId`/`userId` the composite key — and both
the encoder and `globalKeyTargets` tested `primaryKey` first. So `RoleUser.userId`
was minted and demanded as a `RoleUser` id, though the value it holds is a `User`
key: an id no client can produce from anywhere else in the schema. `foreignTarget`
now wins wherever it exists.

Two adapters could not name the target at all. Valkey's `ensureJoinModel` marked
the auto-created join model's keys `foreignKey` without a `foreignTarget`; it now
takes the two ends. Sequelize threw outright for a through model declared without
its own `belongsTo`s, because sequelize builds a join model's `BelongsTo`s with
`new BelongsTo(...)`, which never registers them on the model — it now falls back
to the model owning the table the attribute references.

And a cross-type id is an error rather than an empty result set. It used to be
left undecoded, so it was compared literally and matched nothing, which is
indistinguishable from a filter that legitimately found nothing. The check moves
out of the codecs into `decodeGlobalId`: a codec's `null` already means "not one
of mine, pass it through", so folding a forged id into the same answer made the
two the same answer, and only the caller knows the field to name in the message.
A codec declaring `carriesType: false` is exempt.

Fixes #65

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013aahHVCcqUpyw8TfWM1gSs
@Azerothian
Azerothian merged commit 5fde3d1 into main Sep 7, 2026
1 check passed
@Azerothian
Azerothian deleted the fix/fk-global-id-target branch September 7, 2026 03:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wrong model type when attempting to replace id in args

1 participant