GraphQL in the SDK
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.
In a Next.js server component
// 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
tagsnorrevalidate, not at all: every render reads the API, asentries.listdoes, so a site with no webhook route still shows a publish on the next request. - With
tagsand norevalidate, 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
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,endsWithornecondition 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.
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:
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 selectedtrue 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:
// 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:
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 isThe 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:
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:
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 })).datacapaTreeLayout 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:
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 capaTreeLayoutA 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
nullin GraphQL and{ id, model, missing: true }in REST. - A missing item of a relation list is left out in GraphQL, so
itemsis shorter. REST keeps its slot as{ id, model, missing: true }. - A value that does not fit its field's type is
nullin 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
{
"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:
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 requiredThe 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:
// 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 errorFragments
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:
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:
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 readThe 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:
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.