Docs

CLI: capa-codegen and capa persist

Generate types from your models with capa-codegen, and register persisted queries with capa persist.

View as Markdown

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.ts

Without --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.json

Then 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 production

That 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 persist reads the development key from CAPA_DRAFT_KEY, the name /nextjs reads for drafts (CAPA_KEY when 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 persist sends each document to CAPA_API_URL, the URL your site already reads. https://cdn.capacms.com passes every POST on to the host that stores documents. On a self-hosted stack whose read host stores nothing, set CAPA_ADMIN_URL to the API host your Capa admin uses; it wins over CAPA_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.