Skip to main content

Step classes

A step details a particular action or transform that needs to be performed when executing a GraphQL request. Each step is an instance of a specific step class, produced during the planning of a field. Each step may depend on 0 or more other steps, and through these dependencies ultimately form a directed acyclic graph which we refer to as the execution plan. Thus the steps are the building blocks of an execution plan.

A range of standard step classes are available for you to use; but when these aren't enough you are encouraged to write your own (or pull down third party step classes from npm or similar).

Step classes extend the Step class, the only required method to define is execute, but you may also implement the various lifecycle methods, or add methods of your own to make it easier for you to write plan resolvers.

/** XKCD-221 step class @ref https://xkcd.com/221/ */
class GetRandomNumberStep extends Step {
execute({ count }) {
return new Array(count).fill(4); // chosen by fair dice roll.
// guaranteed to be random.
}
}

function getRandomNumber() {
return new GetRandomNumberStep();
}
Use prefixes on custom fields/methods.

If you add any custom fields or methods to your step classes we recommend that you prefix them with your initials or organization name to avoid naming conflicts occurring.

Don't subclass steps.

Don't subclass steps, this will make things very confusing for you. Always inherit directly from Step.

Double underscore (__) prefix is reserved for internal usage

Step classes whose names start with two underscores (__) are internals and must never be directly created by the user. Further, you must never name your own step classes such that they begin with two underscores.

Step function

By convention, we always define a function that constructs an instance of our class. A step function is typically named after the corresponding step class, but with the first letter lowercased and the Step suffix omitted; for example the AddStep step class would have a step function named add:

function add($a, $b) {
return new AddStep($a, $b);
}

There are many reasons for this indirection:

  • simplicity, conciseness and readability of plan resolvers: no new keyword, no redundant Step text, no harsh PascalCase identifiers; feels lighter
  • flexible API: the function can manipulate arguments before sending them on to a class constructor, allowing for overloads, complex generics, or tagged template literals
  • caching: it's simple to add a cache at the step function level, much harder to handle that in the constructor of a step class
  • branching: a function may choose to return a different step under certain circumstances, for example if it determines that the input is a constant
  • evolution: a step function should express the developer's intent; how that maps to steps under the hood is less important - e.g. a sideEffect() step might start out by building a LambdaStep and marking it as having side effects, but later evolve to using its own SideEffectStep class, without consuming code needing to change

The tiny additional cost of these step functions is only incurred at plan-time, and plans are re-used for similar future requests; the cost of an additional function call during planning is negligible.

Lifecycle methods

execute

execute(details: ExecutionDetails): PromiseOrDirect<GrafastResultsList>
// These are simplified types
interface ExecutionDetails {
count: number; // Size of the batch being processed
values: [...ExecutionValue[]]; // Represents your dependencies
stream: ExecutionDetailsStream | null; // Relates to `@stream`/`subscription`
// ...
indexMap<T>(callback: (batchIndex: number) => T): ReadonlyArray<T>;
indexForEach(callback: (batchIndex: number) => any): void;
}
type ExecutionValue<TData = any> = {
isBatch: boolean;
unaryValue(): TData; // Throws if `isBatch` is true.
at(batchIndex: number): TData; // Get data entry (`0 <= batchIndex < count`)
};
interface ExecutionDetailsStream {
initialCount: number;
}
type GrafastResultsList<T = any> = ReadonlyArray<PromiseOrDirect<T>>;

The one method that your step class must define is execute. It uses a similar approach to DataLoader's batch function, but Grafast steps are much more powerful thanks to the additional dependencies and lifecycle methods.

When the step class adds a dependency (with this.addDependency($step) or similar) a dependency index, depIndex, is returned. Dependency indexes start at 0 and increase monotonically (a dependency can never be removed1).

At execution time the step is passed ExecutionDetails which contains details and helpers for execution.

details.count

One key property is the size (details.count) of the batch; execute must return a list of length count, such that each entry in this list corresponds with the values in the batch at the same batch index. To save you from having to loop yourself, the details.indexMap helper will call the given callback with each index in the batch in turn; this is commonly used when returning the result of the execute method.

details.values

