-
Notifications
You must be signed in to change notification settings - Fork 20
Description
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.