Skip to content

options.root.subscription cannot reference generated types, and fails silently when it tries #54

Description

@Azerothian

Split out of #52 / #53. Not a regression from that fix — it is independent, and I verified it against main at 7ec5728.

Summary

options.root is applied to the live build before the generated model types are reachable by the caller, and there is no live-build equivalent of the materializer's extendFactory. So a subscription root that wants to reference a generated type has no way to get one — and instead of failing, createSchema returns an invalid schema that breaks every query in it.

The materializer already documents the problem it solves on its side (packages/gqlize/src/graphql/snapshot/materialize.ts:107-111):

Extra GraphQLSchema config — subscription, types. Same reason as the field maps: a subscription root that references this schema's model types has to be built after they exist, and options.root is evaluated before.

The load path has that hook. The build path does not.

Reproduction

A fields thunk on a user-supplied subscription root runs during new GraphQLSchema, so the only handle a caller could have on the generated types — schema.$sql2gql.types, available once createSchema returns — is always still empty when the thunk fires:

const holder: {types?: Record<string, GraphQLType>} = {};

const subscription = new GraphQLObjectType({
  name: "Subscription",
  fields: () => ({publicChanged: {type: holder.types?.Public, resolve: () => null}}),
});

const schema = await createSchema(orm, {root: {subscription}});
holder.types = (schema as any).$sql2gql.types;   // too late, always

Observed:

built ok; fields thunk ran: BEFORE the holder was filled

type Subscription {
  publicChanged: undefined
}

validateSchema errors: [
  'The type of Subscription.publicChanged must be Output Type but got: undefined.'
]
an ordinary query still runs: ["The type of Subscription.publicChanged must be Output Type but got: undefined."]

Three separate problems in that output:

  1. createSchema does not throw. It hands back a schema it should have refused to build.
  2. printSchema emits publicChanged: undefined — not valid SDL, so anything consuming the printed schema (including gqlize check --strict, which diffs SDL) is handed garbage.
  3. The whole schema is dead. validateSchema reports the bad field, and an ordinary { models { Public { total } } } query — nothing to do with subscriptions — fails with that same error. One unbuildable subscription field takes down every query.

Second, latent half: the artifact could not carry such a type anyway

collectSnapshotTypes seeds only the query and mutation roots (packages/gqlize/src/graphql/snapshot/reachability.ts:131):

for (const root of [schema.getQueryType(), schema.getMutationType()]) {

and the IR has no subscription field at all (ir.ts:112-113 — query, mutation?, nothing else).

For a user-authored subscription root this is correct and I confirmed it round-trips exactly: options.root is never serialized, collectLiveTypes claims the user's types, and the caller re-supplies the root at load — live SDL and rebuilt SDL come out identical. That part works.

But it means that even if (1) were fixed, a generated model type reachable only through the subscription root still could not be carried by the artifact: nothing seeds the walk from that root. Combined with #53 — which now prunes ledger.modelTypes to what the schema publishes — such a model would be dropped from the artifact silently rather than crashing the loader, so the failure mode is better but the capability is still missing.

Worth fixing together, since a fix for (1) that does not also seed the walk just moves the breakage to the artifact path.

Suggested direction

Not prescriptive, but the shape that matches what is already there:

  • Give the live build the same late-binding hook the loader has — a factory form of options.root (and/or options.extend) called once the model types exist, mirroring extendFactory(types). That would also make the two paths symmetric, which gqlize check --strict currently depends on being true.
  • Seed schema.getSubscriptionType() in collectSnapshotTypes so a generated type reachable only from that root is carried.
  • Failing both, at minimum validate the built schema and throw: an options.root slot that produced a field with no type should be a build-time error naming the field, not a schema that validates dirty and fails on every request.

Note on a confusable

options.subscriptions (the boolean the fingerprint hashes at fingerprint.ts:299, and which docs/specifications.md records as "accepted and ignored — the generator is commented out") is a different thing from options.root.subscription. This issue is about the latter, which is a live, reachable code path — duplicate-types.ts:113 already visits config.subscription, and the materializer's extendFactory explicitly supports it.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions