# GraphQL in the SDK

Source: https://capacms.com/docs/sdk/graphql

client.graphql, the typed builder, typed documents and the tool spec for explorers and assistants.

`/api/graphql` reads the same content as `/api/entries`, with the same key,
version, limits and error codes. The schema is built for your key: it has
exactly the models the key can read. The API reference is at
[capacms.com/docs/api/graphql](https://capacms.com/docs/api/graphql).

## In a Next.js server component

```tsx
// app/blog/page.tsx
import { draftMode, headers } from "next/headers";
import { capaAttrs } from "@capacms/sdk/next";
import { graphql, tagsFor } from "@capacms/sdk/nextjs";
import { BlogIndexModels } from "./capa-graphql"; // written by capa-codegen --graphql

const BLOG_INDEX = `#graphql
  query BlogIndex($first: Int) {
    articles(first: $first, sort: [publishedAt_DESC]) {
      nodes { id model title author { name } }
    }
  }
`;

export default async function Blog() {
  const { data } = await graphql(BLOG_INDEX, { first: 5 }, {
    draftMode,
    headers,
    tags: tagsFor({ namespace: BlogIndexModels }), // articles, and authors for author { name }
    revalidate: 60,
  });
  return (
    <ul>
      {data?.articles?.nodes.map((a) => <li key={a.id} {...capaAttrs(a, "title")}>{a.title}</li>)}
    </ul>
  );
}
```

`data` and the variables are typed from the document itself, with no cast,
once `capa-codegen --graphql` has read it (see Typed documents). Until then
the call does not compile, and the error says to run it, so a document edited
since the last run is never silently untyped.

Given `tags` or `revalidate`, `graphql()` keeps a published read in Next's
data cache, through Next's own `unstable_cache`, under the tags you give.
Given neither, it keeps nothing: the read is sent as a REST read is, and the
page shows a publish on its next render. `BlogIndexModels` lists the models
the query reads, which codegen writes beside its types: `articles`, and
`authors` for `author { name }`. It changes when the query does, so a model
the query starts reading is never left out. `tagsFor({ namespace })` makes
one tag per model (`capa:model:articles`), and `capa:media`. The webhook
route above calls `revalidateFromWebhook`, which revalidates a model's tag
whenever an entry of the model is published, unpublished or deleted, so the
page shows the change on the next request. Editing a file in the media
library, its alt text say, changes no entry, so it revalidates `capa:media`
instead, and every read tagged by its models shows the new text. How long a
read is kept:

* With neither `tags` nor `revalidate`, not at all: every render reads the
  API, as `entries.list` does, so a site with no webhook route still shows a
  publish on the next request.
* With `tags` and no `revalidate`, until one of its tags is revalidated.
  With the webhook route, that is until the next publish of a model it reads.
* With `revalidate: 60`, also at most 60 seconds, so a missed webhook costs
  a minute of stale content at most.
* With `revalidate: 0`, not at all.

A read with a `revalidate` and no `tags` is tagged `capa:graphql`
(`GRAPHQL_TAG`), which `revalidateFromWebhook` revalidates on every content
change, so it is never stale after a publish; tag it with its models to
refresh the page only when one of them changes.

A read that answered with `errors` is never kept: a root field that timed
out is shown once and read again on the next request. Next's fetch cache is
not used for a GraphQL read, since it keeps every 200 and a GraphQL error is
a 200, and in Next 15 it keeps nothing without a `revalidate`. A draft and an
edit-mode page are read uncached. Outside a Next request, in a script or a
test, the read is simply sent. `unstable_cache` is an option only to supply
another implementation.

`draftMode` and `headers` work out draft and edit mode as `getCapaClient`
does. Under draft mode the read uses `CAPA_DRAFT_KEY`, a `cap_` key, and
bypasses the cache.
In edit mode (draft mode, or the editor's Published view) each entry that
selected `id` and `model` is marked, so `capaAttrs(node, field)` makes it
clickable in the Capa editor (see Live preview); a visitor's page carries no
tags. Leave both out for a page with no preview.

`graphql()` reads `CAPA_API_URL`, `CAPA_KEY` and `CAPA_API_VERSION` like the
other helpers, and a setting you pass in `config` (`baseUrl`, `apiKey`,
`version`, `fetch`) is used instead of its variable, so a full `config`
needs no env at all. A published read is a GET, which the CDN and the API
also cache for the published key; a document too long for a URL goes as a
POST, which only Next's data cache keeps (see How reads are sent and cached).
The result's `cacheTags`
holds the API's `Surrogate-Key` for a GET (`m:<modelId>`, `e:<entryId>`), for
purging a CDN of your own. `draft: true` or `false` decides draft mode
yourself. Pass `persisted: true` once your documents are stored with
`capa persist` (see Persisted queries).

## In a Node script

```ts
import { createClient, isCapaError } from "@capacms/sdk/next";

const capa = createClient({
  baseUrl: process.env.CAPA_API_URL!,
  apiKey: process.env.CAPA_KEY!,
  version: "2026-10-01",
});

const POPULAR = `#graphql
  query Popular { articles(first: 5, filter: { views: { gte: 10 } }) { nodes { id title } } }
`;

const { data, errors, extensions } = await capa.graphql(POPULAR);

for (const error of errors) console.warn(error.code, error.message, error.hint, error.path);
console.log(data?.articles?.nodes, extensions.cost?.actualQueryCost);
```

For a string codegen has not read, such as one built at run time, pass the
data type yourself: `capa.graphql<{ version: string }>("{ version }")`.

`capa.graphql(document, variables?, options?)` resolves once the API has run
the document, even when `errors` is not empty: the root fields that worked
still carry data, a root field that failed is `null` (which is why every list
root is nullable in the schema and in generated types), and each error is a
`CapaGraphQLError`.

A `CapaGraphQLError` is a plain object: the spec's `message`, `locations`,
`path` and `extensions`, with `code`, `hint`, `docs`, `param` and `type`
lifted out of `extensions`. So `Response.json(result)` in a route handler, and
a server component passing `errors` to a client component, keep every field.
`isCapaGraphQLError(value)` checks one by its shape, so it holds after JSON.

A request the API refuses as a whole throws `CapaError`, whose
`graphqlErrors` holds every error the API sent: a document that does not
parse or validate, a variable of the wrong type, a query over a budget, a
filter the planner refuses. Its body has `errors` and no `data`. GraphQL over
HTTP sends that refusal as a 200 on `application/json`, which this client
asks for, and as a 4xx on `application/graphql-response+json`; either way the
`CapaError` is the same, with `status` 400. Other refusals keep their own
status: the key (401), the plan (402), the origin (403), the contract (404),
a mutation (405) and the rate (429).

The API runs 4 reads at once per project, GraphQL documents and
`/api/entries` reads counted together, and at most 3 of them for one client
of a key, so one busy visitor never holds every slot. Up to 64 more per
client and 256 per key wait for a slot. Past that it answers 429 with
`Retry-After`. The client waits that long, plus up to 250 ms, and sends the
request again, up to 3 times, so a page that reads many things at once from
one key slows down instead of failing. `retries: 0` throws the first 429
instead, and `retries: n` allows n repeats. A 429 that is thrown carries
`retryAfter`, in seconds. A `Retry-After` over 30 seconds is thrown at once
rather than waited out, and `signal` ends a wait early. `entries.list` and
`entries.get` share these limits and throw their 429 with `retryAfter`
rather than repeat it. When an API machine is full of other projects' reads,
it answers 503 `service_unavailable` with `Retry-After`, which is thrown.

Where GraphQL is switched off, the `CapaError`
says "This Capa deployment does not serve GraphQL." and its `hint` says to
read with `entries.list` or `entries.get` meanwhile. Options:
`operationName`, `method`, `persisted: true`, `retries`, and `signal`.

`extensions.cost` is the query's cost as Shopify's APIs report it, in
entries. `requestedQueryCost` is the most the document can read: every list at
its full `first` (25 at a root and 100 nested when you give none), capped at
5,000, plus 500 for each scan. `actualQueryCost` is what it did read, plus 500
for each scan that ran. The 5,000 limit is checked before the document runs,
on a tighter figure: a nested list with no `first` counts 10 for each entry
above it, not 100, so `{ articles(first: 1) { nodes { coauthors { nodes { name } } } } }`
passes as 11 entries and requests 101. That figure is `budget.counted`, and
the limit is `budget.limit`, on every response: it is the number to watch,
since a document is refused exactly when `counted` passes `limit`, and a
refusal states it. There is no per-key budget, so there is no
`throttleStatus`.

A document that uses a deprecated field, argument or enum value gets one
`{ coordinate, reason }` for each in `extensions.deprecations`, such as
`{ "coordinate": "Articles._folder", "reason": "Read folder instead." }`.
`capa-codegen --graphql` warns about the same uses before you ship.

A scan reads entries the answer does not hold. Each of these is one:

* a `totalCount`;
* a filter or sort through a relation;
* a root field that filters or sorts by its model's own fields, once however
  many such conditions it has, so
  `articles(first: 10, filter: { featured: { eq: true } })` requests 510;
* each `contains`, `startsWith`, `endsWith` or `ne` condition past the first;
* a relation list sorted with `sort`.

Filters on `id`, `createdAt`, `updatedAt`, `publishedAt` and `_tags`, and a
relation's `eq`, are served by an index and cost nothing. The API reference
has every rule: [capacms.com/docs/api/graphql#cost](https://capacms.com/docs/api/graphql#cost).

A development key's reads, such as the draft client's, also carry
`extensions.capa`. Its `cost` breaks the cost down against every limit
(depth, root fields, connections, nodes, fields) and counts the `scans`. Its
`rest` names the REST request each root field was answered with. A production
key's reads leave `extensions.capa` out, which keeps a cached page's answer
small.

### How reads are sent and cached

A read is a GET whenever its URL fits the API's limit of 8,192 bytes (path
and query string), and a POST when it does not; a long document is never
refused for its length. `method: "POST"` always sends a POST, and
`method: "GET"` sends a GET that fits and a POST that does not.

| Request                                    | Cached by the API and the CDN                                          |
| ------------------------------------------ | ---------------------------------------------------------------------- |
| GET with a production key, no errors       | yes: `public, max-age=60`, purged by the entries it read (`cacheTags`) |
| GET with a development key                 | no (`no-store`): drafts move                                           |
| any response with `errors`                 | no (`no-store`)                                                        |
| GET selecting `me`, `__schema` or `__type` | no (`no-store`)                                                        |
| POST                                       | never                                                                  |

So a document too long for a GET is not cached. Keep it cacheable with
persisted queries: `persisted: true` sends only the hash by GET (see
Persisted queries). `graphqlSchema()` reads the introspection by POST, since
it is never cached. The admin host serves GraphQL by POST only and answers a
GET as a path it does not serve; a read that did not ask for GET is then
repeated as a POST, and that host is read by POST for five minutes. A
`method: "GET"` read there throws "This Capa host does not serve GraphQL by
GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top
when a read gives `tags` or `revalidate`, for a published read with no
errors, a POST included: kept under its `tags` until one is revalidated, or
for `revalidate` seconds when you give it, and never for a draft.

## The typed builder

Write the query as an object and get the result typed from exactly what you
selected, without writing GraphQL text:

```ts
import { createClient } from "@capacms/sdk/next";
import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql

const capa = createClient<CapaQuery>({ baseUrl, apiKey, version: "2026-10-01" });

const { data } = await capa.graphql.query({
  articles: {
    args: { first: 5, sort: ["publishedAt_DESC"], filter: { featured: { eq: true } } },
    nodes: { title: true, author: { name: true } },
  },
});

data?.articles?.nodes[0].author?.name; // string | null
data?.articles?.nodes[0].body;         // compile error: not selected
```

`true` selects a scalar, an object selects fields of a relation or a
connection, and `args` sits beside the fields of anything that takes
arguments. A misspelled field, argument, filter field or operator fails to
compile, even beside a correct one, and so does an argument of the wrong type,
a sort value that is not in the enum, or a required argument left out
(`article` without `args: { id }`). The compiler's error names the key and
where it was written: `titel: true` under `articles.nodes` fails with
`Type 'true' is not assignable to type 'true & SelectionError<"titel is not a
field of articles.nodes">'`. A `Date` in `args` is sent as its ISO text.
Without `CapaQuery` the builder still runs, untyped.
`selectionToDocument(selection)` returns the text it sends.

`query()` takes the options `capa.graphql` takes, except `persisted`. Its
document is printed when it runs, so `capa persist` never stored it, and it
is already a GET that the API and the CDN cache. To persist a read, write it
as a `#graphql` literal (see Persisted queries).

In a Next.js server component, read through `getCapaClient`. A read that
names tags is kept in Next's data cache under them:

```tsx
// app/blog/page.tsx
import { draftMode, headers } from "next/headers";
import { getCapaClient, tagsFor } from "@capacms/sdk/nextjs";
import type { CapaQuery } from "./capa-graphql";

export default async function Blog() {
  const capa = await getCapaClient<CapaQuery>({ draftMode, headers });
  const { data } = await capa.graphql.query(
    { articles: { args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } } },
    { tags: tagsFor({ namespace: "articles" }), revalidate: 60 },
  );
  return <ul>{data?.articles?.nodes.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
}
```

`tags` and `revalidate` work as they do for `graphql()` (see In a Next.js
server component). A read is kept until a publish of a model its tags name,
a read with errors is never kept, and a draft or an edit-mode page is read
uncached. A read with neither is not kept, as the client's REST reads are
not. `capa.graphql(document)` on the same client takes them too.

Read one field twice in one request with an alias: any other key, with
`__aliasFor` naming the field it reads. A home page's featured and latest
articles are one request:

```ts
const { data: home } = await capa.graphql.query({
  articles: { args: { first: 3, filter: { featured: { eq: true } } }, nodes: { id: true, title: true } },
  latest: { __aliasFor: "articles", args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } },
});

home?.latest?.nodes[0].title; // string | null, typed as articles is
```

The alias is checked as the field it names, arguments and fields included,
and sent as `latest: articles(...)`. A scalar is aliased with `__aliasFor`
alone: `{ headline: { __aliasFor: "title" } }`.

### Typing a component's props

`NodeOf` names one entry of a builder read, so a component that renders it
takes exactly what the selection reads, and the two cannot drift:

```tsx
import type { NodeOf } from "@capacms/sdk/next";
import type { CapaQuery } from "./capa-graphql";

const teasers = {
  articles: { args: { first: 5 }, nodes: { id: true, title: true, author: { name: true } } },
} as const;

// { id: string; title: string | null; author: { name: string | null } | null }
type Teaser = NodeOf<CapaQuery, typeof teasers, "articles">;

function Card({ article }: { article: Teaser }) {
  return <li>{article.title} by {article.author?.name}</li>;
}

const { data } = await capa.graphql.query(teasers);
const cards = data?.articles?.nodes.map((article) => <Card key={article.id} article={article} />);
```

It is a node of a list root, the entry of a single root (`article`), or the
node of an alias (`NodeOf<CapaQuery, typeof home, "latest">`).
`QueryResult<CapaQuery, typeof teasers>` is the type of `data` as a whole.

### The same data in REST's shape

The builder returns GraphQL's shape, because that is what its types describe:
`articles.nodes[0].author.name`. Code written against `/api/entries` reads
entries instead: `data[0].fields.author.fields.name`. `toTree` converts one to
the other:

```ts
import { toTree } from "@capacms/sdk/next";
import { capaTreeLayout } from "./capa-graphql"; // written by capa-codegen --graphql

const selection = {
  articles: {
    args: { first: 5, sort: ["publishedAt_DESC"] },
    nodes: { id: true, status: true, title: true, author: { id: true, status: true, name: true } },
  },
} as const;
const { data } = await capa.graphql.query(selection);
const { articles } = toTree(data, selection, capaTreeLayout);
articles?.map((entry) => entry.fields.title); // each a string | null, as entries.list types it
// deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-publishedAt"], limit: 5 })).data
```

`capaTreeLayout` is what `toTree` reads of your schema (each model's root
fields, and which fields are renamed, relations, ids or media), written by
codegen beside the types, so the conversion reads nothing from the API.
Without codegen, pass the key's schema instead. That is one introspection
request, so read it once and reuse it:

```ts
import { toTree } from "@capacms/sdk/next";

const schema = await capa.graphqlSchema(); // one request: read it once, reuse it
const selection = { articles: { args: { first: 5 }, nodes: { id: true, status: true, title: true } } } as const;
const { data } = await capa.graphql.query(selection);
const { articles } = toTree(data, selection, schema); // the same tree as with capaTreeLayout
```

A selection kept in a variable is declared `as const`, so each `true` and
each `sort` value keeps its exact type and the result is typed exactly.

Each model root field becomes its REST `data`, typed from the selection: a
list root as an array of entries, a single root as one entry or `null`, and
`null` for a root that failed (its error is in `errors`). System fields sit beside `fields`
(`_version` as `version`), every other field sits under `fields` by its
namespace (`hero_image` as `hero-image`), every entry carries its `model`
from the schema, whether you selected it or not, a relation list becomes
`{ items, pageInfo }` (`{ items }` when you did not select its `pageInfo`), a
relation into a model the key cannot read is
`{ id, model }` as REST writes it unexpanded (a list of them is
`{ items, pageInfo }`), and a media value keeps REST's public shape and key
order, `{ id, url, alt, type, width, height }`, as far as you selected it (a
list of media is an array of those).

The result is deep-equal to the REST response for the same read when the
selection names the rest of what REST always returns: `id` and `status` on
every entry, `pageInfo { hasNextPage endCursor }` on each relation list, and
all six media fields. Its type is then assignable to the entry
`entries.list<Articles, typeof select>` returns for the same read, so a
function written for the REST read takes it unchanged. With a typed client,
a selection without `id` and `status` does not compile. Three things differ by design, because GraphQL
does not carry what REST says there:

* A missing single relation (deleted, or a draft the key cannot see) is `null`
  in GraphQL and `{ id, model, missing: true }` in REST.
* A missing item of a relation list is left out in GraphQL, so `items` is
  shorter. REST keeps its slot as `{ id, model, missing: true }`.
* A value that does not fit its field's type is `null` in GraphQL and the raw
  value in REST.

Page a list with the GraphQL result's own `pageInfo`; `version`, `me`
and `entry` are not model roots, and `toTree` refuses them. A root alias
(`latest: { __aliasFor: "articles", ... }`) becomes the REST data of the field
it names, under its own key. An alias below a root has no REST equivalent,
since REST reads each field once, so `toTree` refuses it too.

## Typed documents

```json
{
  "scripts": {
    "dev": "capa-codegen --graphql --watch --out src/capa-graphql.ts & next dev",
    "prebuild": "capa-codegen --graphql --out src/capa-graphql.ts"
  }
}
```

`capa-codegen` reads `CAPA_API_URL` and `CAPA_KEY` from your shell, else from
`.env.local` and `.env` as `next dev` reads them, so a Next.js site needs no
extra setup. `--watch` keeps running and writes the module again whenever a
document changes, and when the key's schema does (it reads the schema again
every minute). A document with a problem is printed with its file and line,
and the last good module stays in place.

Write a document where you use it, marked in one of three ways, and codegen
types it by its text:

```ts
import { createClient, gql } from "@capacms/sdk/next";

const LATEST = `#graphql
  query Latest($first: Int) { articles(first: $first) { nodes { id title } } }
`;
const ONE = /* capa */ `query One($id: ID!) { article(id: $id) { title views } }`;
const VERSION = gql(`query Version { version }`);

const { data } = await capa.graphql(LATEST, { first: 10 });
data?.articles?.nodes[0].title;             // string | null
await capa.graphql(LATEST, { first: "10" }); // compile error
await capa.graphql(ONE);                     // compile error: id is required
```

The generated file adds each literal's text to `CapaDocuments` with
`declare module "@capacms/sdk/next"`, and `client.graphql` and `graphql()`
from `/nextjs` look the text up, so nothing is imported from it. Keep the
generated file inside your tsconfig's `include`. A literal is sent exactly as
written, so it holds one operation and every fragment it spreads, written
inside it or spread in with `${}` (see Fragments); codegen says so, with the
file and line, when it does not. A literal of fragments only is a fragment
source for others.

Codegen also reads every `.graphql` file and every gql`...` tagged template
under `src` (or `--documents dir1,dir2`). A tagged template is a plain string
to TypeScript, so for those, and for `.graphql` files, import the
`<Name>Document` codegen writes for every named operation:

```ts
// src/queries/articles.graphql
// query ArticlesPage($first: Int) { articles(first: $first) { nodes { id title } } }

import { ArticlesPageDocument } from "./capa-graphql";

const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 });
data?.articles?.nodes[0].title;                            // string | null
await capa.graphql(ArticlesPageDocument, { first: "10" }); // compile error
```

### Fragments

```ts
import type { ArticleTeaserFragment } from "./capa-graphql";

const ARTICLE_TEASER = `#graphql
  fragment ArticleTeaser on Articles { id title }
`;

const TEASERS = `#graphql
  query Teasers($first: Int) { articles(first: $first) { nodes { ...ArticleTeaser } } }
  ${ARTICLE_TEASER}
` as const;

function teaser(props: ArticleTeaserFragment) {
  return props.title;
}

const { data } = await capa.graphql(TEASERS, { first: 3 });
data?.articles?.nodes.map(teaser);
```

Write a fragment in a `#graphql` literal of its own and spread it into a
query with `${NAME}`, as Hydrogen does. Codegen reads the query with the
fragment's text in place, which is exactly the text the program sends, so the
query is typed and persisted like any other literal. End the query's template
with `as const`: TypeScript keeps the text of a template with `${}` only
then, and codegen says so when it is missing. A `${}` that names anything
else is text only known at run time, which codegen skips and names.

Codegen writes a `<Name>Fragment` type for every fragment, from a literal or
a `.graphql` file, for a component that takes the fragment's data as props:
the nodes of any query that spreads the fragment fit it.

The generated file also holds the schema's types, `CapaQuery` for the typed
builder, `capaTreeLayout` for `toTree`, and `<Name>Models` beside each
operation: the models it reads, for its cache tags (see In a Next.js server
component). That is each model whose entries it selects, and each model a
filter or sort reaches through a relation. A filter or sort passed as a
variable counts every model its type can reach, and `entry(id:)` counts every
model, since either can read any of them.

Codegen names, with its file and line, every template it does not read that
looks like a query, and says what to change: one left unmarked, graphql`...`
(from `/nextjs` that is a request, not a tag), and one with a `${}` that names
no literal of the project, whose text is only known at run time.

A field the key cannot read is a codegen error with its file and line, so a
model change fails CI instead of production. A field, argument, filter
operator or sort value a later `Capa-Version` phases out is `@deprecated` in
the generated types, so your editor strikes it through, and codegen prints
each use with its file, line and the reason, while still writing the module.
`--check` exits 2 when the committed file is stale. `--save-schema
capa-schema.json` writes the schema it read, and `--schema capa-schema.json`
reads it back instead of calling the API, for CI without a key. Codegen needs
`graphql` installed in your project (`pnpm add -D graphql`); nothing else in
the SDK does.

A literal codegen has not read yet, new or edited since the last run, does
not compile: the error says `run capa-codegen --graphql`. Text built at run
time is a plain `string` and stays untyped, and so does a call that names its
data type, `capa.graphql<{ version: string }>(text)`.

## A REST select as a builder read, and back

`selectToSelection` turns a REST read into a typed builder selection, which
`client.graphql.query` runs and `toTree` turns into that read's `data`.
`graphqlToSelect` gives a builder selection's REST request, as it gives a
tool spec's:

```ts
import { graphqlToSelect, selectToSelection, toTree } from "@capacms/sdk/next";

const schema = await capa.graphqlSchema();
const selection = selectToSelection(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
const { data } = await capa.graphql.query(selection);
const { articles } = toTree(data, selection, schema);
// deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-views"], limit: 5 })).data

graphqlToSelect(schema, { articles: { args: { first: 5 }, nodes: { title: true, author: { name: true } } } }).url;
// "/api/entries/articles?select=title,author(name)&limit=5"
```

The selection names what `toTree` needs to answer what REST answers: `id`,
`model` and `status` on every entry, `pageInfo` on every relation list, and
all six media fields. Its fields are known only when it runs, so its data and
its tree are untyped, with a typed client too. It takes what `selectToGraphQL`
takes and refuses what it refuses. A relation the select names bare, which
REST returns unexpanded as `{ id, model }`, is read expanded to its id, the
REST read `author(id)`, because GraphQL reads a relation to a model the key
can read as an entry. `graphqlToSelect` reads a selection's one model root
field, and throws `CapaBuildError` for any other root (`version`, `entry`),
and for a relation list read from a cursor in a list read, which have no REST
request here. `last` with no `before`, or with `before: null` as Relay and
Apollo send it, reads the end of the list: REST's `before=end`.

## The tool spec: build a query for an explorer or an assistant

Tools that build queries (the admin's Explorer, the MCP server) share one
plain format, the tool spec: `{ model, fields, first, sort, filter }`. The SDK
reads the key's schema once and writes GraphQL from it in Capa's canonical
format, and moves between it and a REST read:

```ts
import { buildGraphQLQuery, graphqlToSelect, selectToGraphQL } from "@capacms/sdk/next";

const schema = await capa.graphqlSchema(); // models, fields, filters, sorts, from one request

const spec = { model: "articles", fields: ["title", { field: "author", fields: ["name"] }], sort: ["views_DESC"], first: 5 };
const { query, variables } = buildGraphQLQuery(schema, spec);
graphqlToSelect(schema, spec).url;
// "/api/entries/articles?select=title,author(name)&sort=-views&limit=5"

selectToGraphQL(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
// the same tool spec, from a REST read
```

The tool spec is not the typed builder's selection: `client.graphql.query`
and `toTree` take the selection, which `selectToSelection` writes.

`graphqlToSelect` writes the REST twin exactly as the API writes it in
`extensions.capa.rest`: the filter as `where` JSON, the root's default page
size left out, a relation list's `first` written as `limit:` whenever you give
it (100 included, since REST counts a limit you write in full and one you
leave out as 10), and a system key a field of the model shadows written with `$`
(`_tags` beside a field called `tags` is `select=$tags,tags`; `createdAt_DESC`
beside a field called `createdAt` is `sort=-$createdAt`). That includes a
field GraphQL leaves out because its name collides with another's, which the
type's description lists as `Not exposed:` and the schema summary as
`notExposed`: beside a hidden field called `id`, `id_DESC` is `sort=-$id`.
`selectToGraphQL` reads `$` names back, and refuses a REST name for a hidden
field, which GraphQL cannot read. It selects what the REST read returns: a
media value with all six of its fields (`id url alt type width height`), and
`*`, or no select, as everything: the system keys `createdAt`, `updatedAt`,
`publishedAt`, `version`, `folder` and `tags`, every field, and each relation
list as a connection of ids (`coauthors { nodes { id } }`), the page of
references REST returns. A reference to an entry the key cannot see is the
one difference left: REST shows it as `{ id, model, missing: true }`, and
GraphQL reads it as `null`, or leaves it out of a list. `buildGraphQLQuery`
with no `fields` keeps its own shorter default, media as `id url alt`. Both helpers send each filter value as the type its
filter input declares, since GraphQL refuses any other: `"10"` for a number
field becomes `10`, and `has: "true"` on a list of true/false values becomes
`true`, which its `BooleanListFilter` takes. A name the schema does not have,
or a key a relation's spec does not take (`frist` for `first`, at any depth),
throws `CapaBuildError` with `didYouMean`, before anything is sent. A `where`
on `$tags` ports to GraphQL's `_tags` filter:

```ts
import { selectToGraphQL } from "@capacms/sdk/next";

const schema = await capa.graphqlSchema();
selectToGraphQL(schema, "articles", "title", { where: { $tags: { has: "news" } } });
// { model: "articles", fields: ["title"], filter: { _tags: { has: "news" } } }
```

A model GraphQL leaves out, because its type name would collide with
another's (`twin_a` and `twin-a`), is listed in the summary's `restOnly`.
Asked for one, the builder throws `CapaBuildError` naming its REST read
(`twin_a is readable over REST only: GET /api/entries/twin_a.`) and why,
with no `didYouMean`: `entries.list("twin_a")` reads it.

Two REST reads have no GraphQL twin and throw too: a `where` on `$version`,
`$folder`, `$model` or `$status` (GraphQL filters `id`, `createdAt`,
`updatedAt`, `publishedAt` and `_tags`), and a filter hop through a relation
the key cannot read (REST answers it `unknown_field`).

A tool spec pages as a REST read does: `first` with `after` reads the page after a
cursor, and `first` with `before` the entries just before one. GraphQL writes
the second as `last` with `before`, and the API refuses `first` there, so
`{ first: 25, before }` prints `articles(last: $last, before: $before)`, and
a tool spec with both `after` and `before` throws, as the API refuses it.
`before: "end"` reads the last entries of the list, REST's `before=end`:
`{ first: 5, before: "end" }` prints `articles(last: $last)`, and the list's
`startCursor` pages back from there. `after: "end"` throws, since `end` is not
a cursor.
A read of one entry also pages a relation list from its cursor, as REST's
`coauthors(name,after:…)` does: `{ field: "coauthors", fields: ["name"],
after }` in a spec with `mode: "single"`, or `args: { after }` on the list in
a selection. A list read throws for it, as the API refuses it there
(`after: applies to a single entry`). `sort` takes one value or a list, at
the root as on a relation list.

A spec written as REST writes a read throws, and says what to write instead:
`"author.name"` or `"author(name)"` in `fields` is
`{ field: "author", fields: ["name"] }`, `"*"` is `fields` left out, and a
sort `"-publishedAt"` is `"publishedAt_DESC"` (in `didYouMean` too).
Relations nest up to 4 deep below the root entry, which with the root is the
API's 5 levels of entries; a fifth throws `CapaBuildError` and names the field
to select without fields, for its id.
