CLI: capa-codegen and capa persist
Generate types from your models with capa-codegen, and register persisted queries with capa persist.
Typed select and codegen
Select<T> is exported from @capacms/sdk/next. capa-codegen keeps the legacy
/v2/schema/types shape for schemas without relations. When a schema has
relations, codegen wraps them in branded helpers, which describe the model;
a read's fields is typed from them as the API returns it (see Typed reads):
export type CapaRelation<T> = T & { readonly __capaRelation: "one"; readonly __capaRelationTarget: T };
export type CapaRelationList<T> = T[] & { readonly __capaRelation: "many"; readonly __capaRelationTarget: T };
export interface Article {
title?: string;
author?: CapaRelation<Author>;
coauthors?: CapaRelationList<Author>;
}
export type ArticleSelect = import("@capacms/sdk/next").Select<Article>;Those brands let TypeScript tell scalar fields from relation fields, so misspelled
fields and invalid nested selects fail in consumer typechecks. A relation named
alone ("author") is read as a reference.
Every name is written so the module parses, whatever the namespace holds. A
field that is not an identifier is quoted ("am/pm_indicator"?: string;,
read as fields["am/pm_indicator"]), and a model whose name is not one is
named as GraphQL names it: 2024_events is _2024Events, with
_2024EventsSelect beside it. Two models whose names give one interface name,
such as twin_a and twin-a, are each named from the whole namespace instead:
Model_twin_a and Model_twin$2da, each character that is not a letter, digit
or _ written as $ and its hex code. An enum's values are written as stored,
a quote or a backslash included.
A select names a field by its namespace as saved, whatever it holds:
["price.usd", { "at.place": ["zip.code"] }]. The client writes a name holding
, ( ) : . " * [ ] or a space, or starting with -, quoted, as
REST's grammar reads it: select="price.usd","at.place"("zip.code"). A string
select, sort and where take REST's own text, so write such a name quoted
there yourself: sort: ['-"price.usd"'].
A select also takes the entry's system keys by their $ names, which mean the
system key even on a model with a field of the same plain name
(SystemKey): ["title", "$tags", { coauthors: { select: ["name"], sort: "-$createdAt" } }].
A $ name that is no system key fails to compile. A field whose namespace
starts with $ cannot be named, so select: "*" is how to read it.
Codegen
CAPA_API_URL=... CAPA_KEY=pk_... CAPA_TENANT_ID=... capa-codegen --out src/capa-types.tsWithout --graphql, capa-codegen reads the legacy /v2 schema, which
takes a pk_ key and CAPA_TENANT_ID. A cap_ key, which /v2 refuses,
writes the same interfaces from the key's GraphQL schema, with no tenant id,
and so does --schema <file>, a schema capa-codegen --graphql --save-schema
wrote. GraphQL does not say three things, so those types say less: every
field is optional, an enum is a string, and a field GraphQL leaves out is
unknown. There is no CAPA_SCHEMA_CHECKSUM, since that is the /v2
schema's checksum. Where the API does not serve GraphQL, use a pk_ key. It
reads .env.local and .env too, as next dev does.
Exit codes: 0 wrote or already current, 1 failed, 2 --check found a
diff. --check is the CI mode: it fails when the committed file is stale.
Commit the output. Generating at install needs credentials during
npm install, which breaks CI images and Docker builds, and generating at
build time makes every build depend on the network. A committed file also
turns "a model changed, so the build fails" into a reviewable diff instead of
a wall of tsc errors.
Re-running when nothing changed costs one conditional request that returns no body: codegen stamps the schema's checksum in the file and fetches the types only when the schema moved. Models are written in the order of their interface names, so renaming a model's display name does not reorder the file.
Persisted queries
Register your documents at build time, with a development key:
CAPA_API_URL=https://cdn.capacms.com CAPA_DRAFT_KEY=cap_test_... capa persist --manifest persisted.jsonThen send only their hash, with any key, including the production key your site ships:
import { ArticlesPageDocument } from "./capa-graphql";
const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 }, { persisted: true });With persisted: true the client sends the document's sha256 as one small
GET, which the CDN and the API cache for a production key, and the document
never travels. Variables too long for a GET URL (8,192 bytes) go with the hash
in a POST instead, which works the same way but is not cached. capa persist prints <sha256> <operation> stored, pinned per
operation, hashing the same text capa-codegen --graphql exports, so run
both from the same documents. A document written as a literal is stored
twice, as its <Name>Document text and as written (<Name> (literal)),
since a call with the literal sends that text.
capa persist registers every document with Capa-Persist: pin. The API
drops unpinned documents first when a project's store is full, so the
documents developers register while trying queries in the Explorer or a
draft preview never push your site's documents out. A document the API
stores without its pin is reported as stored, not pinned and the command
exits 3, since it is exposed to exactly that. --manifest writes each
operation's sha256, stored and pinned.
Name the build too, so a run of preview deploys never pushes production's documents out:
capa persist --release "$GIT_COMMIT" --env productionThat sends Capa-Persist: pin; release=<commit>; env=production. A project
keeps up to 2,000 documents and 16 MiB, and when it is full the API keeps
the pins of the latest 3 production releases first, then what a production
key ran in the last 30 days, then other pins, such as a preview build's.
On Vercel and Netlify the command reads both from the build
(VERCEL_GIT_COMMIT_SHA and VERCEL_ENV, or COMMIT_REF and CONTEXT),
so there is nothing to pass. Elsewhere, pass the flags or set CAPA_RELEASE
and CAPA_RELEASE_ENV. A release is 1 to 64 letters, digits, dots, dashes
or underscores, such as a commit or a deploy id, and an environment is a
lowercase name such as production or preview. The two go together, and
the command checks them before it sends anything. The manifest records them
as release and env.
Two things decide whether a document is stored:
- The key.
capa persistreads the development key fromCAPA_DRAFT_KEY, the name/nextjsreads for drafts (CAPA_KEYwhen that is unset), and refuses a production key before it sends anything. A production key ships in your site's bundle, so it may run a document but never register one. - The host.
capa persistsends each document toCAPA_API_URL, the URL your site already reads.https://cdn.capacms.compasses every POST on to the host that stores documents. On a self-hosted stack whose read host stores nothing, setCAPA_ADMIN_URLto the API host your Capa admin uses; it wins overCAPA_API_URL. When a host stores nothing, the command says so and names the variable to set.
When a hash is not stored yet, a production client still gets its data, with
no error: the API answers the GET with PersistedQueryNotFound, the client
sends one POST carrying the document and the hash, and the API runs it and
answers extensions.persistedQuery.registered: false. The client then
remembers that hash for five minutes and sends POST straight away, so a
missing registration costs one extra GET per five minutes rather than one per
call. registered: false on a production read is how to spot a document
capa persist did not register. graphql() from /nextjs is not persisted
by default for the same reason: until capa persist has run, every hash
misses.
A builder read (capa.graphql.query) cannot be persisted, and
persisted: true there throws a TypeError before any request. Its
document is printed when it runs, so capa persist never sees it, and a
production key never stores one. It is already a cacheable GET.