feat(gqlize): @deprecated, build-time schema validation, Relay-compliant connections - #55
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.tsround-tripsdeprecationReasonand 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 endThere was previously no way to deprecate anything.
grepfound zerodeprecationReasonoutside the snapshot IR, so renaming a column was a hard breaking change with no warning path — the one problem@deprecatedexists to solve.A reason can be written two ways:
deprecationFor(packages/utilize/src/utils/deprecation.ts) is the single point that decides precedence: the central map wins, which is the same precedencecomments.fieldsalready has over a field's owndescription, 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
overridefields), mutation inputs, both halves of theorderByenum pair (a deprecated column should not stay silently sortable), class-method query fields,applytransform inputs, and for a model-leveldeprecated,QueryModels.XandMutationModels.X— GraphQL has no way to deprecate an object type.One asymmetry is the spec's, not ours: graphql rejects
@deprecatedon a required input field, since a client cannot stop sending a value it is obliged to send. A deprecatedNOT NULLcolumn 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.listsmemoises 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/assertValidSchemawere never called anywhere inpackages/*/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 inoptions.rootdid 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:
materializeSchemaruns 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: falseopts out for a host building many permission profiles per process. graphql's own error objects stay onerror.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.tshas always returned anedgesarray and minted every edge'scursoritself, so the nullable spelling only ever bought clients a null check they could not trigger — while Relay, graphql-codegen and Apollo'srelayStylePaginationall generatedboolean | nullfrom it.Consumer impact: artifacts must be rebuilt (
gqlize checkreports 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 indocs/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; andpackages/gqlize/README.md.The README
## Featuresand## TODOwere 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/afterhooks 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 throughbuild-include-from-selection.tsand so over-fetches rather than breaks.Every SDL block in the docs was diffed against real
printSchemaoutput rather than hand-written; that caught one wrong connection type name.Verification
pnpm test: 9/9 packages green, gqlize 76 suites / 801 tests (from 73/788).nestizeandtemporalizeboth build throughcreateSchema, so item 2's assert covers them.pnpm typecheck9/9;pnpm lintclean against the zero-warning ratchet.npx jest --cisits at the pre-existing 13-suite/26-test baseline (jest ESM transform onrelay.test.ts, plus postgres unavailable locally) — unchanged by this branch.examples/gqlize-basicregenerated, and the artifact verified to boot throughloadSchemaand 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