From 393059ceffec6aa1cb7b0725df7c2781200dd094 Mon Sep 17 00:00:00 2001 From: msotnikov Date: Mon, 9 Mar 2026 12:17:15 +0300 Subject: [PATCH 1/7] Extend documentation with a guide for plan resolvers (#244) --- grafast/grafast/src/steps/lambda.ts | 12 + .../grafast/plan-resolvers/best-practices.md | 245 ++++++++++++++++++ .../website/grafast/plan-resolvers/index.mdx | 9 + .../website/grafast/standard-steps/lambda.md | 16 ++ 4 files changed, 282 insertions(+) create mode 100644 grafast/website/grafast/plan-resolvers/best-practices.md diff --git a/grafast/grafast/src/steps/lambda.ts b/grafast/grafast/src/steps/lambda.ts index ddc0d567a5..e8d9f66d74 100644 --- a/grafast/grafast/src/steps/lambda.ts +++ b/grafast/grafast/src/steps/lambda.ts @@ -1,3 +1,4 @@ +import { isDev } from "../dev.ts"; import type { PromiseOrDirect, UnbatchedExecutionExtra, @@ -8,6 +9,8 @@ import type { Step } from "../step.ts"; import { UnbatchedStep } from "../step.ts"; import { sideEffect } from "./sideEffect.ts"; +const warnedCallbacks = new WeakSet(); + /** * Calls the given lambda function for each tuple */ @@ -72,6 +75,15 @@ function lambda( "lambda callback should accept one argument, perhaps you forgot to destructure the arguments?", ); } + if (isDev && !fn.name && !warnedCallbacks.has(fn)) { + warnedCallbacks.add(fn); + console.warn( + `lambda() was called with an anonymous (inline) callback function. ` + + `This prevents deduplication. Define the callback at file scope or ` + + `give it a name for better optimization. ` + + `See: https://grafast.org/grafast/standard-steps/lambda#define-callback-in-top-scope`, + ); + } const $in = multistep(spec); const $lambda = new LambdaStep, TOut>($in, fn); if ((fn as any).hasSideEffects) { diff --git a/grafast/website/grafast/plan-resolvers/best-practices.md b/grafast/website/grafast/plan-resolvers/best-practices.md new file mode 100644 index 0000000000..7d92d3c57f --- /dev/null +++ b/grafast/website/grafast/plan-resolvers/best-practices.md @@ -0,0 +1,245 @@ +--- +sidebar_position: 5.5 +title: "Best practices" +--- + +# Best practices for plan resolvers + +Plan resolvers are **declarative**: they build a graph of steps at plan-time, +and Gra*fast* executes that graph later in batches. Keeping this mental model in +mind leads to cleaner, faster plans. This page collects the most important +recommendations. + +## Extract arguments deeply + +When accessing nested argument values, prefer extracting the leaf value directly +rather than extracting an intermediate object and then pulling values from it. +This gives Gra*fast* more information about what you actually need, which +enables better optimization. + +```graphql +input UserFilter { + author: String + publishedAfter: Int +} + +type Query { + bookCount(search: String, filter: UserFilter): Int! +} +``` + +### Don't: shallow extraction then transform + +```ts +function bookCount_plan($parent, fieldArgs) { + const $filter = fieldArgs.getRaw("filter"); + // ✘ Creates an unnecessary intermediate lambda step + const $author = lambda($filter, (f) => f?.author); + // ... +} +``` + +### Do: deep extraction directly + +```ts +function bookCount_plan($parent, fieldArgs) { + // ✔ One step, directly optimizable + const $author = fieldArgs.getRaw(["filter", "author"]); + const $publishedAfter = fieldArgs.getRaw(["filter", "publishedAfter"]); + // ... +} +``` + +You can also use the `$`-prefixed shortcut for the same result: + +```ts +function bookCount_plan($parent, fieldArgs) { + const { $search, $filter } = fieldArgs; + const { $author, $publishedAfter } = $filter; + // ... +} +``` + +Both `.getRaw()` with a path array and the `$`-prefixed destructuring give +Gra*fast* direct visibility into exactly which leaf values you need, allowing it +to skip unnecessary work and optimize the plan more aggressively. + +## Prefer custom steps over `lambda` + +[`lambda()`](../standard-steps/lambda.md) is an escape hatch — it +processes values **one at a time** rather than in batches. This is fine for +trivial synchronous transforms (string concatenation, simple math), but for +anything more complex you should create a custom step class. + +### Why custom steps are better + +| | `lambda` | Custom step | +|---|---|---| +| Batching | No — called once per value | Yes — `execute()` receives the full batch | +| Deduplication | Only if callback is the same reference | Full control via `deduplicate()` | +| Optimization | None | Can implement `optimize()` | +| Side effects | Not supported (use `sideEffect()`) | Full control via `hasSideEffects` | + +### When `lambda` is appropriate + +- Concatenating strings: `lambda([$first, $last], ([f, l]) => \`${f} ${l}\`, true)` +- Simple math: `lambda($n, (n) => n + 1, true)` +- Trivial data mapping that doesn't benefit from batching + +### When to create a custom step + +- The transform involves I/O or async work — use + [`loadOne()`](../standard-steps/loadOne.md) / + [`loadMany()`](../standard-steps/loadMany.md) instead +- You want deduplication (e.g. multiple fields perform the same transform) +- The logic is non-trivial or would benefit from batching + +### Example: custom step + +```ts +import { UnbatchedStep } from "grafast"; + +export class FullNameStep extends UnbatchedStep { + static $$export = { + moduleName: "my-app", + exportName: "FullNameStep", + }; + isSyncAndSafe = true; + + constructor($firstName: ExecutableStep, $lastName: ExecutableStep) { + super(); + this.addDependency($firstName); + this.addDependency($lastName); + } + + // Steps with identical dependencies are candidates for deduplication + deduplicate(peers: FullNameStep[]): FullNameStep[] { + return peers; + } + + unbatchedExecute(_extra: UnbatchedExecutionExtra, firstName: string, lastName: string) { + return `${firstName} ${lastName}`; + } +} + +export function fullName($first: ExecutableStep, $last: ExecutableStep) { + return new FullNameStep($first, $last); +} +``` + +## Define lambda callbacks at file scope + +If you do use `lambda`, **always define the callback at file/module scope** +(or import it from another file) rather than inline. Gra*fast* deduplicates +lambda steps by comparing the callback reference — inline arrow functions +create a new reference on every call, defeating deduplication. + +### Don't: inline callback + +```ts +const objects = { + User: { + plans: { + fullName($user) { + const $firstName = $user.get("firstName"); + const $lastName = $user.get("lastName"); + // ✘ New function reference every time — cannot be deduplicated + return lambda([$firstName, $lastName], ([f, l]) => `${f} ${l}`, true); + }, + }, + }, +}; +``` + +### Do: file-scoped callback + +```ts +// ✔ Defined once at module scope — same reference every time +function fullname([firstName, lastName]: [string, string]): string { + return `${firstName} ${lastName}`; +} + +const objects = { + User: { + plans: { + fullName($user) { + const $firstName = $user.get("firstName"); + const $lastName = $user.get("lastName"); + return lambda([$firstName, $lastName], fullname, true); + }, + }, + }, +}; +``` + +## Don't use `try`/`catch` in plan resolvers + +Plan resolvers run at **plan-time**, not execution-time. They build a +declarative graph of steps — think of them like React component render +functions where steps are like hooks. Using `try`/`catch` introduces imperative +control flow that doesn't fit this model. + +### Why it doesn't work + +- Plan resolvers don't execute your data-fetching logic — they only + **describe** it. A `try` block around step creation doesn't catch runtime + data errors because those errors happen later, during execution. +- Wrapping step creation in `try`/`catch` can mask plan-time programming + errors that should be fixed, not caught. +- It suggests a misunderstanding of the plan/execute separation. + +### Don't: try/catch around steps + +```ts +// ✘ This try/catch is meaningless — runtime errors happen during execution, +// not during planning +function post_author_plan($post) { + try { + const $authorId = $post.get("authorId"); + return loadOne($authorId, batchGetAuthorById); + } catch (e) { + return constant(null); + } +} +``` + +### Do: use flow control steps + +Gra*fast* provides declarative flow control for handling errors and null values +at execution-time: + +```ts +import { loadOne, trap, inhibitOnNull, TRAP_ERROR } from "grafast"; + +function post_author_plan($post) { + const $authorId = $post.get("authorId"); + + // Guard against null authorId — skip the load entirely + const $guardedId = inhibitOnNull($authorId); + + // Load the author; if it errors, convert to null + const $author = loadOne($guardedId, batchGetAuthorById); + return trap($author, TRAP_ERROR); +} +``` + +The key flow control steps are: + +- [`inhibitOnNull()`](../standard-steps/inhibitOnNull.mdx) — suppresses + downstream work when a value is `null` +- [`assertNotNull()`](../standard-steps/assertNotNull.mdx) — turns + `null` into a `SafeError` visible to clients +- [`trap()`](../standard-steps/trap.mdx) — recovers inhibited or errored + values back into ordinary data (e.g. `null` or an empty list) + +See [Thinking in plans: Flow control](../flow.mdx#flow-control) for more +details on when and how to use these. + +## Summary + +| Recommendation | Why | +|---|---| +| [Extract arguments deeply](#extract-arguments-deeply) | Fewer intermediate steps, better optimization | +| [Prefer custom steps over `lambda`](#prefer-custom-steps-over-lambda) | Batching, deduplication, optimization | +| [File-scoped lambda callbacks](#define-lambda-callbacks-at-file-scope) | Enables deduplication | +| [No `try`/`catch`](#dont-use-trycatch-in-plan-resolvers) | Plan resolvers are declarative; use flow control steps | diff --git a/grafast/website/grafast/plan-resolvers/index.mdx b/grafast/website/grafast/plan-resolvers/index.mdx index f67525fe54..a9ef6e0c4c 100644 --- a/grafast/website/grafast/plan-resolvers/index.mdx +++ b/grafast/website/grafast/plan-resolvers/index.mdx @@ -25,6 +25,15 @@ as steps to be populated at execution time for each request. ::: +:::tip[Best practices] + +For recommendations on writing efficient and correct plan resolvers — +including deep argument extraction, when to use custom steps vs `lambda`, +and how to handle errors declaratively — see the +[Best practices](./best-practices.md) guide. + +::: + ## Field plan resolvers ```ts diff --git a/grafast/website/grafast/standard-steps/lambda.md b/grafast/website/grafast/standard-steps/lambda.md index e9cae71096..8480fb4d86 100644 --- a/grafast/website/grafast/standard-steps/lambda.md +++ b/grafast/website/grafast/standard-steps/lambda.md @@ -80,6 +80,22 @@ not do this unless you are certain! ::: +## When to use something else + +Before reaching for `lambda`, consider whether a better tool exists: + +- **I/O or async work** → use [`loadOne()`](./loadOne.md) or + [`loadMany()`](./loadMany.md) which support batching +- **Non-trivial transforms that appear in multiple fields** → create a + [custom step class](../step-classes.mdx) with `deduplicate()` support +- **Side effects** → use [`sideEffect()`](/grafast/standard-steps/sideEffect) + +`lambda` is best reserved for trivial, synchronous, pure transforms such as +string concatenation or simple arithmetic. + +See [Plan resolver best practices](../plan-resolvers/best-practices.md) for +more guidance. + ## Warning: no batching! **`lambda` is an escape hatch** that breaks you out of Gra*fast*'s batching; From 755eb6247ba2a68bc37251313b6cd726bf81bfeb Mon Sep 17 00:00:00 2001 From: msotnikov Date: Tue, 10 Mar 2026 23:28:05 +0300 Subject: [PATCH 2/7] Apply suggestions from code review Co-authored-by: Benjie --- .../grafast/plan-resolvers/best-practices.md | 78 ++++++++++++++----- .../website/grafast/plan-resolvers/index.mdx | 2 +- 2 files changed, 58 insertions(+), 22 deletions(-) diff --git a/grafast/website/grafast/plan-resolvers/best-practices.md b/grafast/website/grafast/plan-resolvers/best-practices.md index 7d92d3c57f..487bb84fa9 100644 --- a/grafast/website/grafast/plan-resolvers/best-practices.md +++ b/grafast/website/grafast/plan-resolvers/best-practices.md @@ -69,16 +69,31 @@ to skip unnecessary work and optimize the plan more aggressively. [`lambda()`](../standard-steps/lambda.md) is an escape hatch — it processes values **one at a time** rather than in batches. This is fine for trivial synchronous transforms (string concatenation, simple math), but for -anything more complex you should create a custom step class. +anything more complex you should use a batch step such as `loadOne()`, +`loadMany()`, or a custom class. ### Why custom steps are better -| | `lambda` | Custom step | +| | `lambda` | `loadOne` | Custom step | |---|---|---| -| Batching | No — called once per value | Yes — `execute()` receives the full batch | -| Deduplication | Only if callback is the same reference | Full control via `deduplicate()` | -| Optimization | None | Can implement `optimize()` | -| Side effects | Not supported (use `sideEffect()`) | Full control via `hasSideEffects` | +| Batching | No — called once per value | Yes, batched and uniqued | Yes with full control | +| Deduplication | Only if callback is the same reference | Only if callback is the same reference | Full control via `deduplicate()` | +| Optimization | None | Many automatic optimizations | Full control via `optimize()` / `finalize()` / `execute()` | + +:::note[Batching may not be relevant to mutations/side effects] + +According to +[the GraphQL spec](https://spec.graphql.org/September2025/#sel-GANVLDCB-BBqFxyV), +side effects may only occur in `Mutation` fields, and these fields are +executed serially. This gives no opportunity for batching. Typically +`sideEffect()` (like `lambda()`, but with side effects) is suitable for +a mutation field plan resolver, and no other steps should be needed. + +Side-effects should not happen in other (non-Mutation) plan resolvers, +however any step can be marked as having side effects via: + +```ts +$step.hasSideEffects = true; ### When `lambda` is appropriate @@ -86,13 +101,24 @@ anything more complex you should create a custom step class. - Simple math: `lambda($n, (n) => n + 1, true)` - Trivial data mapping that doesn't benefit from batching +### When to use loadOne/loadMany + +Use [`loadOne()`](../standard-steps/loadOne.md) to loading a single record for each input, or [`loadMany()`](../standard-steps/loadMany.md) to load a collection of records for each input, when: + +- you have async work (except mutations), +- you have I/O work (except mutations), or +- the code would benefit from batching. + ### When to create a custom step -- The transform involves I/O or async work — use - [`loadOne()`](../standard-steps/loadOne.md) / - [`loadMany()`](../standard-steps/loadMany.md) instead -- You want deduplication (e.g. multiple fields perform the same transform) -- The logic is non-trivial or would benefit from batching +Custom steps can be used for any purpose, typically you'll want +to build your own step classes if: + +- You want to expose your own helper APIs (e.g. custom methods on your step) +- You want full control over execution (e.g. if loadOne/loadMany's optimizations don't fit your needs) +- You want full control over deduplication (reducing redundant work) +- You want full control over plan optimization (in particular eliminating over- and under- fetching by communicating with other steps) +- You want to do custom work one time only for your step (custom `finalize()`) ### Example: custom step @@ -174,10 +200,12 @@ const objects = { ## Don't use `try`/`catch` in plan resolvers -Plan resolvers run at **plan-time**, not execution-time. They build a -declarative graph of steps — think of them like React component render -functions where steps are like hooks. Using `try`/`catch` introduces imperative -control flow that doesn't fit this model. +Plan resolvers run at **plan-time**, before any input values are known and +before any data has been fetched. They build a declarative graph of steps +that will be executed later. Since they run at plan-time, `try`/`catch` +will only catch planning errors (which shouldn't really happen!) - it will +not catch execution-time errors (i.e. errors resulting from +fetching/manipulating real data). ### Why it doesn't work @@ -203,7 +231,14 @@ function post_author_plan($post) { } ``` -### Do: use flow control steps +### Do: use `maskError` or similar to process errors + +GraphQL is designed to continue in the face of errors, allowing for +"partial success"; however, you may wish to relabel an error when +presenting it to a user. To do so, use Grafserv's `maskError` +functionality, or similar methods that come with your server of choice. + +### If necessary: use flow control steps Gra*fast* provides declarative flow control for handling errors and null values at execution-time: @@ -219,7 +254,7 @@ function post_author_plan($post) { // Load the author; if it errors, convert to null const $author = loadOne($guardedId, batchGetAuthorById); - return trap($author, TRAP_ERROR); + return trap($author, TRAP_ERROR, { valueForError: "NULL" }); } ``` @@ -230,7 +265,8 @@ The key flow control steps are: - [`assertNotNull()`](../standard-steps/assertNotNull.mdx) — turns `null` into a `SafeError` visible to clients - [`trap()`](../standard-steps/trap.mdx) — recovers inhibited or errored - values back into ordinary data (e.g. `null` or an empty list) + values back into ordinary data (e.g. `null`, an empty list, or the error as + a simple "data value" rather than an exception) See [Thinking in plans: Flow control](../flow.mdx#flow-control) for more details on when and how to use these. @@ -240,6 +276,6 @@ details on when and how to use these. | Recommendation | Why | |---|---| | [Extract arguments deeply](#extract-arguments-deeply) | Fewer intermediate steps, better optimization | -| [Prefer custom steps over `lambda`](#prefer-custom-steps-over-lambda) | Batching, deduplication, optimization | -| [File-scoped lambda callbacks](#define-lambda-callbacks-at-file-scope) | Enables deduplication | -| [No `try`/`catch`](#dont-use-trycatch-in-plan-resolvers) | Plan resolvers are declarative; use flow control steps | +| [Avoid `lambda`, except for inexpensive synchronous work](#prefer-custom-steps-over-lambda) | Batching, deduplication, optimization | +| [File-scoped callbacks](#define-lambda-callbacks-at-file-scope) | Enables deduplication | +| [No plan resolver `try`/`catch`](#dont-use-trycatch-in-plan-resolvers) | Plan resolvers are declarative; use `maskError` or flow control steps | diff --git a/grafast/website/grafast/plan-resolvers/index.mdx b/grafast/website/grafast/plan-resolvers/index.mdx index a9ef6e0c4c..5d38528785 100644 --- a/grafast/website/grafast/plan-resolvers/index.mdx +++ b/grafast/website/grafast/plan-resolvers/index.mdx @@ -28,7 +28,7 @@ as steps to be populated at execution time for each request. :::tip[Best practices] For recommendations on writing efficient and correct plan resolvers — -including deep argument extraction, when to use custom steps vs `lambda`, +including deep argument extraction; when to use custom steps, `loadOne()`, `loadMany()`, or `lambda()`; and how to handle errors declaratively — see the [Best practices](./best-practices.md) guide. From e9c8f6ee1d40ddd5507eabf54bc72e1490c07f7a Mon Sep 17 00:00:00 2001 From: msotnikov Date: Tue, 10 Mar 2026 23:58:00 +0300 Subject: [PATCH 3/7] removed runtime warning --- grafast/grafast/src/steps/lambda.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/grafast/grafast/src/steps/lambda.ts b/grafast/grafast/src/steps/lambda.ts index e8d9f66d74..ddc0d567a5 100644 --- a/grafast/grafast/src/steps/lambda.ts +++ b/grafast/grafast/src/steps/lambda.ts @@ -1,4 +1,3 @@ -import { isDev } from "../dev.ts"; import type { PromiseOrDirect, UnbatchedExecutionExtra, @@ -9,8 +8,6 @@ import type { Step } from "../step.ts"; import { UnbatchedStep } from "../step.ts"; import { sideEffect } from "./sideEffect.ts"; -const warnedCallbacks = new WeakSet(); - /** * Calls the given lambda function for each tuple */ @@ -75,15 +72,6 @@ function lambda( "lambda callback should accept one argument, perhaps you forgot to destructure the arguments?", ); } - if (isDev && !fn.name && !warnedCallbacks.has(fn)) { - warnedCallbacks.add(fn); - console.warn( - `lambda() was called with an anonymous (inline) callback function. ` + - `This prevents deduplication. Define the callback at file scope or ` + - `give it a name for better optimization. ` + - `See: https://grafast.org/grafast/standard-steps/lambda#define-callback-in-top-scope`, - ); - } const $in = multistep(spec); const $lambda = new LambdaStep, TOut>($in, fn); if ((fn as any).hasSideEffects) { From 746aaddb9dea4344bef113c1a249d09ab40110a8 Mon Sep 17 00:00:00 2001 From: msotnikov Date: Tue, 10 Mar 2026 23:58:40 +0300 Subject: [PATCH 4/7] dedup warning --- .../website/grafast/standard-steps/lambda.md | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/grafast/website/grafast/standard-steps/lambda.md b/grafast/website/grafast/standard-steps/lambda.md index 8480fb4d86..36075f9481 100644 --- a/grafast/website/grafast/standard-steps/lambda.md +++ b/grafast/website/grafast/standard-steps/lambda.md @@ -7,7 +7,11 @@ batching!](#warning-no-batching)). It's highly suitable for usage when you just want to synchronously transform data (e.g. concatenating strings, mapping over arrays, etc) but is almost never well suited to asynchronous topics — you likely want [`loadOne()`](./loadOne.md) or [`loadMany()`](./loadMany.md) instead -in those cases. +in those cases. For non-trivial transforms that appear in multiple fields, +consider creating a [custom step class](../step-classes.mdx) with +`deduplicate()` support. + +See also [Plan resolver best practices](../plan-resolvers/best-practices.md). ::: @@ -80,22 +84,6 @@ not do this unless you are certain! ::: -## When to use something else - -Before reaching for `lambda`, consider whether a better tool exists: - -- **I/O or async work** → use [`loadOne()`](./loadOne.md) or - [`loadMany()`](./loadMany.md) which support batching -- **Non-trivial transforms that appear in multiple fields** → create a - [custom step class](../step-classes.mdx) with `deduplicate()` support -- **Side effects** → use [`sideEffect()`](/grafast/standard-steps/sideEffect) - -`lambda` is best reserved for trivial, synchronous, pure transforms such as -string concatenation or simple arithmetic. - -See [Plan resolver best practices](../plan-resolvers/best-practices.md) for -more guidance. - ## Warning: no batching! **`lambda` is an escape hatch** that breaks you out of Gra*fast*'s batching; From 1ddc15b4248cda59bcc345e2a3da2f87c90886a0 Mon Sep 17 00:00:00 2001 From: msotnikov Date: Wed, 11 Mar 2026 00:35:04 +0300 Subject: [PATCH 5/7] fix: after review --- .../grafast/plan-resolvers/best-practices.md | 125 +++++++++++++++--- 1 file changed, 105 insertions(+), 20 deletions(-) diff --git a/grafast/website/grafast/plan-resolvers/best-practices.md b/grafast/website/grafast/plan-resolvers/best-practices.md index 487bb84fa9..9564eb657b 100644 --- a/grafast/website/grafast/plan-resolvers/best-practices.md +++ b/grafast/website/grafast/plan-resolvers/best-practices.md @@ -61,8 +61,10 @@ function bookCount_plan($parent, fieldArgs) { ``` Both `.getRaw()` with a path array and the `$`-prefixed destructuring give -Gra*fast* direct visibility into exactly which leaf values you need, allowing it -to skip unnecessary work and optimize the plan more aggressively. +Gra*fast* direct visibility into exactly which leaf values you need. While +current optimizations (such as the "eliminate eval" pass) reduce the impact of +shallow extraction, deep extraction remains a good habit — it makes your intent +explicit and may become more significant again in future optimizations. ## Prefer custom steps over `lambda` @@ -122,43 +124,126 @@ to build your own step classes if: ### Example: custom step +This example wraps the +[Google Drive files.list](https://developers.google.com/workspace/drive/api/reference/rest/v3/files/list) +API into a custom step that batches multiple file-ID lookups into a single HTTP +request and uses the +[`fields` parameter](https://developers.google.com/workspace/drive/api/guides/fields-parameter) +to fetch only the data the GraphQL query actually needs. + ```ts -import { UnbatchedStep } from "grafast"; +import { ExecutableStep, ExecutionExtra, access } from "grafast"; -export class FullNameStep extends UnbatchedStep { +/** Loads Google Drive file metadata, batching multiple IDs into one API call. */ +export class GoogleDriveFileStep extends ExecutableStep { static $$export = { moduleName: "my-app", - exportName: "FullNameStep", + exportName: "GoogleDriveFileStep", }; - isSyncAndSafe = true; - constructor($firstName: ExecutableStep, $lastName: ExecutableStep) { + // Track which fields the plan actually needs + private fieldPaths: Set = new Set(["id"]); + + constructor($fileId: ExecutableStep) { super(); - this.addDependency($firstName); - this.addDependency($lastName); + this.addDependency($fileId); + } + + /** + * Helper: declare that downstream steps need a particular field. + * Returns an access step that reads the field from the result. + */ + getField(name: string): ExecutableStep { + this.fieldPaths.add(name); + return access(this, name); + } + + /** + * Helper: declare that downstream steps need a nested field. + */ + getNestedField(parent: string, child: string): ExecutableStep { + this.fieldPaths.add(`${parent}(${child})`); + return access(this, [parent, child]); } - // Steps with identical dependencies are candidates for deduplication - deduplicate(peers: FullNameStep[]): FullNameStep[] { + // Deduplicate steps that request the same file ID + deduplicate(peers: GoogleDriveFileStep[]): GoogleDriveFileStep[] { return peers; } - unbatchedExecute(_extra: UnbatchedExecutionExtra, firstName: string, lastName: string) { - return `${firstName} ${lastName}`; + // Merge requested fields from deduplicated peers + deduplicatedWith(peers: GoogleDriveFileStep[]): void { + for (const peer of peers) { + for (const field of peer.fieldPaths) { + this.fieldPaths.add(field); + } + } + } + + // Execute once for the entire batch + async execute( + _extra: ExecutionExtra, + [fileIds]: [string[]], + ): Promise<(GoogleDriveFile | null)[]> { + const uniqueIds = [...new Set(fileIds)]; + const fields = `files(${[...this.fieldPaths].join(",")})`; + + // One HTTP request for the whole batch + const url = new URL("https://www.googleapis.com/drive/v3/files"); + url.searchParams.set("q", uniqueIds.map((id) => `'${id}'`).join(" or ")); + url.searchParams.set("fields", fields); + + const response = await fetch(url); + const { files } = await response.json(); + + // Build a lookup map and return results in the original order + const byId = new Map(files.map((f: GoogleDriveFile) => [f.id, f])); + return fileIds.map((id) => byId.get(id) ?? null); } } -export function fullName($first: ExecutableStep, $last: ExecutableStep) { - return new FullNameStep($first, $last); +export function googleDriveFile($fileId: ExecutableStep) { + return new GoogleDriveFileStep($fileId); +} +``` + +Usage in a plan resolver: + +```ts +function file_plan($parent) { + const $fileId = $parent.get("driveFileId"); + const $file = googleDriveFile($fileId); + + // Only the fields actually requested by the GraphQL query are fetched + const $name = $file.getField("name"); + const $ownerEmail = $file.getNestedField("owners", "emailAddress"); + // ... } ``` -## Define lambda callbacks at file scope +## Define callbacks at file scope + +Many step functions accept a callback. **Always define these callbacks at +file/module scope** (or import them from another file) rather than inline. +Gra*fast* deduplicates steps by comparing the callback reference — inline +arrow functions create a new reference on every call, defeating deduplication. + +This applies to the following functions, in order of importance: + +**Most important** — commonly used in nearly every schema: + +- [`lambda()`](../standard-steps/lambda.md) +- [`loadOne()`](../standard-steps/loadOne.md) +- [`loadMany()`](../standard-steps/loadMany.md) +- [`applyInput()`](../standard-steps/applyInput.md) + +**Also recommended** — less common, but the same principle applies: -If you do use `lambda`, **always define the callback at file/module scope** -(or import it from another file) rather than inline. Gra*fast* deduplicates -lambda steps by comparing the callback reference — inline arrow functions -create a new reference on every call, defeating deduplication. +- [`each()`](../standard-steps/each.md) +- [`filter()`](../standard-steps/filter.md) +- [`groupBy()`](../standard-steps/groupBy.md) +- [`partitionByIndex()`](../standard-steps/partitionByIndex.md) +- [`sideEffect()`](../standard-steps/sideEffect.md) ### Don't: inline callback From a0045ef252e0076ed0dcf237763fdee76ae0aed8 Mon Sep 17 00:00:00 2001 From: msotnikov Date: Wed, 11 Mar 2026 00:45:05 +0300 Subject: [PATCH 6/7] fix: names in explain --- grafast/website/grafast/plan-resolvers/best-practices.md | 1 + 1 file changed, 1 insertion(+) diff --git a/grafast/website/grafast/plan-resolvers/best-practices.md b/grafast/website/grafast/plan-resolvers/best-practices.md index 9564eb657b..eb811dec65 100644 --- a/grafast/website/grafast/plan-resolvers/best-practices.md +++ b/grafast/website/grafast/plan-resolvers/best-practices.md @@ -227,6 +227,7 @@ Many step functions accept a callback. **Always define these callbacks at file/module scope** (or import them from another file) rather than inline. Gra*fast* deduplicates steps by comparing the callback reference — inline arrow functions create a new reference on every call, defeating deduplication. +Named functions also produce more readable debug output and `explain` plans. This applies to the following functions, in order of importance: From 81eafa63e03abef36bdc061ee5c10c21f680cb53 Mon Sep 17 00:00:00 2001 From: msotnikov Date: Wed, 11 Mar 2026 00:56:08 +0300 Subject: [PATCH 7/7] fix: shift emphasis from lambda to overview like --- grafast/website/grafast/plan-resolvers/best-practices.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/grafast/website/grafast/plan-resolvers/best-practices.md b/grafast/website/grafast/plan-resolvers/best-practices.md index eb811dec65..94da3e3bb4 100644 --- a/grafast/website/grafast/plan-resolvers/best-practices.md +++ b/grafast/website/grafast/plan-resolvers/best-practices.md @@ -66,7 +66,7 @@ current optimizations (such as the "eliminate eval" pass) reduce the impact of shallow extraction, deep extraction remains a good habit — it makes your intent explicit and may become more significant again in future optimizations. -## Prefer custom steps over `lambda` +## Choose the right step type [`lambda()`](../standard-steps/lambda.md) is an escape hatch — it processes values **one at a time** rather than in batches. This is fine for @@ -74,7 +74,7 @@ trivial synchronous transforms (string concatenation, simple math), but for anything more complex you should use a batch step such as `loadOne()`, `loadMany()`, or a custom class. -### Why custom steps are better +### Comparison | | `lambda` | `loadOne` | Custom step | |---|---|---| @@ -362,6 +362,6 @@ details on when and how to use these. | Recommendation | Why | |---|---| | [Extract arguments deeply](#extract-arguments-deeply) | Fewer intermediate steps, better optimization | -| [Avoid `lambda`, except for inexpensive synchronous work](#prefer-custom-steps-over-lambda) | Batching, deduplication, optimization | +| [Use `loadOne`/`loadMany` for I/O; reserve `lambda` for trivial sync transforms](#choose-the-right-step-type) | Batching, deduplication, optimization | | [File-scoped callbacks](#define-lambda-callbacks-at-file-scope) | Enables deduplication | | [No plan resolver `try`/`catch`](#dont-use-trycatch-in-plan-resolvers) | Plan resolvers are declarative; use `maskError` or flow control steps |