Skip to content

feat(gqlize): @deprecated, build-time schema validation, Relay-compliant connections - #55

Merged
Azerothian merged 2 commits into
mainfrom
feat/graphql-deprecated-validate-connection
Aug 28, 2026
Merged

feat(gqlize): @deprecated, build-time schema validation, Relay-compliant connections#55
Azerothian merged 2 commits into
mainfrom
feat/graphql-deprecated-validate-connection

Conversation

@Azerothian

Copy link
Copy Markdown
Owner

Three GraphQL features the generator was leaving on the table, from the audit of graphql 17 and the Relay spec against packages/gqlize/src/graphql/**. The artifact IR was already ahead of the generator — snapshot/ir.ts round-trips deprecationReason and nothing produced it — so half the work was done and tested.

Each item is independently reviewable; they are separate concerns that happen to share a branch.

1. @deprecated, end to end

There was previously no way to deprecate anything. grep found zero deprecationReason outside the snapshot IR, so renaming a column was a hard breaking change with no warning path — the one problem @deprecated exists to solve.

A reason can be written two ways:

{
  name: "Post",
  define: { subtitle: { type: Sequelize.STRING, deprecated: "use `title`" } },
  deprecations: {
    fields:          { comments: "use `Comment(where: { postId: ... })`" },  // relationships have no slot of their own
    classMethods:    { legacySearch: "use `Post(where:)`" },
    instanceMethods: { trimTitle: "the server trims on write now" },         // the `apply` transform inputs
  },
}

deprecationFor (packages/utilize/src/utils/deprecation.ts) is the single point that decides precedence: the central map wins, which is the same precedence comments.fields already has over a field's own description, and for the same reason — the map is the only way a definition can deprecate a declaration it did not author.

Where a reason lands: output fields (including global-id and override fields), mutation inputs, both halves of the orderBy enum pair (a deprecated column should not stay silently sortable), class-method query fields, apply transform inputs, and for a model-level deprecated, QueryModels.X and MutationModels.X — GraphQL has no way to deprecate an object type.

One asymmetry is the spec's, not ours: graphql rejects @deprecated on a required input field, since a client cannot stop sending a value it is obliged to send. A deprecated NOT NULL column therefore shows the reason on {Def}OptionalInput (update) and not on {Def}RequiredInput (create). Asserted as a test rather than left as a surprise.

Two cache-sharing hazards worth flagging for review: schemaCache.lists memoises connection field configs across parents, so every mark on a list field copies ({...many, deprecationReason}) rather than mutating — otherwise deprecating one parent's relationship would deprecate every other use of the same connection.

2. Build-time schema validation

validateSchema / assertValidSchema were never called anywhere in packages/*/src. Issue #54 is why that matters: graphql validates lazily — once per execution, cached on the schema — and returns the same error list for every operation. So one malformed field in options.root did not fail only the query that selected it, it failed every query, at request time, naming the coordinate but not the code that produced it.

Now:

Error: gqlize: the generated schema is not a valid GraphQL schema:
  - Subscription.subjectChanged field type must be Output Type but got: undefined.
Most often this is a type written into `options.root`, `options.extend` or a definition's
`override` / `expose` block. Pass `validate: false` to skip this check.

materializeSchema runs the same gate, where it matters more — an artifact is loaded at boot, so an invalid one would otherwise reach production traffic before the first query reported it. options.validate: false opts out for a host building many permission profiles per process. graphql's own error objects stay on error.errors.

No existing suite turned out to be building a deliberately invalid schema.

3. Relay connection nullability ⚠️ breaking

 type PostList {
-  pageInfo: PageInfo          +  pageInfo: PageInfo!
-  edges: [PostEdge]           +  edges: [PostEdge!]!
   total: Int                  # unchanged — a separate COUNT the include builder may skip
 }
 type PostEdge {
   node: Post                  # unchanged — the row can go away between the page query and the node resolve
-  cursor: String              +  cursor: String!
 }
 type PageInfo {
-  hasNextPage: Boolean        +  hasNextPage: Boolean!
-  hasPreviousPage: Boolean    +  hasPreviousPage: Boolean!
   startCursor: String         # unchanged — an empty page has no edge to name
 }

The first three are what the Relay Connections spec requires. The other two are the same argument applied consistently: resolvers/connection.ts has always returned an edges array and minted every edge's cursor itself, so the nullable spelling only ever bought clients a null check they could not trigger — while Relay, graphql-codegen and Apollo's relayStylePagination all generated boolean | null from it.

Consumer impact: artifacts must be rebuilt (gqlize check reports them stale) and client codegen re-run. Nothing breaks at runtime — the server never sent null for these — but null checks become dead code. No resolver, hook or permission behaviour changed. Documented as a new section in docs/migration-6-to-7.md, since 7.0 is still unreleased.

4. Documentation

docs/specifications.md §3, §4, §5 (a new Deprecation section), §6, §11, §13; docs/guide.md §3 (Deprecating fields), §4 (Build-time validation), §6; a migration section; and packages/gqlize/README.md.

The README ## Features and ## TODO were stale in ways the spec inherited, since §13 claims to quote the README: the typed where/filter object for the Sequelize adapter, cross-adapter relationships, before/after hooks and CI/CD were all listed as pending and all ship today. Each was checked against the code before being pruned. §13 now also records the deferred findings (schema directives, user-declared interfaces/unions, @oneOf, the missing subscription artifact half) and a Deliberately out of scope section for @defer/@stream, cost/depth limiting, federation and DataLoader — with the caveat that a @streamed field still goes through build-include-from-selection.ts and so over-fetches rather than breaks.

Every SDL block in the docs was diffed against real printSchema output rather than hand-written; that caught one wrong connection type name.

Verification

  • Root pnpm test: 9/9 packages green, gqlize 76 suites / 801 tests (from 73/788). nestize and temporalize both build through createSchema, so item 2's assert covers them.
  • pnpm typecheck 9/9; pnpm lint clean against the zero-warning ratchet.
  • npx jest --ci sits at the pre-existing 13-suite/26-test baseline (jest ESM transform on relay.test.ts, plus postgres unavailable locally) — unchanged by this branch.
  • The two SDL goldens moved for item 3 only, and every one of the 32 diff lines was read by hand; items 1 and 2 moved no golden, confirming they leaked no schema-shape change.
  • examples/gqlize-basic regenerated, and the artifact verified to boot through loadSchema and serve a connection query with the new shape.

Closes the build-time half of #54 — the subscription root itself is still unimplemented and stays on the roadmap, with the artifact gap now recorded alongside it.

🤖 Generated with Claude Code

https://claude.ai/code/session_012xoQguMMNiv844ST3LvBMm

Azerothian and others added 2 commits August 26, 2026 18:12
Closes #52. A restricted schema could not load its own artifact:

    gqlize: the artifact lists relay model type "Element" but it is not in
    the schema — the artifact is inconsistent, rebuild it

Rebuilding never helped, because the snapshotter produced the inconsistency
itself. Two sets were recorded and only one was filtered by reachability:
`ledger.modelTypes` took every model that passed the permission gates, while
`snapshot.types` took only what the roots reach. Neither `createSchema` nor
the materializer passes `types:` to `new GraphQLSchema`, so a model nothing
refers to is not in `getTypeMap()` at all — it existed only in the builder's
cache and the relay node map. Deny both a model's query list field and its
mutation entry and it becomes an island: built, published nowhere, and named
by a ledger the artifact has no type for. The live build tolerated that
silently, which is why only the artifact path failed.

The build now records the map after `new GraphQLSchema` has walked the roots
and pruned it to what the schema publishes, so `ledger.modelTypes`, the
`$sql2gql` hatch and the node mapper are one key set. The snapshotter prunes
again against the reachability walk, whose reach is narrower still — it skips
`extend`-bound fields, so a type only an extend field refers to is in the type
map but has no IR entry to be rebuilt from. `pruneModelTypes` decodes the
`Name[]` list-wrapper convention in the one place both call sites share.

`rebuildModelTypes` keeps throwing, and the two `Ghost` guards still pin it —
with the artifact self-consistent by construction, the message now means what
it says. Dropping an island from the node map is not observable: being one
requires `permission.query` to have denied the model, and `id-fetcher`
re-checks that predicate per request, so `node(id:)` returned null either way.
A test asserts it on both the live and the materialized schema.

Two comments described a model the code never implemented — `reachability`
does not seed model types, and the test claiming to cover an unreachable model
only denied `permission.query`, leaving it reachable through the mutation root.
Both now say what is true, and the real case has a regression test.

No format-version bump: neither format changes shape, and an artifact built
before this still loads or throws exactly as it does today.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xoQguMMNiv844ST3LvBMm
…ant connections

Three GraphQL features the generator was leaving on the table. The artifact IR
already round-tripped `deprecationReason`, so only the producers needed writing.

@deprecated — there was previously no way to deprecate anything, which made
renaming a column a hard break with no warning path. A reason can be written on
the declaration (`define[x].deprecated`, an `ExposedMethod`, a model-level
`deprecated`) or in a central `deprecations` map mirroring `comments`; the map
wins, because it is the only way to reach a declaration the definition did not
author, such as a relationship. `deprecationFor` in utilize is the single point
that decides precedence. The reason reaches output fields, mutation inputs, both
halves of the orderBy enum pair, class-method query fields, `apply` transform
inputs, and — for a model-level reason — `QueryModels.X` / `MutationModels.X`,
since GraphQL cannot deprecate an object type.

One asymmetry is the spec's: graphql rejects `@deprecated` on a required input
field, so a deprecated NOT NULL column shows the reason on the update input and
not the create input.

Schema validation — `validateSchema` was never called. graphql validates lazily,
once per execution, and returns the same errors for every operation, so one
malformed field in `options.root` / `options.extend` / an `override` failed
*every* query at request time (issue #54). `createSchema` and `materializeSchema`
now assert validity at the end of the build, where the mistake was made.
`options.validate: false` opts out for a host building many profiles per process.

Connection nullability — `pageInfo: PageInfo!` with both page flags `Boolean!`
is what the Relay Connections spec requires; `edges: [XEdge!]!` and
`cursor: String!` follow the same argument, since the resolver always returns an
array and mints every cursor itself. `total` and `edges.node` stay nullable on
purpose. This moves the SDL, so goldens are regenerated and artifacts need a
rebuild — documented in the 6-to-7 migration note.

Docs: specifications §3/§4/§5/§6/§11/§13, guide §3/§4/§6, a migration section,
and the gqlize README Features/TODO — the latter two were stale, still listing
the typed where/filter object, cross-adapter relationships and before/after
hooks as pending when all three ship. Every SDL block was diffed against real
printSchema output rather than hand-written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012xoQguMMNiv844ST3LvBMm
@Azerothian
Azerothian merged commit 994c93f into main Aug 28, 2026
1 check passed
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.

1 participant