Skip to content

[RFC] Derived GraphQL Contexts #159

@captbaritone

Description

@captbaritone

TL;DR: I’m proposing a scheme for defining scoped context values to allow large/modular client-side executors to only pay for bundle size/setup cost of the context values they actually use.

Problem Statement

When executing GraphQL on the client JavaScript bundle size must be managed. This means that in a given JavaScript bundle you only want to include code that you actually use. “Pay for what you use”.

In the case or GraphQL resolvers this is possible (though not yet widely adopted). Relay Resolvers achieve this by inlining resolvers into it’s query/fragment generated artifacts, Grats has a path toward achieving this via deriving per-query sub-schemas.

GraphQL context is harder. Context, as used in graphql-js is a global object which is the same for all consumers. This is doubly problematic because the things you consume off of context are generally quite large/expensive: Data stores, databases, etc.

Proposed Solution

Implementation-first GraphQL tools, like Grats, could introduce the notion of a “derived context” which is defined as a function of the global context return an explicitly typed derived context value. For any resolvers which specify an argument of this type, Grats would then be able to import in this function and invoke it, passing the result to the resolver.

Let’s look at an example:

First we have some global code

// - GlobalCode.ts

// We still define a global context object, which 
// the executor must be configured to provide

/** @gqlContext */
export type GlobalContext = {};

/** @gqlType */
expprt type Query = unknown;

Then we have a module which defines a derived context

// - SqliteDb.ts
import {DB, connect} from "some-sqlite-library";
import {GlobalContext} from "./GlobalCode.ts"

// By defining this annotated function we are telling
// Grats that `DB` type is a context value, and that this is
// the function to call if a component wants it.

/** @gqlContext */
export function getSqlite(_ctx: GlobalContext): DB {
  return connect({source: "in-memory"});
}

Finally we have some normal looking Grats resolver code

// - models.ts

/** @gqlType */
type User = {
  /** @gqlField */
  name: string;
}

// Here we have a function which consumes the derived context value `DB`.
// Grats can tell that this is a reference to the derived context type and
// pull in, and execute, the code to get that value.

/** @gqlField */
export function me(_: Query, db: DB): User {
  const row = DB("SELECT * FROM users WHERE id = '4';").get();
  return {name: row.name};
}

Output

Now if we imagine a query like this: query { me { name } }, Grats could produce a sub-schema resolver map like this:

import {getSqlite} from "./SqliteDb";
import {me} from "./models";

export function getResolverMap() {
  return {
    query: {
      me(source, _args, context) {
        // Grats pull in this context creation function only
        // because it's used by `Query.me`.
        return me(source, getSqlite(context));
      }
    }
    // User.name is omitted because it can use the default resolver
  }
} 

Memoization

Generally you don’t want to produce a new context value for each resolver. Instead you want a shared DB connection/logger/whatever. I’m not sure if Grats should apply per-context WeakMap memoization to ensure only one derived context is created per context, or if that should be left up to the implementor of the derived context function. Perhaps there are use cases for non-memoized derived contexts?

Other tools

This approach could work for Grats, but also eventually for Relay which is also exploring and implementation-first Grats-style syntax for Relay Resolvers.

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