details.values is also critical: an ordered array of execution values, one for each dependency. You can retrieve the execution value for a given dependency via its depIndex as const depEv = details.values[depIndex]. Once you have an execution value, you can retrieve the value for a given index in the batch via const value = depEv.at(batchIndex).

Grafast tracks which steps will always represent exactly one value (e.g. the GraphQL context, input values passed as field arguments, constants, etc), and which will represent a batch (e.g. values inside of a list). The former are stored in "unary execution values", and the latter in "batch execution values". For convenience, these both expose the .at(batchIndex) method so most of the time you do not need to know the difference and can just think of them all as simply "execution values".

Unary dependencies are useful for request-global concerns such as pagination arguments, authentication credentials, database clients and the like. Should you know that your dependency is a unary step (e.g. because you added it via this.addUnaryDependency($step)), you may get its singular value via const value = depEv.unaryValue() (but if it wasn't unary then this will throw!).

If you have a fixed number of dependencies it common to destructure them at the top of the execute method:

class MyStep extends Step {
constructor($a, $b, $c) {
this.aDepIndex = this.addUnaryDependency($a);
this.bDepIndex = this.addDependency($b);
this.cDepIndex = this.addDependency($c);
}
execute(details) {
const { values } = details;
// Retrieve the execution values in the same order we added dependencies
const [aEv, bEv, cEv] = values;
// The unary dependency value can be shared throughout execute
const a = aEv.unaryValue();

// We must return a list of the correct length in the correct order.
return details.indexMap((batchIndex) => {
// Batch depenencies may have a different value at each index.
const b = bEv.at(batchIndex);
const c = cEv.at(batchIndex);

return a + b + c;
});
}
}
Step with no dependencies

If the step has no dependencies then values will be a 0-tuple (an empty tuple), but that doesn't mean the batch is empty or has size one, count may be any positive integer. It's therefore recommended that you use indexMap to generate your results in the vast majority of cases:

return indexMap((i) => 42);
Tuple of values, rather than list of tuples?

You might wonder why the values input is a tuple of execution values, rather than a list of tuples. The reason comes down to efficiency, by using a tuple of execution values, Grafast only needs to build one new array (the tuple), and into that array it can insert the results from previously executed steps unmodified. Were it to provide a list of tuples instead then it would need to build N+1 new arrays, where N was the number of values being processed, which can easily be in the thousands.

Avoid N+1!

Critically, the execute method should never do async actions whilst looping over the values, otherwise you introduce the N+1 problem. Most execute methods should only perform a single async action. Typically it looks like this:

  1. Extract and identify the execution value for each dependency.
  2. Map over the indices to prepare the input for your async task.
  3. Execute the async task via a single await (or promise), not in a loop.
  4. Map over the input values again, for each index finding the value from step 3 that correlates.

Here's a hypothetical example, concentrate on the execute() method and see how the steps outline above play out:

import { languageService } from "./services/language";

class TranslationStep extends Step {
langDepIndex: number;
textDepIndex: number;
constructor($language: Step<string>, $text: Step<string>) {
super();
// Add our dependencies
this.langDepIndex = this.addDependency($language);
this.textDepIndex = this.addDependency($text);
}
execute(details) {
// 1. Extract and identify the execution value for each dependency:
const langEv = details.values[this.langDepIndex];
const textEv = details.values[this.textDepIndex];

// 2. Map over the indices to prepare the input for our translation API:
const specs = details.indexMap((batchIndex) => {
const language = langEv.at(batchIndex);
const sourceText = textEv.at(batchIndex);
return { language, sourceText };
});

// 3. Execute the translations via a single `await`, not in a loop:
const translations = await languageService.batchTranslate(specs);

// 4. Finally, return results that correlate with the inputs:
return indexMap((batchIndex) => {
const language = langEv.at(batchIndex);
const sourceText = textEv.at(batchIndex);
const match = translations.find(
(t) => t.language === language && t.sourceText === sourceText,
);
return match?.translation;
});
}
}

Errors

If the execute method throws (or rejects), then all entries in the batch for this step will fail with the same error.

If you want one of your entries to throw an error, but the others shouldn't, then set the corresponding entry in the results list to flagError(error) (where error is the error you want to use).

Alternatively reject the promise for that specific item

If it's more convenient and performance isn't a huge concern, you may return a list of values and promises, and reject specifically the promise in that list relating to that item. You can do this even if you don't use promises for any of the other values, and even if your execute method is not marked as async.

No per-value errors if isSyncAndSafe

You must not add per-value errors if you have marked your step class with isSyncAndSafe = true; the results of doing so are undefined.

deduplicate

This method is optional.

// Where `MyStep` is the current step class
deduplicate(peers: readonly MyStep[]): readonly MyStep[]

After a field has been fully planned, Grafast will call this method on each new step when more than one step exists in the draft execution plan with the same step class and the same dependencies. These "peers" (including the step itself) will be passed in to the deduplicate method, and this method should return the list of the peers that are equivalent (or could cheaply be made equivalent) to the current step.

To cause your step class to never be deduplicated, either don't implement this method or simply return [];.

You should not mutate your peers or yourself during this method, instead use the deduplicatedWith method to pass any necessary information to your replacement.

deduplicatedWith

This method is optional.

deduplicatedWith(replacement: Step): void

If Grafast determines that this specific step instance should be replaced by one of its peers (thanks to the results from deduplicate above), Grafast will call deduplicatedWith on the step that is being replaced, passing the step that it is being replaced with as the first argument. This gives your step a chance to pass any information to the peer that may be necessary to make the peers truly equivalent.

When would I need this?

It's rare to need this functionality, so let's work through a hypothetical.

Imagine step $select1 represents the SQL query SELECT id, name FROM users and step $select2 represents SELECT id, avatar_url FROM users.

Lets further imagine that we've optimised our SQL handling step classes such that both $select1 and $select2 return each other from their deduplicate method (because they can "cheaply" be made equivalent).

Assuming Grafast chooses to keep $select1 and "deduplicate" (get rid of) $select2, Grafast would then call $select2.deduplicateWith($select1). This would give $select2 a chance to inform $select1 that in order to be completely equivalent, it must also select avatar_url.

In this scenario, at the end of deduplication, only $select1 would remain and it would represent the SQL query SELECT id, name, avatar_url FROM users.

optimize

This method is optional.

optimize(options: { stream: {} | null, meta?: Record<string, unknown> }): Step

This method is called on each step during the optimize lifecycle event. It gives the step a chance to request that its ancestors do additional work, and/or replace itself with another step (new or old). If it does not want to be replaced, it can simply return itself: return this;.

This one method unlocks a significant proportion of Grafast's efficiency improvements. Here are some common use cases that it can be used for:

Use case: inlining

optimize is often useful for "inlining" the requirements of this step into an ancestor and then (optionally) replacing itself with a simple access or remapKeys step. This reduces the number of asynchronous tasks the request needs to execute and can enable significantly more efficient data fetching.

Use case: plan-time only steps

Another use case for optimize is to make planning-time only steps "evaporate" by replacing them with their parent or a different step.

The loadMany step represents each record via a LoadedRecordStep instance which can be used to .get(attr) a named attribute. This reference is then stored, and at optimize time the LoadedRecordStep can tell the LoadStep to request this attribute (so that the loadMany callback doesn't need to do the equivalent of SELECT * - it can be more selective). However, since LoadedRecordStep has no run-time behavior (only planning-time behavior) it can simply replace itself during optimize with its parent step (typically an __ItemStep).

The built-in each step uses optimize to replace itself with the underlying list where possible.

Use case: simplification

Another use case is simplification.

For example the step representing access(access(access($a, 'b'), 'c'), 'd') could be simplified down to just access($a, ['b', 'c', 'd']), reducing the number of steps in the operation plan.

Similarly first(list([$a, $b])) can be simplified to just $a.

options.meta

If the step sets a this.optimizeMetaKey, then options.meta will be a Record that can be mutated during optimize. This is useful for steps of the same class to communicate with each other out of band, for example list() and object() use it to ensure that if they replace themselves with constants, they do so with the same constant when representing the same values.

finalize

This method is optional.

finalize(): void

This method is called on each step during the finalize lifecycle event. It gives each step a chance to prepare for execution, doing anything that needs to be done just once. A step that deals with a database might precompile its SQL, a step that transforms an object might build an optimized function to do so, there are so many other actions that this step can be used for.

Must call super.finalize() last!

It is critical that the step calls super.finalize() at the end of the finalize() step:

finalize() {
// ... your code here ...

super.finalize();
}
Do not communicate with other steps!

Importantly during this lifecycle event the step should only worry about its own concerns and should not attempt to communicate with its ancestors or descendents — they may not be the steps that it remembers as they may have been switched out during optimize! If the step needs to communicate with its ancestors it should use the optimize method to do so.

Custom methods and conventions

Your step may implement any additional methods that it needs; however certain methods have special meaning. For example, if your step represents an object then it should implement the .get(key) method; and if the step represents an array/list then it should implement the .at(index) method.

These conventions are still evolving, and more may be added as common usage patterns are detected. Some likely candidates for future reserved methods include: import, export, defer, filter, order and merge. To avoid conflicts with built in and future methods, consider using a prefix when naming custom methods.

Functions that have special meanings/expectations can be found below:

at

at(index: number): Step

Implement .at() if your step represents a list or an array. It should accept a single argument, an integer, which represents the index within the list-like value which should be accessed.

Usage:

import { access } from "grafast";

class MyListStep extends Step {
// ...

at(index) {
// Your step may implement a more optimized solution here.
return access(this, index);
}
}
Must follow convention!

If your step implements .at(), make sure it meets the expectations: i.e. it correctly accepts a single argument: an integer. Grafast relies on this assumption; unanticipated behaviours may result from steps which don't adhere to these expectations.

get

get(key: string): Step

Implement .get() if your step represents an object. It should accept a single argument, a string, which represents an attribute to access an object-like value.

import { access } from "grafast";

class MyObjectStep extends Step {
// ...

get(key) {
// Your step may implement a more optimized solution here.
return access(this, key);
}
}
Must follow convention!

If your step implements .get(), make sure it meets the expectations: i.e. it correctly accepts a single argument of a string. ​Grafast relies on this assumption; unanticipated behaviours may result from steps which don't adhere to these expectations.

Implementing .get(key) with per-key type safety

If you have put effort into making your get function type safe (such that accessing different keys returns different values), this will not work with get($step, key) out of the box due to limitations of TypeScript.

To work around this, you can add the __inferGet fake property to your class which should be an object with the same keys as your get function accepts, and the value should be the return type for that key - i.e. it's very similar to your get method itself:

class MyStep extends Step {
__inferGet?: {
[TKey in keyof MyData]: MySpecialStep<MyData[TKey]>;
};
get<TKey extends keyof MyData>(key: TKey): MySpecialStep<MyData[TKey]> {
// ...
}
}

items

items(): Step<any[]>

Implement .items() if your step represents a collection and you want to give users an easy way of accessing the items of your collection (as opposed to metadata you may also wish to make available, such as pagination info). It should accept no arguments (later we might support options related to streaming, so do not implement arguments!) and it should expect to be called zero or more times.

import { access } from "grafast";

class MyCollectionStep extends Step {
// ...

items() {
// Update this to access the correct property needed for the items in your
// collection; you may also choose to track that this was requested and
// thus ensure that fetches only go ahead when necessary.
return access(this, "items");
}
}
Must follow convention!

If your step implements .items(), make sure it meets the expectations: i.e. it does not require any arguments. ​Grafast relies on this assumption; unanticipated behaviours may result from steps which don't adhere to these expectations.

apply

apply($cb: Step<(parent: any) => any): void

Implement .apply() if your step wants to allow for runtime modification of its action based on non-trivial input values - for example, if your step represents an SQL query it might want to allow dynamic WHERE or ORDER BY clauses based on input arguments to a GraphQL field. .apply() will accept a single argument, a step that represents a runtime callback function. The step should then call this function from .execute() before running its main action.

For more information, see handling complex inputs.

toTypename

toTypename($step: Step): Step<string>

Enables a step to return a step that yields the typename, used by the defaultPlanType polymorphic type resolver function when the GraphQL union or interface type does not implement the planType method. If not implemented, this default function will fall back to get($step, '__typename').

toSpecifier

toSpecifier($step: Step): Step

This function name is reserved for convenience such that $step.toSpecifier() should mean the same as abstractType.toSpecifier($step). Grafast does not actually use this method (currently), but it can be convenient for users so we reserve it for specifically this use case.

Importantly, Grafast does not require that a specifier takes a particular form, it's an agreement between the steps you're using and the polymorphic types (unions and interfaces) that you've implemented. We strongly recommend it's a plain-old JavaScript object (POJO) though!

class MyStep extends Step {
// ...
toSpecifier() {
return object({
__typename: this.get("type"),
id: this.get("id"),
});
}
}

const Animal = new GraphQLInterfaceType({
name: "Animal",
// ... fields ...
toSpecifier($step) {
// Call .toSpecifier() if it exists, otherwise use the step directly
return $step.toSpecifier?.() ?? $step;
},
planType($specifier, info) {
// Extract the property that indicates the type from above
const $__typename = get($specifier, "__typename");
return { $__typename };
},
});

const Cat = new GraphQLObjectType({
name: "Cat",
interfaces: [Animal],
// ... fields ...
extensions: {
grafast: {
planType($stepOrSpecifier) {
const $id = get($specifier, "id");
return cats.get({ id: $id });
},
},
},
});

listItem

listItem($item: __ItemStep): Step

If your step represents a list, it may implement .listItem() to wrap the Grafast internal __ItemStep that represents items of this list in a more useful step for child field plan resolvers to use.

A step class that implements .listItem() is called a "list-capable step" (it implements ListCapableStep).

Example

Here our list capable step wraps the __ItemStep in a MyListItemStep to ensure that the child field plan resolvers have access to the methods they expect:

class MyListStep extends Step implements ListCapableStep {
listItem($item: __ItemStep): SingleItemFromMyListStep {
return myListItem($item);
}
}

This might result in a plan diagram as such:

Built in methods

Your custom step class will have access to all the built-in methods that come as part of Step.

addDependency

When your step requires another step's value in order to execute (which is the case for the majority of steps!) it must add a dependency via the this.addDependency($otherStep) method. This method will return a number, which is the index in the execute values tuple that represents this step.

It's common to do this in the constructor, but it can be done at other stages too, for example during the optimize phase a step's descendent might ask it to do additional work, and that work might depend on another step.

In the getting started guide we saw the constructor for the AddStep step class added two dependencies:

class AddStep extends Step {
constructor($a, $b) {
super();
this.addDependency($a); // Returns 0
this.addDependency($b); // Returns 1
}
}
Steps are ephemeral, never store a reference to a step.

You must never store an instance of another step directly (or indirectly) in your step class. Steps come and go at quite a rate during planning - being removed due to deduplicate, optimize, or tree shaking lifecycle events. Referring to a step that no longer exists in the execution plan is likely to make your program have very unexpected behaviors and/or crash.

In the exceedingly unlikely event that you need to reference another step but it is not a dependency, use: this.refIdx = this.addRef($step, "[ENTER REASON WHY I'M NOT USING addDependency HERE]"). You can then use const $ref = this.getRef(this.refIdx) to retrieve the step at a later time; if it exists it may be different to the step you remember, but it should serve the same purpose. However, it may have been deleted due to tree shaking - if this causes a problem, then maybe that step should have been a dependency after all?

addUnaryDependency

Sometimes you'll want to ensure that one or more of the steps your step class depends on will have exactly one value at runtime; to do so, you can use this.addUnaryDependency($step) rather than this.addDependency($step). This asserts that the given dependency is a unary step (a regular step which the system has determined will always represent exactly one value) and is primarily useful when a parameter to a remote service request needs to be the same for all entries in the batch; typically this will be the case for ordering, pagination and access control.

Only for use with steps which will always be unary

this.addUnaryDependency($step) will raise an error during planning if the given $step is not unary, so you should be very careful using it. If in doubt, use this.addDependency($step) instead.

The system steps which represent request–level data (e.g. context, variable and argument values) are always unary steps, and ​Grafast will automatically determine which other steps are also unary steps.

It's generally intended for addUnaryDependency to be used for arguments and their derivatives; it can also be used with context-derived values, but there is complexity when it comes to mutations since context is mutable (whereas input values are not).

getDep

Pass in the number of the dependency (0 for the first dependency, 1 for the second, and so on) and Grafast will return the corresponding step. This should only be used before or during the optimize phase.

For example in the AddStep example above we might have:

const $a = this.getDep(0);
const $b = this.getDep(1);

getDepDeep

EXPERIMENTAL

Like getDep, but skips over __ItemStep and similar built-in intermediary steps to try and get to the original source. Typically useful if you have a step representing an entry from a collection (e.g. a database "row") and you want to get the step representing the entire collection (e.g. a database SELECT statement).

toString

Pretty formatting for the step.

console.log("$a = " + $a.toString());

toStringMeta

You may override this to add additional data to the toString method (the data that would occur between the triangular brackets).

Other properties

id

Every step is assigned a unique id by Grafast. This id may be a string, number, or symbol - treat it as opaque.

note

Currently this value is a number, but Grafast may change it to be a string or symbol in a minor release so you should not rely on its data type. You may, however, rely on String(id) being unique across an operation plan.

hasSideEffects

Set this true if the step has side effects (i.e. causes a mutation) - if this is true then Grafast will not remove this step during tree shaking, and will ensure that the step is executed even if it doesn't appear to be used in any output.

Network requests are not side effects

In languages like Scala/Haskell, a side effect is anything that is visible outside of the function/component itself: a network call, logging, etc. You might have heard that "a pure function has no side effects visible to any observer."

Not so with Grafast, we're not talking about pure functions here. If your step is meant to fetch data from a remote source and return it as part of the result, then that fetch is not a side effect of the step; it's the main event. If nothing ever depends on that data, then we can determine it wasn't needed and we can "tree shake" the step out of the plan such that that fetch never happens. Similarly, if two steps are doing the exact same thing, we may be able to de-duplicate them such that two fetches become one.

For Grafast, a side effect is something that should happen even if nothing ever depends on the result. It's something that must not be tree-shaken away or de-duplicated. The most common use case for this is mutations: adding, deleting, or updating state in your backend storage, triggering an event to be sent, etc.; however it may also be used for things like logging where you want the log statement to run even if nothing ever depends on the output of the step:

const $step = doSomething();

// Nothing depends on this, but because it hasSideEffects it will not be tree
// shaken away, and will be executed.
sideEffect($step, (value) => void console.log(value));

return $step;

If you're looking for the equivalent of a "pure function" in Grafast, the closest is a step that isSyncAndSafe...

isSyncAndSafe

danger

This is a very dangerous optimization, only use it if you're 100% sure you know what you are doing!

Setting this true is a performance optimization, but it comes with strong rules; we do not test you comply with these rules (as that would undo the performance gains) but should you break them the behaviour is undefined (and, basically, the schema may no longer be GraphQL compliant).

Do not set this true unless the following hold:

  • The execute method must be a regular (not async) function
  • The execute method must NEVER return a promise, iterator, or similar
  • The values within the list returned from execute must NEVER include promises, iterators, or similar
  • The result of calling execute should not differ after a step.hasSideEffects has executed (i.e. it should be pure, only dependent on its deps and use no external state)

It's acceptable for the execute method to throw if it needs to, but this will impact every batched value.

This optimisation applies to the majority of the built in plans and allows the engine to execute without needing to resolve any promises which saves precious event-loop ticks.

isOptimized

This is set true after the step has been optimized.

allowMultipleOptimizations

Set this true if your plan's optimize method can be called a second time.

Your dependencies may change classes!

In this situation it's likely that your dependencies (or their dependencies) will not be what you expect them to be (e.g. a PgSelectSingleStep might become an AccessStep due to having been optimized). This, and the fact that it's rarely needed, is why it's not enabled by default.

metaKey

EXPERIMENTAL

You may optionally set this to indicate a key to use for which meta object to be passed in to execute (typically used for caching). To make it unique to the instance of your step, in the constructor after calling super(), set it as this.metaKey = this.id;. If you want to share the same meta object between all steps of a given class, that class may set metaKey to be the name of the class. You can even set it to a shared value between multiple step classes (a "family" of step classes) should that make sense. By default no metaKey is set, and your class will therefore have no meta object.

Inspiration

The loadMany and loadOne standard steps make use of this key to optimize value caching, you may want to look at them for more inspiration.

optimizeMetaKey

EXPERIMENTAL

Makes the meta property available on optimize options.

Inspiration

The list and object standard steps make use of this key for caching constants consistently, you may want to look at them for more inspiration.

Footnotes

  1. To remove a dependency, you must instead replace the step with a copy that does not depend on that step, for example via the optimize lifecycle method.