@capacms/sdk 1.0.0-next.3 → 1.0.0-next.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +323 -0
  2. package/README.md +1163 -186
  3. package/bin/capa-codegen.js +192 -5
  4. package/bin/capa.js +208 -0
  5. package/bin/graphql-project.js +142 -0
  6. package/bin/project-env.js +58 -0
  7. package/dist/client.d.ts +5 -0
  8. package/dist/client.js +17 -0
  9. package/dist/codegen.d.ts +55 -0
  10. package/dist/codegen.js +320 -39
  11. package/dist/config.d.ts +5 -1
  12. package/dist/graphql-codegen.d.ts +117 -0
  13. package/dist/graphql-codegen.js +705 -0
  14. package/dist/http.js +1 -1
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +2 -1
  17. package/dist/next/attrs.d.ts +84 -10
  18. package/dist/next/attrs.js +119 -2
  19. package/dist/next/client.d.ts +160 -31
  20. package/dist/next/client.js +178 -89
  21. package/dist/next/entry-fields.d.ts +162 -0
  22. package/dist/next/entry-fields.js +2 -0
  23. package/dist/next/errors.d.ts +136 -0
  24. package/dist/next/errors.js +214 -0
  25. package/dist/next/field-names.d.ts +37 -0
  26. package/dist/next/field-names.js +145 -0
  27. package/dist/next/graphql/build.d.ts +27 -0
  28. package/dist/next/graphql/build.js +98 -0
  29. package/dist/next/graphql/documents.d.ts +67 -0
  30. package/dist/next/graphql/documents.js +35 -0
  31. package/dist/next/graphql/edit-mode.d.ts +16 -0
  32. package/dist/next/graphql/edit-mode.js +93 -0
  33. package/dist/next/graphql/filter-values.d.ts +34 -0
  34. package/dist/next/graphql/filter-values.js +96 -0
  35. package/dist/next/graphql/introspection.d.ts +89 -0
  36. package/dist/next/graphql/introspection.js +102 -0
  37. package/dist/next/graphql/plan.d.ts +115 -0
  38. package/dist/next/graphql/plan.js +531 -0
  39. package/dist/next/graphql/request.d.ts +228 -0
  40. package/dist/next/graphql/request.js +283 -0
  41. package/dist/next/graphql/rest.d.ts +66 -0
  42. package/dist/next/graphql/rest.js +502 -0
  43. package/dist/next/graphql/selection.d.ts +55 -0
  44. package/dist/next/graphql/selection.js +212 -0
  45. package/dist/next/graphql/sha256.d.ts +13 -0
  46. package/dist/next/graphql/sha256.js +86 -0
  47. package/dist/next/graphql/summary.d.ts +83 -0
  48. package/dist/next/graphql/summary.js +151 -0
  49. package/dist/next/graphql/tree-layout.d.ts +36 -0
  50. package/dist/next/graphql/tree-layout.js +20 -0
  51. package/dist/next/graphql/tree.d.ts +171 -0
  52. package/dist/next/graphql/tree.js +249 -0
  53. package/dist/next/graphql/typed.d.ts +261 -0
  54. package/dist/next/graphql/typed.js +146 -0
  55. package/dist/next/index.d.ts +29 -4
  56. package/dist/next/index.js +31 -1
  57. package/dist/next/inflate.d.ts +51 -0
  58. package/dist/next/inflate.js +243 -0
  59. package/dist/next/key-family.d.ts +34 -0
  60. package/dist/next/key-family.js +74 -0
  61. package/dist/next/select-types.d.ts +58 -5
  62. package/dist/next/system-keys.d.ts +27 -0
  63. package/dist/next/system-keys.js +42 -0
  64. package/dist/nextjs/index.d.ts +333 -6
  65. package/dist/nextjs/index.js +450 -4
  66. package/dist/nextjs/overlay.d.ts +5 -0
  67. package/dist/nextjs/overlay.js +35 -0
  68. package/package.json +35 -13
package/README.md CHANGED
@@ -1,21 +1,34 @@
1
1
  # @capacms/sdk
2
2
 
3
- ## The `/api/` client (`@capacms/sdk/next`)
4
-
5
- Install the SDK from the `next` dist tag:
3
+ The TypeScript SDK for Capa's content API: REST and GraphQL reads typed from
4
+ your models, Next.js caching and live preview, and codegen. The API reference
5
+ is at https://docs.capacms.com/api.
6
6
 
7
7
  ```sh
8
8
  pnpm add @capacms/sdk@next
9
9
  ```
10
10
 
11
+ It runs on Node 18 or later. Its types need TypeScript 5.0 or later, with
12
+ `strict` on or off.
13
+
14
+ | Import | What it is |
15
+ |---|---|
16
+ | `@capacms/sdk/next` | The client for `/api/`: entries, GraphQL, pages and preview. It takes a `cap_` key, or the legacy key a site already holds for reads. Start here. |
17
+ | `@capacms/sdk/nextjs` | Next.js helpers: `graphql()` in a server component, cache tags, draft and edit mode, webhook revalidation. |
18
+ | `@capacms/sdk/nextjs/overlay` | The live preview overlay, as a Next.js client component. |
19
+ | `@capacms/sdk/overlay` | The same overlay without Next. |
20
+ | `@capacms/sdk` | The legacy `/v2/api` client, which takes a legacy key and a tenant id and refuses a `cap_` key, and the webhook signature check. |
21
+
22
+ ## The `/api/` client (`@capacms/sdk/next`)
23
+
11
24
  Create one client per key and pin the platform version in code:
12
25
 
13
26
  ```ts
14
27
  import { createClient, CapaError } from "@capacms/sdk/next";
15
28
 
16
29
  const capa = createClient({
17
- baseUrl: process.env.CAPA_BASE_URL!,
18
- apiKey: process.env.CAPA_API_KEY!, // cap_live_... or cap_test_...
30
+ baseUrl: process.env.CAPA_API_URL!,
31
+ apiKey: process.env.CAPA_KEY!, // cap_live_..., cap_test_..., or the legacy key your site has
19
32
  version: "2026-10-01",
20
33
  contract: 1,
21
34
  });
@@ -23,7 +36,30 @@ const capa = createClient({
23
36
 
24
37
  The `/api/` client sends `x-api-key`, `Capa-Version`, optional
25
38
  `Capa-Contract`, and `Accept: application/json`. It never sends
26
- `X-Tenant-Key`; the tenant comes from the `cap_` key.
39
+ `X-Tenant-Key`; the tenant comes from the key.
40
+
41
+ ### Keys
42
+
43
+ A `cap_` key is the key for `/api/`: scoped, and stored hashed. `cap_live_`
44
+ reads published content, `cap_test_` drafts too. Mint one in the Capa admin
45
+ under Developers > Keys.
46
+
47
+ The legacy key your site already holds works too, for every read: a `pk_`
48
+ or `sk_` key, or an older key with no prefix, since every key but `cap_` is
49
+ a legacy key to the API. That covers `entries`, `graphql`,
50
+ `graphqlSchema`, `pages`, `me` and `versions`. The first client built with
51
+ one prints a warning once per process, naming the `cap_` key to mint. Draft
52
+ previews keep their rule and take a `cap_` key only: `preview()`,
53
+ `draftClient`'s `draft` config, and `CAPA_DRAFT_KEY` for `getCapaClient` and
54
+ `graphql()` throw a `TypeError` for a legacy key before any request. An
55
+ `apiKey` that is not a string is refused where the client is built.
56
+
57
+ `CAPA_API_URL` and `CAPA_KEY` are the names every Capa tool reads: the
58
+ `/nextjs` helpers, `capa-codegen` and Capa's MCP server, so one `.env` serves
59
+ them all. `capa persist` registers with the development key in
60
+ `CAPA_DRAFT_KEY`, since only a development key stores a document (see
61
+ Persisted queries). `CAPA_BASE_URL` and `CAPA_API_KEY` still work as
62
+ aliases; when both are set, the first pair wins.
27
63
 
28
64
  ### Entries
29
65
 
@@ -53,8 +89,8 @@ const one = await capa.entries.get("articles", "entry-id", {
53
89
  });
54
90
  ```
55
91
 
56
- `select` may be the grammar string from `docs/api/entries.md` or the object
57
- form above. Lists return `{ data, page, meta, cacheTags }`; singles return
92
+ `select` may be the grammar string the API reference describes
93
+ (https://docs.capacms.com/api/entries) or the object form above. Lists return `{ data, page, meta, cacheTags }`; singles return
58
94
  `{ data, meta, cacheTags }`. `cacheTags` is parsed from the `Surrogate-Key`
59
95
  header. `get()` returns `null` for `404 entry_not_found` and throws every other
60
96
  error.
@@ -64,7 +100,7 @@ Filters use the `/api/` operators:
64
100
  ```ts
65
101
  await capa.entries.list("articles", {
66
102
  filter: {
67
- id: { in: ["a", "b"] },
103
+ id: { in: ["00000000-0000-4000-8000-000000000021", "00000000-0000-4000-8000-000000000022"] },
68
104
  views: { gte: 10 },
69
105
  tags: { hasAny: ["news", "launch"] },
70
106
  },
@@ -75,6 +111,102 @@ await capa.entries.list("articles", {
75
111
  Unknown filter operators throw a local `TypeError` before any request is sent.
76
112
  Per-call `{ signal }` is forwarded to `fetch`.
77
113
 
114
+ #### Typed reads
115
+
116
+ `capa-codegen` writes an interface per model (see Codegen). Pass it, and the
117
+ select's own type, and `fields` is typed as the API returns it:
118
+
119
+ ```ts
120
+ import type { Articles, ArticlesSelect } from "./capa-types"; // written by capa-codegen
121
+
122
+ const select = [
123
+ "title",
124
+ { author: ["name"] },
125
+ { coauthors: { select: ["name"], limit: 3 } },
126
+ ] as const satisfies ArticlesSelect;
127
+
128
+ const typed = await capa.entries.list<Articles, typeof select>("articles", { select });
129
+
130
+ for (const article of typed.data) {
131
+ const { title, author, coauthors } = article.fields;
132
+ if (author && "fields" in author) console.log(title, author.fields.name);
133
+ for (const coauthor of coauthors.items) {
134
+ if ("fields" in coauthor) console.log(coauthor.fields.name);
135
+ }
136
+ article.fields.body; // compile error: the select does not name it
137
+ }
138
+ ```
139
+
140
+ - A relation the select expands is the related entry, with its own `fields`,
141
+ or `{ id, model, missing: true }` when that entry was deleted, is
142
+ unpublished for a production key, or is in a model the key cannot read.
143
+ `"fields" in author` tells them apart.
144
+ - A relation the select names without expanding it (`"author"`, or every
145
+ relation under `*`) is a reference, `{ id, model }`.
146
+ - A relation list is `{ items, pageInfo }`, expanded or not.
147
+ - Media is `{ id, url, alt, type, width, height }`.
148
+ - A field the select names is always there, and `null` when it was never
149
+ filled, as the API writes it. `title?: string` in the model reads as
150
+ `string | null`.
151
+ - A field the select does not name is not there, so reading it does not
152
+ compile.
153
+
154
+ Without `typeof select`, or with a select written as a string, every field is
155
+ typed, and each relation as whichever of the three it may be. `get` and
156
+ `iterate` take the same two types, and `EntryFields<Articles, typeof select>`
157
+ names the type of `fields`.
158
+
159
+ ### Flat responses: each related entry once
160
+
161
+ By default an expanded relation is nested where you selected it, so twenty
162
+ articles by one author carry that author twenty times. Pass `shape: "flat"` and
163
+ every relation comes back as a `{ id, model }` reference, with each expanded
164
+ entry once in `included`, keyed by model namespace and then id:
165
+
166
+ ```ts
167
+ const select = ["title", { author: ["name"] }] as const satisfies Select<Article>;
168
+ const flat = await capa.entries.list<Article, typeof select>("articles", {
169
+ select,
170
+ shape: "flat",
171
+ });
172
+
173
+ flat.data[0].fields.author; // { id: "…", model: "authors" }
174
+ flat.included.authors[authorId].fields; // { name: "Ada Vale" }, typed from Author
175
+ ```
176
+
177
+ `included` is typed by the select: the union of the entry types it expands, at
178
+ any depth, each field of which may be absent, since an entry holds what every
179
+ path that reached it selected. A relation in `data` or in `included` is a
180
+ reference. A select written as a plain string types `included` as
181
+ `Record<string, unknown>`. `get` takes `shape: "flat"` the same way.
182
+ `iterate` reads the tree shape only.
183
+
184
+ `inflate` turns a flat result back into the tree result, deep-equal to what the
185
+ same request without `shape` returns:
186
+
187
+ ```ts
188
+ import { inflate } from "@capacms/sdk/next";
189
+
190
+ const tree = inflate(flat); // { data, page, meta, cacheTags }, typed as the tree read
191
+ ```
192
+
193
+ - It walks the select the request sent. A result from this client carries it
194
+ (`flat.select`); for a body you fetched yourself, pass it:
195
+ `inflate(body, "title,author(name)")`.
196
+ - `$tags`, `$createdAt` and the other `$` names in the select are the system
197
+ keys, as the API reads them, so `select=$tags,tags` on a model with its own
198
+ `tags` field comes back with both.
199
+ - It returns copies. The same author under twenty articles is twenty equal,
200
+ independent objects, as when a tree body is parsed. The input is not changed.
201
+ - Cycles end where the select ends: `a` related to `b` related to `a` is
202
+ inflated to the depth you wrote and no further.
203
+ - An entry reached by two paths holds the union of what they selected, and one
204
+ value per field. If two paths expand the same array relation with different
205
+ `limit` or `sort`, the first one wins, and `inflate` cannot tell them apart.
206
+ Every other request round-trips exactly.
207
+ - In edit mode, included entries are marked, and so is every copy `inflate`
208
+ makes of them.
209
+
78
210
  ### Errors
79
211
 
80
212
  ```ts
@@ -105,7 +237,11 @@ const capa = createClient({ baseUrl, apiKey, version: "2026-10-01", fetch: fetch
105
237
  ```
106
238
 
107
239
  `withCache` merges `{ next: { tags, revalidate } }` into every fetch call.
108
- `tagsFor` builds Capa surrogate keys: `m:`, `e:`, `k:`, and `t:`.
240
+ `tagsFor` builds Capa surrogate keys: `m:`, `e:`, `k:`, and `t:`, by id, and
241
+ `tagsFor({ namespace: "articles" })` builds `capa:model:articles`, the tag
242
+ for a GraphQL read (see GraphQL), and `capa:media`. `revalidateFromWebhook`
243
+ revalidates all of them for the entry and model a webhook names, and for a
244
+ media event, the file's `f:` key and `capa:media`.
109
245
 
110
246
  Webhook revalidation pairs with the existing signature verifier:
111
247
 
@@ -139,19 +275,773 @@ SDK never imports `next/*`:
139
275
  ```ts
140
276
  import { draftMode } from "next/headers";
141
277
  import { draftClient } from "@capacms/sdk/nextjs";
278
+ import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql
142
279
 
143
- const capa = await draftClient({
280
+ const capa = await draftClient<CapaQuery>({
144
281
  production,
145
282
  draft,
146
283
  isDraft: async () => (await draftMode()).isEnabled,
147
284
  });
148
285
  ```
149
286
 
287
+ `CapaQuery` types the GraphQL builder on the client it returns, as it does
288
+ for `createClient<CapaQuery>`; `getCapaClient<CapaQuery>` and
289
+ `getPublishedClient<CapaQuery>` take it the same way. Without it each helper
290
+ returns an untyped client. `getCapaClient` also keeps its GraphQL reads in
291
+ Next's data cache, as `graphql()` does, under the `tags` and `revalidate`
292
+ each call gives (see The typed builder).
293
+
294
+ ## GraphQL
295
+
296
+ `/api/graphql` reads the same content as `/api/entries`, with the same key,
297
+ version, limits and error codes. The schema is built for your key: it has
298
+ exactly the models the key can read. The API reference is at
299
+ https://docs.capacms.com/api/graphql.
300
+
301
+ ### In a Next.js server component
302
+
303
+ ```tsx
304
+ // app/blog/page.tsx
305
+ import { draftMode, headers } from "next/headers";
306
+ import { capaAttrs } from "@capacms/sdk/next";
307
+ import { graphql, tagsFor } from "@capacms/sdk/nextjs";
308
+ import { BlogIndexModels } from "./capa-graphql"; // written by capa-codegen --graphql
309
+
310
+ const BLOG_INDEX = `#graphql
311
+ query BlogIndex($first: Int) {
312
+ articles(first: $first, sort: [publishedAt_DESC]) {
313
+ nodes { id model title author { name } }
314
+ }
315
+ }
316
+ `;
317
+
318
+ export default async function Blog() {
319
+ const { data } = await graphql(BLOG_INDEX, { first: 5 }, {
320
+ draftMode,
321
+ headers,
322
+ tags: tagsFor({ namespace: BlogIndexModels }), // articles, and authors for author { name }
323
+ revalidate: 60,
324
+ });
325
+ return (
326
+ <ul>
327
+ {data?.articles?.nodes.map((a) => <li key={a.id} {...capaAttrs(a, "title")}>{a.title}</li>)}
328
+ </ul>
329
+ );
330
+ }
331
+ ```
332
+
333
+ `data` and the variables are typed from the document itself, with no cast,
334
+ once `capa-codegen --graphql` has read it (see Typed documents). Until then
335
+ the call does not compile, and the error says to run it, so a document edited
336
+ since the last run is never silently untyped.
337
+
338
+ `graphql()` keeps a published read in Next's data cache, through Next's own
339
+ `unstable_cache`, under the tags you give. `BlogIndexModels` lists the models
340
+ the query reads, which codegen writes beside its types: `articles`, and
341
+ `authors` for `author { name }`. It changes when the query does, so a model
342
+ the query starts reading is never left out. `tagsFor({ namespace })` makes
343
+ one tag per model (`capa:model:articles`), and `capa:media`. The webhook
344
+ route above calls `revalidateFromWebhook`, which revalidates a model's tag
345
+ whenever an entry of the model is published, unpublished or deleted, so the
346
+ page shows the change on the next request. Editing a file in the media
347
+ library, its alt text say, changes no entry, so it revalidates `capa:media`
348
+ instead, and every read tagged by its models shows the new text. How long a
349
+ read is kept:
350
+
351
+ - With no `revalidate`, until one of its tags is revalidated. With the
352
+ webhook route, that is until the next publish of a model it reads.
353
+ - With `revalidate: 60`, also at most 60 seconds, so a missed webhook costs
354
+ a minute of stale content at most. This is the only thing it adds.
355
+ - With `revalidate: 0`, not at all: every render reads the API.
356
+
357
+ A read with no `tags` is tagged `capa:graphql` (`GRAPHQL_TAG`), which
358
+ `revalidateFromWebhook` revalidates on every content change, so it is never
359
+ stale after a publish; tag it with its models to refresh the page only when
360
+ one of them changes.
361
+
362
+ A read that answered with `errors` is never kept: a root field that timed
363
+ out is shown once and read again on the next request. Next's fetch cache is
364
+ not used for a GraphQL read, since it keeps every 200 and a GraphQL error is
365
+ a 200, and in Next 15 it keeps nothing without a `revalidate`. A draft and an
366
+ edit-mode page are read uncached. Outside a Next request, in a script or a
367
+ test, the read is simply sent. `unstable_cache` is an option only to supply
368
+ another implementation.
369
+
370
+ `draftMode` and `headers` work out draft and edit mode as `getCapaClient`
371
+ does. Under draft mode the read uses `CAPA_DRAFT_KEY`, a `cap_` key, and
372
+ bypasses the cache.
373
+ In edit mode (draft mode, or the editor's Published view) each entry that
374
+ selected `id` and `model` is marked, so `capaAttrs(node, field)` makes it
375
+ clickable in the Capa editor (see Live preview); a visitor's page carries no
376
+ tags. Leave both out for a page with no preview.
377
+
378
+ `graphql()` reads `CAPA_API_URL`, `CAPA_KEY` and `CAPA_API_VERSION` like the
379
+ other helpers, and a setting you pass in `config` (`baseUrl`, `apiKey`,
380
+ `version`, `fetch`) is used instead of its variable, so a full `config`
381
+ needs no env at all. A published read is a GET, which the CDN and the API
382
+ also cache for the published key; a document too long for a URL goes as a
383
+ POST, which only Next's data cache keeps (see How reads are sent and cached).
384
+ The result's `cacheTags`
385
+ holds the API's `Surrogate-Key` for a GET (`m:<modelId>`, `e:<entryId>`), for
386
+ purging a CDN of your own. `draft: true` or `false` decides draft mode
387
+ yourself. Pass `persisted: true` once your documents are stored with
388
+ `capa persist` (see Persisted queries).
389
+
390
+ ### In a Node script
391
+
392
+ ```ts
393
+ import { createClient, isCapaError } from "@capacms/sdk/next";
394
+
395
+ const capa = createClient({
396
+ baseUrl: process.env.CAPA_API_URL!,
397
+ apiKey: process.env.CAPA_KEY!,
398
+ version: "2026-10-01",
399
+ });
400
+
401
+ const POPULAR = `#graphql
402
+ query Popular { articles(first: 5, filter: { views: { gte: 10 } }) { nodes { id title } } }
403
+ `;
404
+
405
+ const { data, errors, extensions } = await capa.graphql(POPULAR);
406
+
407
+ for (const error of errors) console.warn(error.code, error.message, error.hint, error.path);
408
+ console.log(data?.articles?.nodes, extensions.cost?.actualQueryCost);
409
+ ```
410
+
411
+ For a string codegen has not read, such as one built at run time, pass the
412
+ data type yourself: `capa.graphql<{ version: string }>("{ version }")`.
413
+
414
+ `capa.graphql(document, variables?, options?)` resolves once the API has run
415
+ the document, even when `errors` is not empty: the root fields that worked
416
+ still carry data, a root field that failed is `null` (which is why every list
417
+ root is nullable in the schema and in generated types), and each error is a
418
+ `CapaGraphQLError`.
419
+
420
+ A `CapaGraphQLError` is a plain object: the spec's `message`, `locations`,
421
+ `path` and `extensions`, with `code`, `hint`, `docs`, `param` and `type`
422
+ lifted out of `extensions`. So `Response.json(result)` in a route handler, and
423
+ a server component passing `errors` to a client component, keep every field.
424
+ `isCapaGraphQLError(value)` checks one by its shape, so it holds after JSON.
425
+
426
+ A request the API refuses as a whole throws `CapaError`, whose
427
+ `graphqlErrors` holds every error the API sent: a document that does not
428
+ parse or validate, a variable of the wrong type, a query over a budget, a
429
+ filter the planner refuses. Its body has `errors` and no `data`. GraphQL over
430
+ HTTP sends that refusal as a 200 on `application/json`, which this client
431
+ asks for, and as a 4xx on `application/graphql-response+json`; either way the
432
+ `CapaError` is the same, with `status` 400. Other refusals keep their own
433
+ status: the key (401), the plan (402), the origin (403), the contract (404),
434
+ a mutation (405) and the rate (429).
435
+
436
+ The API runs 4 reads at once per project, GraphQL documents and
437
+ `/api/entries` reads counted together, and at most 3 of them for one client
438
+ of a key, so one busy visitor never holds every slot. Up to 64 more per
439
+ client and 256 per key wait for a slot. Past that it answers 429 with
440
+ `Retry-After`. The client waits that long, plus up to 250 ms, and sends the
441
+ request again, up to 3 times, so a page that reads many things at once from
442
+ one key slows down instead of failing. `retries: 0` throws the first 429
443
+ instead, and `retries: n` allows n repeats. A 429 that is thrown carries
444
+ `retryAfter`, in seconds. A `Retry-After` over 30 seconds is thrown at once
445
+ rather than waited out, and `signal` ends a wait early. `entries.list` and
446
+ `entries.get` share these limits and throw their 429 with `retryAfter`
447
+ rather than repeat it. When an API machine is full of other projects' reads,
448
+ it answers 503 `service_unavailable` with `Retry-After`, which is thrown.
449
+
450
+ Where GraphQL is switched off (`CAPA_API_GRAPHQL=off`), the `CapaError`
451
+ says "This Capa deployment does not serve GraphQL." and its `hint` says to
452
+ read with `entries.list` or `entries.get` meanwhile. Options:
453
+ `operationName`, `method`, `persisted: true`, `retries`, and `signal`.
454
+
455
+ `extensions.cost` is the query's cost as Shopify's APIs report it, in
456
+ entries. `requestedQueryCost` is the most the document can read: every list at
457
+ its full `first` (25 at a root and 100 nested when you give none), capped at
458
+ 5,000, plus 500 for each scan. `actualQueryCost` is what it did read, plus 500
459
+ for each scan that ran. The 5,000 limit is checked before the document runs,
460
+ on a tighter figure: a nested list with no `first` counts 10 for each entry
461
+ above it, not 100, so `{ articles(first: 1) { nodes { coauthors { nodes { name } } } } }`
462
+ passes as 11 entries and requests 101. That figure is `budget.counted`, and
463
+ the limit is `budget.limit`, on every response: it is the number to watch,
464
+ since a document is refused exactly when `counted` passes `limit`, and a
465
+ refusal states it. There is no per-key budget, so there is no
466
+ `throttleStatus`.
467
+
468
+ A document that uses a deprecated field, argument or enum value gets one
469
+ `{ coordinate, reason }` for each in `extensions.deprecations`, such as
470
+ `{ "coordinate": "Articles._folder", "reason": "Read folder instead." }`.
471
+ `capa-codegen --graphql` warns about the same uses before you ship.
472
+
473
+ A scan reads entries the answer does not hold. Each of these is one:
474
+
475
+ - a `totalCount`;
476
+ - a filter or sort through a relation;
477
+ - a root field that filters or sorts by its model's own fields, once however
478
+ many such conditions it has, so
479
+ `articles(first: 10, filter: { featured: { eq: true } })` requests 510;
480
+ - each `contains`, `startsWith`, `endsWith` or `ne` condition past the first;
481
+ - a relation list sorted with `sort`.
482
+
483
+ Filters on `id`, `createdAt`, `updatedAt`, `publishedAt` and `_tags`, and a
484
+ relation's `eq`, are served by an index and cost nothing. The API reference
485
+ has every rule: https://docs.capacms.com/api/graphql#cost.
486
+
487
+ A development key's reads, such as the draft client's, also carry
488
+ `extensions.capa`. Its `cost` breaks the cost down against every limit
489
+ (depth, root fields, connections, nodes, fields) and counts the `scans`. Its
490
+ `rest` names the REST request each root field was answered with. A production
491
+ key's reads leave `extensions.capa` out, which keeps a cached page's answer
492
+ small.
493
+
494
+ #### How reads are sent and cached
495
+
496
+ A read is a GET whenever its URL fits the API's limit of 8,192 bytes (path
497
+ and query string), and a POST when it does not; a long document is never
498
+ refused for its length. `method: "POST"` always sends a POST, and
499
+ `method: "GET"` sends a GET that fits and a POST that does not.
500
+
501
+ | Request | Cached by the API and the CDN |
502
+ |---|---|
503
+ | GET with a production key, no errors | yes: `public, max-age=60`, purged by the entries it read (`cacheTags`) |
504
+ | GET with a development key | no (`no-store`): drafts move |
505
+ | any response with `errors` | no (`no-store`) |
506
+ | GET selecting `me`, `__schema` or `__type` | no (`no-store`) |
507
+ | POST | never |
508
+
509
+ So a document too long for a GET is not cached. Keep it cacheable with
510
+ persisted queries: `persisted: true` sends only the hash by GET (see
511
+ Persisted queries). `graphqlSchema()` reads the introspection by POST, since
512
+ it is never cached. The admin host serves GraphQL by POST only and answers a
513
+ GET as a path it does not serve; a read that did not ask for GET is then
514
+ repeated as a POST, and that host is read by POST for five minutes. A
515
+ `method: "GET"` read there throws "This Capa host does not serve GraphQL by
516
+ GET." In Next.js, `graphql()` from `/nextjs` adds Next's data cache on top,
517
+ for a published read with no errors, a POST included: kept under its `tags`
518
+ until one is revalidated, or for `revalidate` seconds when you give it, and
519
+ never for a draft.
520
+
521
+ ### The typed builder
522
+
523
+ Write the query as an object and get the result typed from exactly what you
524
+ selected, without writing GraphQL text:
525
+
526
+ ```ts
527
+ import { createClient } from "@capacms/sdk/next";
528
+ import type { CapaQuery } from "./capa-graphql"; // written by capa-codegen --graphql
529
+
530
+ const capa = createClient<CapaQuery>({ baseUrl, apiKey, version: "2026-10-01" });
531
+
532
+ const { data } = await capa.graphql.query({
533
+ articles: {
534
+ args: { first: 5, sort: ["publishedAt_DESC"], filter: { featured: { eq: true } } },
535
+ nodes: { title: true, author: { name: true } },
536
+ },
537
+ });
538
+
539
+ data?.articles?.nodes[0].author?.name; // string | null
540
+ data?.articles?.nodes[0].body; // compile error: not selected
541
+ ```
542
+
543
+ `true` selects a scalar, an object selects fields of a relation or a
544
+ connection, and `args` sits beside the fields of anything that takes
545
+ arguments. A misspelled field, argument, filter field or operator fails to
546
+ compile, even beside a correct one, and so does an argument of the wrong type,
547
+ a sort value that is not in the enum, or a required argument left out
548
+ (`article` without `args: { id }`). The compiler's error names the key and
549
+ where it was written: `titel: true` under `articles.nodes` fails with
550
+ `Type 'true' is not assignable to type 'true & SelectionError<"titel is not a
551
+ field of articles.nodes">'`. A `Date` in `args` is sent as its ISO text.
552
+ Without `CapaQuery` the builder still runs, untyped.
553
+ `selectionToDocument(selection)` returns the text it sends.
554
+
555
+ `query()` takes the options `capa.graphql` takes, except `persisted`. Its
556
+ document is printed when it runs, so `capa persist` never stored it, and it
557
+ is already a GET that the API and the CDN cache. To persist a read, write it
558
+ as a `#graphql` literal (see Persisted queries).
559
+
560
+ In a Next.js server component, read through `getCapaClient`. Its reads are
561
+ kept in Next's data cache, under the tags each call names:
562
+
563
+ ```tsx
564
+ // app/blog/page.tsx
565
+ import { draftMode, headers } from "next/headers";
566
+ import { getCapaClient, tagsFor } from "@capacms/sdk/nextjs";
567
+ import type { CapaQuery } from "./capa-graphql";
568
+
569
+ export default async function Blog() {
570
+ const capa = await getCapaClient<CapaQuery>({ draftMode, headers });
571
+ const { data } = await capa.graphql.query(
572
+ { articles: { args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } } },
573
+ { tags: tagsFor({ namespace: "articles" }), revalidate: 60 },
574
+ );
575
+ return <ul>{data?.articles?.nodes.map((a) => <li key={a.id}>{a.title}</li>)}</ul>;
576
+ }
577
+ ```
578
+
579
+ `tags` and `revalidate` work as they do for `graphql()` (see In a Next.js
580
+ server component). A read is kept until a publish of a model its tags name,
581
+ a read with errors is never kept, and a draft or an edit-mode page is read
582
+ uncached. `capa.graphql(document)` on the same client takes them too.
583
+
584
+ Read one field twice in one request with an alias: any other key, with
585
+ `__aliasFor` naming the field it reads. A home page's featured and latest
586
+ articles are one request:
587
+
588
+ ```ts
589
+ const { data: home } = await capa.graphql.query({
590
+ articles: { args: { first: 3, filter: { featured: { eq: true } } }, nodes: { id: true, title: true } },
591
+ latest: { __aliasFor: "articles", args: { first: 5, sort: ["publishedAt_DESC"] }, nodes: { id: true, title: true } },
592
+ });
593
+
594
+ home?.latest?.nodes[0].title; // string | null, typed as articles is
595
+ ```
596
+
597
+ The alias is checked as the field it names, arguments and fields included,
598
+ and sent as `latest: articles(...)`. A scalar is aliased with `__aliasFor`
599
+ alone: `{ headline: { __aliasFor: "title" } }`.
600
+
601
+ #### Typing a component's props
602
+
603
+ `NodeOf` names one entry of a builder read, so a component that renders it
604
+ takes exactly what the selection reads, and the two cannot drift:
605
+
606
+ ```tsx
607
+ import type { NodeOf } from "@capacms/sdk/next";
608
+ import type { CapaQuery } from "./capa-graphql";
609
+
610
+ const teasers = {
611
+ articles: { args: { first: 5 }, nodes: { id: true, title: true, author: { name: true } } },
612
+ } as const;
613
+
614
+ // { id: string; title: string | null; author: { name: string | null } | null }
615
+ type Teaser = NodeOf<CapaQuery, typeof teasers, "articles">;
616
+
617
+ function Card({ article }: { article: Teaser }) {
618
+ return <li>{article.title} by {article.author?.name}</li>;
619
+ }
620
+
621
+ const { data } = await capa.graphql.query(teasers);
622
+ const cards = data?.articles?.nodes.map((article) => <Card key={article.id} article={article} />);
623
+ ```
624
+
625
+ It is a node of a list root, the entry of a single root (`article`), or the
626
+ node of an alias (`NodeOf<CapaQuery, typeof home, "latest">`).
627
+ `QueryResult<CapaQuery, typeof teasers>` is the type of `data` as a whole.
628
+
629
+ #### The same data in REST's shape
630
+
631
+ The builder returns GraphQL's shape, because that is what its types describe:
632
+ `articles.nodes[0].author.name`. Code written against `/api/entries` reads
633
+ entries instead: `data[0].fields.author.fields.name`. `toTree` converts one to
634
+ the other:
635
+
636
+ ```ts
637
+ import { toTree } from "@capacms/sdk/next";
638
+ import { capaTreeLayout } from "./capa-graphql"; // written by capa-codegen --graphql
639
+
640
+ const selection = {
641
+ articles: {
642
+ args: { first: 5, sort: ["publishedAt_DESC"] },
643
+ nodes: { id: true, status: true, title: true, author: { id: true, status: true, name: true } },
644
+ },
645
+ } as const;
646
+ const { data } = await capa.graphql.query(selection);
647
+ const { articles } = toTree(data, selection, capaTreeLayout);
648
+ articles?.map((entry) => entry.fields.title); // each a string | null, as entries.list types it
649
+ // deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-publishedAt"], limit: 5 })).data
650
+ ```
651
+
652
+ `capaTreeLayout` is what `toTree` reads of your schema (each model's root
653
+ fields, and which fields are renamed, relations, ids or media), written by
654
+ codegen beside the types, so the conversion reads nothing from the API.
655
+ Without codegen, pass the key's schema instead. That is one introspection
656
+ request, so read it once and reuse it:
657
+
658
+ ```ts
659
+ import { toTree } from "@capacms/sdk/next";
660
+
661
+ const schema = await capa.graphqlSchema(); // one request: read it once, reuse it
662
+ const selection = { articles: { args: { first: 5 }, nodes: { id: true, status: true, title: true } } } as const;
663
+ const { data } = await capa.graphql.query(selection);
664
+ const { articles } = toTree(data, selection, schema); // the same tree as with capaTreeLayout
665
+ ```
666
+
667
+ A selection kept in a variable is declared `as const`, so each `true` and
668
+ each `sort` value keeps its exact type and the result is typed exactly.
669
+
670
+ Each model root field becomes its REST `data`, typed from the selection: a
671
+ list root as an array of entries, a single root as one entry or `null`, and
672
+ `null` for a root that failed (its error is in `errors`). System fields sit beside `fields`
673
+ (`_version` as `version`), every other field sits under `fields` by its
674
+ namespace (`hero_image` as `hero-image`), every entry carries its `model`
675
+ from the schema, whether you selected it or not, a relation list becomes
676
+ `{ items, pageInfo }` (`{ items }` when you did not select its `pageInfo`), a
677
+ relation into a model the key cannot read is
678
+ `{ id, model }` as REST writes it unexpanded (a list of them is
679
+ `{ items, pageInfo }`), and a media value keeps REST's public shape and key
680
+ order, `{ id, url, alt, type, width, height }`, as far as you selected it (a
681
+ list of media is an array of those).
682
+
683
+ The result is deep-equal to the REST response for the same read when the
684
+ selection names the rest of what REST always returns: `id` and `status` on
685
+ every entry, `pageInfo { hasNextPage endCursor }` on each relation list, and
686
+ all six media fields. Its type is then assignable to the entry
687
+ `entries.list<Articles, typeof select>` returns for the same read, so a
688
+ function written for the REST read takes it unchanged. With a typed client,
689
+ a selection without `id` and `status` does not compile. Three things differ by design, because GraphQL
690
+ does not carry what REST says there:
691
+
692
+ - A missing single relation (deleted, or a draft the key cannot see) is `null`
693
+ in GraphQL and `{ id, model, missing: true }` in REST.
694
+ - A missing item of a relation list is left out in GraphQL, so `items` is
695
+ shorter. REST keeps its slot as `{ id, model, missing: true }`.
696
+ - A value that does not fit its field's type is `null` in GraphQL and the raw
697
+ value in REST.
698
+
699
+ Page a list with the GraphQL result's own `pageInfo`; `version`, `me`
700
+ and `entry` are not model roots, and `toTree` refuses them. A root alias
701
+ (`latest: { __aliasFor: "articles", ... }`) becomes the REST data of the field
702
+ it names, under its own key. An alias below a root has no REST equivalent,
703
+ since REST reads each field once, so `toTree` refuses it too.
704
+
705
+ ### Typed documents
706
+
707
+ ```json
708
+ {
709
+ "scripts": {
710
+ "dev": "capa-codegen --graphql --watch --out src/capa-graphql.ts & next dev",
711
+ "prebuild": "capa-codegen --graphql --out src/capa-graphql.ts"
712
+ }
713
+ }
714
+ ```
715
+
716
+ `capa-codegen` reads `CAPA_API_URL` and `CAPA_KEY` from your shell, else from
717
+ `.env.local` and `.env` as `next dev` reads them, so a Next.js site needs no
718
+ extra setup. `--watch` keeps running and writes the module again whenever a
719
+ document changes, and when the key's schema does (it reads the schema again
720
+ every minute). A document with a problem is printed with its file and line,
721
+ and the last good module stays in place.
722
+
723
+ Write a document where you use it, marked in one of three ways, and codegen
724
+ types it by its text:
725
+
726
+ ```ts
727
+ import { createClient, gql } from "@capacms/sdk/next";
728
+
729
+ const LATEST = `#graphql
730
+ query Latest($first: Int) { articles(first: $first) { nodes { id title } } }
731
+ `;
732
+ const ONE = /* capa */ `query One($id: ID!) { article(id: $id) { title views } }`;
733
+ const VERSION = gql(`query Version { version }`);
734
+
735
+ const { data } = await capa.graphql(LATEST, { first: 10 });
736
+ data?.articles?.nodes[0].title; // string | null
737
+ await capa.graphql(LATEST, { first: "10" }); // compile error
738
+ await capa.graphql(ONE); // compile error: id is required
739
+ ```
740
+
741
+ The generated file adds each literal's text to `CapaDocuments` with
742
+ `declare module "@capacms/sdk/next"`, and `client.graphql` and `graphql()`
743
+ from `/nextjs` look the text up, so nothing is imported from it. Keep the
744
+ generated file inside your tsconfig's `include`. A literal is sent exactly as
745
+ written, so it holds one operation and every fragment it spreads, written
746
+ inside it or spread in with `${}` (see Fragments); codegen says so, with the
747
+ file and line, when it does not. A literal of fragments only is a fragment
748
+ source for others.
749
+
750
+ Codegen also reads every `.graphql` file and every gql`...` tagged template
751
+ under `src` (or `--documents dir1,dir2`). A tagged template is a plain string
752
+ to TypeScript, so for those, and for `.graphql` files, import the
753
+ `<Name>Document` codegen writes for every named operation:
754
+
755
+ ```ts
756
+ // src/queries/articles.graphql
757
+ // query ArticlesPage($first: Int) { articles(first: $first) { nodes { id title } } }
758
+
759
+ import { ArticlesPageDocument } from "./capa-graphql";
760
+
761
+ const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 });
762
+ data?.articles?.nodes[0].title; // string | null
763
+ await capa.graphql(ArticlesPageDocument, { first: "10" }); // compile error
764
+ ```
765
+
766
+ #### Fragments
767
+
768
+ ```ts
769
+ import type { ArticleTeaserFragment } from "./capa-graphql";
770
+
771
+ const ARTICLE_TEASER = `#graphql
772
+ fragment ArticleTeaser on Articles { id title }
773
+ `;
774
+
775
+ const TEASERS = `#graphql
776
+ query Teasers($first: Int) { articles(first: $first) { nodes { ...ArticleTeaser } } }
777
+ ${ARTICLE_TEASER}
778
+ ` as const;
779
+
780
+ function teaser(props: ArticleTeaserFragment) {
781
+ return props.title;
782
+ }
783
+
784
+ const { data } = await capa.graphql(TEASERS, { first: 3 });
785
+ data?.articles?.nodes.map(teaser);
786
+ ```
787
+
788
+ Write a fragment in a `#graphql` literal of its own and spread it into a
789
+ query with `${NAME}`, as Hydrogen does. Codegen reads the query with the
790
+ fragment's text in place, which is exactly the text the program sends, so the
791
+ query is typed and persisted like any other literal. End the query's template
792
+ with `as const`: TypeScript keeps the text of a template with `${}` only
793
+ then, and codegen says so when it is missing. A `${}` that names anything
794
+ else is text only known at run time, which codegen skips and names.
795
+
796
+ Codegen writes a `<Name>Fragment` type for every fragment, from a literal or
797
+ a `.graphql` file, for a component that takes the fragment's data as props:
798
+ the nodes of any query that spreads the fragment fit it.
799
+
800
+ The generated file also holds the schema's types, `CapaQuery` for the typed
801
+ builder, `capaTreeLayout` for `toTree`, and `<Name>Models` beside each
802
+ operation: the models it reads, for its cache tags (see In a Next.js server
803
+ component). That is each model whose entries it selects, and each model a
804
+ filter or sort reaches through a relation. A filter or sort passed as a
805
+ variable counts every model its type can reach, and `entry(id:)` counts every
806
+ model, since either can read any of them.
807
+
808
+ Codegen names, with its file and line, every template it does not read that
809
+ looks like a query, and says what to change: one left unmarked, graphql`...`
810
+ (from `/nextjs` that is a request, not a tag), and one with a `${}` that names
811
+ no literal of the project, whose text is only known at run time.
812
+
813
+ A field the key cannot read is a codegen error with its file and line, so a
814
+ model change fails CI instead of production. A field, argument, filter
815
+ operator or sort value a later `Capa-Version` phases out is `@deprecated` in
816
+ the generated types, so your editor strikes it through, and codegen prints
817
+ each use with its file, line and the reason, while still writing the module.
818
+ `--check` exits 2 when the committed file is stale. `--save-schema
819
+ capa-schema.json` writes the schema it read, and `--schema capa-schema.json`
820
+ reads it back instead of calling the API, for CI without a key. Codegen needs
821
+ `graphql` installed in your project (`pnpm add -D graphql`); nothing else in
822
+ the SDK does.
823
+
824
+ A literal codegen has not read yet, new or edited since the last run, does
825
+ not compile: the error says `run capa-codegen --graphql`. Text built at run
826
+ time is a plain `string` and stays untyped, and so does a call that names its
827
+ data type, `capa.graphql<{ version: string }>(text)`.
828
+
829
+ ### Persisted queries
830
+
831
+ Register your documents at build time, with a development key:
832
+
833
+ ```sh
834
+ CAPA_API_URL=https://api.capacms.com CAPA_DRAFT_KEY=cap_test_... capa persist --manifest persisted.json
835
+ ```
836
+
837
+ Then send only their hash, with any key, including the production key your
838
+ site ships:
839
+
840
+ ```ts
841
+ import { ArticlesPageDocument } from "./capa-graphql";
842
+
843
+ const { data } = await capa.graphql(ArticlesPageDocument, { first: 10 }, { persisted: true });
844
+ ```
845
+
846
+ With `persisted: true` the client sends the document's sha256 as one small
847
+ GET, which the CDN and the API cache for a production key, and the document
848
+ never travels. Variables too long for a GET URL (8,192 bytes) go with the hash
849
+ in a POST instead, which works the same way but is not cached. `capa persist` prints `<sha256> <operation> stored, pinned` per
850
+ operation, hashing the same text `capa-codegen --graphql` exports, so run
851
+ both from the same documents. A document written as a literal is stored
852
+ twice, as its `<Name>Document` text and as written (`<Name> (literal)`),
853
+ since a call with the literal sends that text.
854
+
855
+ `capa persist` registers every document with `Capa-Persist: pin`. The API
856
+ drops unpinned documents first when a project's store is full, so the
857
+ documents developers register while trying queries in the Explorer or a
858
+ draft preview never push your site's documents out. A document the API
859
+ stores without its pin is reported as `stored, not pinned` and the command
860
+ exits 3, since it is exposed to exactly that. `--manifest` writes each
861
+ operation's `sha256`, `stored` and `pinned`.
862
+
863
+ Name the build too, so a run of preview deploys never pushes production's
864
+ documents out:
865
+
866
+ ```sh
867
+ capa persist --release "$GIT_COMMIT" --env production
868
+ ```
869
+
870
+ That sends `Capa-Persist: pin; release=<commit>; env=production`. A project
871
+ keeps up to 2,000 documents and 16 MiB, and when it is full the API keeps
872
+ the pins of the latest 3 production releases first, then what a production
873
+ key ran in the last 30 days, then other pins, such as a preview build's.
874
+ On Vercel and Netlify the command reads both from the build
875
+ (`VERCEL_GIT_COMMIT_SHA` and `VERCEL_ENV`, or `COMMIT_REF` and `CONTEXT`),
876
+ so there is nothing to pass. Elsewhere, pass the flags or set `CAPA_RELEASE`
877
+ and `CAPA_RELEASE_ENV`. A release is 1 to 64 letters, digits, dots, dashes
878
+ or underscores, such as a commit or a deploy id, and an environment is a
879
+ lowercase name such as `production` or `preview`. The two go together, and
880
+ the command checks them before it sends anything. The manifest records them
881
+ as `release` and `env`.
882
+
883
+ Two things decide whether a document is stored:
884
+
885
+ - The key. `capa persist` reads the development key from `CAPA_DRAFT_KEY`,
886
+ the name `/nextjs` reads for drafts (`CAPA_KEY` when that is unset), and
887
+ refuses a production key before it sends anything. A production key ships
888
+ in your site's bundle, so it may run a document but never register one.
889
+ - The host. `capa persist` sends each document to `CAPA_API_URL`, the URL
890
+ your site already reads. `https://api.capacms.com` passes every POST on to
891
+ the host that stores documents. On a self-hosted stack whose read host
892
+ stores nothing, set `CAPA_ADMIN_URL` to the API host your Capa admin uses;
893
+ it wins over `CAPA_API_URL`. When a host stores nothing, the command says
894
+ so and names the variable to set.
895
+
896
+ When a hash is not stored yet, a production client still gets its data, with
897
+ no error: the API answers the GET with `PersistedQueryNotFound`, the client
898
+ sends one POST carrying the document and the hash, and the API runs it and
899
+ answers `extensions.persistedQuery.registered: false`. The client then
900
+ remembers that hash for five minutes and sends POST straight away, so a
901
+ missing registration costs one extra GET per five minutes rather than one per
902
+ call. `registered: false` on a production read is how to spot a document
903
+ `capa persist` did not register. `graphql()` from `/nextjs` is not persisted
904
+ by default for the same reason: until `capa persist` has run, every hash
905
+ misses.
906
+
907
+ A builder read (`capa.graphql.query`) cannot be persisted, and
908
+ `persisted: true` there throws a `TypeError` before any request. Its
909
+ document is printed when it runs, so `capa persist` never sees it, and a
910
+ production key never stores one. It is already a cacheable GET.
911
+
912
+ ### A REST select as a builder read, and back
913
+
914
+ `selectToSelection` turns a REST read into a typed builder selection, which
915
+ `client.graphql.query` runs and `toTree` turns into that read's `data`.
916
+ `graphqlToSelect` gives a builder selection's REST request, as it gives a
917
+ tool spec's:
918
+
919
+ ```ts
920
+ import { graphqlToSelect, selectToSelection, toTree } from "@capacms/sdk/next";
921
+
922
+ const schema = await capa.graphqlSchema();
923
+ const selection = selectToSelection(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
924
+ const { data } = await capa.graphql.query(selection);
925
+ const { articles } = toTree(data, selection, schema);
926
+ // deep-equal to (await capa.entries.list("articles", { select: "title,author(name)", sort: ["-views"], limit: 5 })).data
927
+
928
+ graphqlToSelect(schema, { articles: { args: { first: 5 }, nodes: { title: true, author: { name: true } } } }).url;
929
+ // "/api/entries/articles?select=title,author(name)&limit=5"
930
+ ```
931
+
932
+ The selection names what `toTree` needs to answer what REST answers: `id`,
933
+ `model` and `status` on every entry, `pageInfo` on every relation list, and
934
+ all six media fields. Its fields are known only when it runs, so its data and
935
+ its tree are untyped, with a typed client too. It takes what `selectToGraphQL`
936
+ takes and refuses what it refuses. A relation the select names bare, which
937
+ REST returns unexpanded as `{ id, model }`, is read expanded to its id, the
938
+ REST read `author(id)`, because GraphQL reads a relation to a model the key
939
+ can read as an entry. `graphqlToSelect` reads a selection's one model root
940
+ field, and throws `CapaBuildError` for any other root (`version`, `entry`),
941
+ and for a relation list read from a cursor in a list read, which have no REST
942
+ request here. `last` with no `before`, or with `before: null` as Relay and
943
+ Apollo send it, reads the end of the list: REST's `before=end`.
944
+
945
+ ### The tool spec: build a query for an explorer or an assistant
946
+
947
+ Tools that build queries (the admin's Explorer, the MCP server) share one
948
+ plain format, the tool spec: `{ model, fields, first, sort, filter }`. The SDK
949
+ reads the key's schema once and writes GraphQL from it in Capa's canonical
950
+ format, and moves between it and a REST read:
951
+
952
+ ```ts
953
+ import { buildGraphQLQuery, graphqlToSelect, selectToGraphQL } from "@capacms/sdk/next";
954
+
955
+ const schema = await capa.graphqlSchema(); // models, fields, filters, sorts, from one request
956
+
957
+ const spec = { model: "articles", fields: ["title", { field: "author", fields: ["name"] }], sort: ["views_DESC"], first: 5 };
958
+ const { query, variables } = buildGraphQLQuery(schema, spec);
959
+ graphqlToSelect(schema, spec).url;
960
+ // "/api/entries/articles?select=title,author(name)&sort=-views&limit=5"
961
+
962
+ selectToGraphQL(schema, "articles", "title,author(name)", { sort: ["-views"], limit: 5 });
963
+ // the same tool spec, from a REST read
964
+ ```
965
+
966
+ The tool spec is not the typed builder's selection: `client.graphql.query`
967
+ and `toTree` take the selection, which `selectToSelection` writes.
968
+
969
+ `graphqlToSelect` writes the REST twin exactly as the API writes it in
970
+ `extensions.capa.rest`: the filter as `where` JSON, the root's default page
971
+ size left out, a relation list's `first` written as `limit:` whenever you give
972
+ it (100 included, since REST counts a limit you write in full and one you
973
+ leave out as 10), and a system key a field of the model shadows written with `$`
974
+ (`_tags` beside a field called `tags` is `select=$tags,tags`; `createdAt_DESC`
975
+ beside a field called `createdAt` is `sort=-$createdAt`). That includes a
976
+ field GraphQL leaves out because its name collides with another's, which the
977
+ type's description lists as `Not exposed:` and the schema summary as
978
+ `notExposed`: beside a hidden field called `id`, `id_DESC` is `sort=-$id`.
979
+ `selectToGraphQL` reads `$` names back, and refuses a REST name for a hidden
980
+ field, which GraphQL cannot read. It selects what the REST read returns: a
981
+ media value with all six of its fields (`id url alt type width height`), and
982
+ `*`, or no select, as everything: the system keys `createdAt`, `updatedAt`,
983
+ `publishedAt`, `version`, `folder` and `tags`, every field, and each relation
984
+ list as a connection of ids (`coauthors { nodes { id } }`), the page of
985
+ references REST returns. A reference to an entry the key cannot see is the
986
+ one difference left: REST shows it as `{ id, model, missing: true }`, and
987
+ GraphQL reads it as `null`, or leaves it out of a list. `buildGraphQLQuery`
988
+ with no `fields` keeps its own shorter default, media as `id url alt`. Both helpers send each filter value as the type its
989
+ filter input declares, since GraphQL refuses any other: `"10"` for a number
990
+ field becomes `10`, and `has: "true"` on a list of true/false values becomes
991
+ `true`, which its `BooleanListFilter` takes. A name the schema does not have,
992
+ or a key a relation's spec does not take (`frist` for `first`, at any depth),
993
+ throws `CapaBuildError` with `didYouMean`, before anything is sent. A `where`
994
+ on `$tags` ports to GraphQL's `_tags` filter:
995
+
996
+ ```ts
997
+ import { selectToGraphQL } from "@capacms/sdk/next";
998
+
999
+ const schema = await capa.graphqlSchema();
1000
+ selectToGraphQL(schema, "articles", "title", { where: { $tags: { has: "news" } } });
1001
+ // { model: "articles", fields: ["title"], filter: { _tags: { has: "news" } } }
1002
+ ```
1003
+
1004
+ A model GraphQL leaves out, because its type name would collide with
1005
+ another's (`twin_a` and `twin-a`), is listed in the summary's `restOnly`.
1006
+ Asked for one, the builder throws `CapaBuildError` naming its REST read
1007
+ (`twin_a is readable over REST only: GET /api/entries/twin_a.`) and why,
1008
+ with no `didYouMean`: `entries.list("twin_a")` reads it.
1009
+
1010
+ Two REST reads have no GraphQL twin and throw too: a `where` on `$version`,
1011
+ `$folder`, `$model` or `$status` (GraphQL filters `id`, `createdAt`,
1012
+ `updatedAt`, `publishedAt` and `_tags`), and a filter hop through a relation
1013
+ the key cannot read (REST answers it `unknown_field`).
1014
+
1015
+ A tool spec pages as a REST read does: `first` with `after` reads the page after a
1016
+ cursor, and `first` with `before` the entries just before one. GraphQL writes
1017
+ the second as `last` with `before`, and the API refuses `first` there, so
1018
+ `{ first: 25, before }` prints `articles(last: $last, before: $before)`, and
1019
+ a tool spec with both `after` and `before` throws, as the API refuses it.
1020
+ `before: "end"` reads the last entries of the list, REST's `before=end`:
1021
+ `{ first: 5, before: "end" }` prints `articles(last: $last)`, and the list's
1022
+ `startCursor` pages back from there. `after: "end"` throws, since `end` is not
1023
+ a cursor.
1024
+ A read of one entry also pages a relation list from its cursor, as REST's
1025
+ `coauthors(name,after:…)` does: `{ field: "coauthors", fields: ["name"],
1026
+ after }` in a spec with `mode: "single"`, or `args: { after }` on the list in
1027
+ a selection. A list read throws for it, as the API refuses it there
1028
+ (`after: applies to a single entry`). `sort` takes one value or a list, at
1029
+ the root as on a relation list.
1030
+
1031
+ A spec written as REST writes a read throws, and says what to write instead:
1032
+ `"author.name"` or `"author(name)"` in `fields` is
1033
+ `{ field: "author", fields: ["name"] }`, `"*"` is `fields` left out, and a
1034
+ sort `"-publishedAt"` is `"publishedAt_DESC"` (in `didYouMean` too).
1035
+ Relations nest up to 4 deep below the root entry, which with the root is the
1036
+ API's 5 levels of entries; a fifth throws `CapaBuildError` and names the field
1037
+ to select without fields, for its id.
1038
+
150
1039
  ## Typed select and codegen
151
1040
 
152
1041
  `Select<T>` is exported from `@capacms/sdk/next`. `capa-codegen` keeps the legacy
153
1042
  `/v2/schema/types` shape for schemas without relations. When a schema has
154
- relations, codegen wraps them in branded helpers:
1043
+ relations, codegen wraps them in branded helpers, which describe the model;
1044
+ a read's `fields` is typed from them as the API returns it (see Typed reads):
155
1045
 
156
1046
  ```ts
157
1047
  export type CapaRelation<T> = T & { readonly __capaRelation: "one"; readonly __capaRelationTarget: T };
@@ -167,7 +1057,31 @@ export type ArticleSelect = import("@capacms/sdk/next").Select<Article>;
167
1057
  ```
168
1058
 
169
1059
  Those brands let TypeScript tell scalar fields from relation fields, so misspelled
170
- fields and invalid nested selects fail in consumer typechecks.
1060
+ fields and invalid nested selects fail in consumer typechecks. A relation named
1061
+ alone (`"author"`) is read as a reference.
1062
+
1063
+ Every name is written so the module parses, whatever the namespace holds. A
1064
+ field that is not an identifier is quoted (`"am/pm_indicator"?: string;`,
1065
+ read as `fields["am/pm_indicator"]`), and a model whose name is not one is
1066
+ named as GraphQL names it: `2024_events` is `_2024Events`, with
1067
+ `_2024EventsSelect` beside it. Two models whose names give one interface name,
1068
+ such as `twin_a` and `twin-a`, are each named from the whole namespace instead:
1069
+ `Model_twin_a` and `Model_twin$2da`, each character that is not a letter, digit
1070
+ or `_` written as `$` and its hex code. An enum's values are written as stored,
1071
+ a quote or a backslash included.
1072
+
1073
+ A select names a field by its namespace as saved, whatever it holds:
1074
+ `["price.usd", { "at.place": ["zip.code"] }]`. The client writes a name holding
1075
+ `,` `(` `)` `:` `.` `"` `*` `[` `]` or a space, or starting with `-`, quoted, as
1076
+ REST's grammar reads it: `select="price.usd","at.place"("zip.code")`. A string
1077
+ select, `sort` and `where` take REST's own text, so write such a name quoted
1078
+ there yourself: `sort: ['-"price.usd"']`.
1079
+
1080
+ A select also takes the entry's system keys by their `$` names, which mean the
1081
+ system key even on a model with a field of the same plain name
1082
+ (`SystemKey`): `["title", "$tags", { coauthors: { select: ["name"], sort: "-$createdAt" } }]`.
1083
+ A `$` name that is no system key fails to compile. A field whose namespace
1084
+ starts with `$` cannot be named, so `select: "*"` is how to read it.
171
1085
 
172
1086
  ## Pages and preview
173
1087
 
@@ -192,6 +1106,15 @@ const capa = createClient({ baseUrl, apiKey, version: "2026-10-01" });
192
1106
  const posts = await capa.entries.list("articles", { page: routeOf(import.meta.url) });
193
1107
  ```
194
1108
 
1109
+ Send `path` beside it, the concrete path being rendered, and Capa can list the
1110
+ real URLs an entry appears on, not only the route patterns:
1111
+
1112
+ ```ts
1113
+ await capa.entries.list("articles", { page: "/blog/[slug]", path: `/blog/${slug}` });
1114
+ ```
1115
+
1116
+ `path` goes out as `Capa-Path`, only when `page` is also set.
1117
+
195
1118
  `routeOf` turns a Next route file into the page string: `/blog/[slug]`. It
196
1119
  drops route groups `(marketing)`, parallel slots `@modal`, the leaf file name
197
1120
  and the extension. Write the string out by hand if you prefer; `routeOf` exists
@@ -265,7 +1188,7 @@ exactly like a site that is up to date.
265
1188
 
266
1189
  `capa.pages.get(page)` carries an `insights` array: suggestions drawn from that
267
1190
  page's own reads, computed by Capa so that this SDK, the Capa admin and
268
- `@capa/mcp` all read one answer.
1191
+ Capa's MCP server all read one answer.
269
1192
 
270
1193
  ```ts
271
1194
  const detail = await capa.pages.get("/blog/[slug]");
@@ -341,41 +1264,123 @@ editor sees the path without a link to open it.
341
1264
 
342
1265
  ## Live preview
343
1266
 
1267
+ ### Quick start (Next.js)
1268
+
1269
+ Set `CAPA_API_URL`, `CAPA_KEY` (`cap_live_`) and `CAPA_DRAFT_KEY` (`cap_test_`).
1270
+ Draft previews take `cap_` keys only (see Keys). Then:
1271
+
1272
+ ```ts
1273
+ // middleware.ts
1274
+ import { NextResponse } from "next/server";
1275
+ import { capaMiddleware } from "@capacms/sdk/nextjs";
1276
+ export const middleware = capaMiddleware({ NextResponse });
1277
+
1278
+ // app/api/capa/preview/route.ts (and exit/route.ts with exitPreviewRoute)
1279
+ import { draftMode } from "next/headers";
1280
+ import { redirect } from "next/navigation";
1281
+ import { createPreviewRoute } from "@capacms/sdk/nextjs";
1282
+ export const GET = createPreviewRoute({ draftMode, redirect });
1283
+
1284
+ // in a page: getCapaClient({ draftMode, headers }), then <h1 {...fieldAttrs(post).title}>
1285
+ // in the root layout, in edit mode: <CapaOverlay adminOrigins={[...]} /> from @capacms/sdk/nextjs/overlay
1286
+ ```
1287
+
1288
+ In Capa, set the project's preview URL to your site, and editors can click
1289
+ your page. The sections below explain each piece.
1290
+
344
1291
  The Capa editor can show your site beside the form: focus a field and its spot
345
1292
  on the page is outlined, click the page and the editor jumps to the field, save
346
- and the draft re-renders in place. It needs three things on your side. A
347
- complete, runnable example is `examples/sdk-demo` in this repository.
1293
+ and the draft re-renders in place. It needs three things on your side.
348
1294
 
349
- ### 1. Tag what an editor can click
1295
+ ### 1. Turn on edit mode and tag what an editor can click
350
1296
 
351
- `capaAttrs(entry, field, enabled)` from `@capacms/sdk/next` returns the two
352
- attributes the overlay looks for. `field` is the field's namespace, which is its
353
- key in `entry.fields`, and it autocompletes when the entry is typed. Pass the
354
- draft flag as `enabled` so a published page ships no entry ids.
1297
+ Edit mode is on when Next draft mode is on, or when the request carries a
1298
+ `capa-edit` token the Capa editor's Published view sends. Work it out once per
1299
+ request and build the client with it:
1300
+
1301
+ ```ts
1302
+ // lib/capa.ts
1303
+ import { draftMode, headers } from "next/headers";
1304
+ import { createClient } from "@capacms/sdk/next";
1305
+ import { editMode } from "@capacms/sdk/nextjs";
1306
+
1307
+ export async function capa() {
1308
+ const draft = (await draftMode()).isEnabled;
1309
+ return createClient({
1310
+ baseUrl, version: "2026-10-01",
1311
+ apiKey: draft ? process.env.CAPA_DRAFT_KEY! : process.env.CAPA_KEY!,
1312
+ editMode: await editMode({ draftMode, headers }),
1313
+ });
1314
+ }
1315
+ ```
1316
+
1317
+ Then tag fields with `capaAttrs(entry, field)` from `@capacms/sdk/next`. `field`
1318
+ is the field's namespace, its key in `entry.fields`, and it autocompletes when
1319
+ the entry is typed. There is no flag to pass: an entry read by an edit-mode
1320
+ client carries a hidden mark (related entries too), and `capaAttrs` tags only
1321
+ marked entries. A visitor's page therefore ships no `data-capa-` attributes.
355
1322
 
356
1323
  ```tsx
357
1324
  import { capaAttrs } from "@capacms/sdk/next";
358
1325
 
359
- <h1 {...capaAttrs(article, "title", isDraft)}>{article.fields.title}</h1>
1326
+ <h1 {...capaAttrs(article, "title")}>{article.fields.title}</h1>
360
1327
  ```
361
1328
 
362
- ### 2. Start the overlay in draft mode
1329
+ The mark does not survive a spread copy or being passed to a client component,
1330
+ so tag in the server component that read the entry. `capaAttrs(entry, field,
1331
+ true)` still forces the tags on and `false` forces them off.
1332
+
1333
+ GraphQL reads are marked the same way, from `getCapaClient`, an edit-mode
1334
+ `createClient` or `graphql()` given `{ draftMode, headers }`. A node is an
1335
+ entry when it selected `id` and `model`, and `field` is the field as you
1336
+ selected it:
363
1337
 
364
1338
  ```tsx
365
- // app/capa-overlay.tsx
366
- "use client";
367
- import { useEffect } from "react";
368
- import { useRouter } from "next/navigation";
369
- import { startOverlay } from "@capacms/sdk/overlay";
370
-
371
- export function CapaOverlay({ adminOrigins }: { adminOrigins: string[] }) {
372
- const router = useRouter();
373
- useEffect(() => startOverlay({ adminOrigins, onRefresh: () => router.refresh() }), [adminOrigins, router]);
374
- return null;
1339
+ const { data } = await capa.graphql.query({ articles: { nodes: { id: true, model: true, title: true } } });
1340
+ <h1 {...capaAttrs(data!.articles!.nodes[0], "title")}>{data!.articles!.nodes[0].title}</h1>
1341
+ ```
1342
+
1343
+ A field GraphQL renamed (`hero_image` for `hero-image`) is tagged by its
1344
+ namespace, which the editor knows it by: in edit mode the client reads the
1345
+ names of the key's types and fields once a minute to know which fields those
1346
+ are. That read starts beside the page's read, so the page waits for the
1347
+ slower of the two, and a document that never says `model` reads no names.
1348
+ With a typed client, tagging a node that did not select `model` does not
1349
+ compile.
1350
+ `toTree` keeps the mark, so its entries tag like REST entries, by namespace.
1351
+
1352
+ To accept `capa-edit`, verify it in middleware. The token is checked with Capa,
1353
+ any forged `x-capa-edit` header is removed, and edit-mode responses are marked
1354
+ `private, no-store`:
1355
+
1356
+ ```ts
1357
+ // middleware.ts
1358
+ import { NextResponse, type NextRequest } from "next/server";
1359
+ import { resolveEditRequest } from "@capacms/sdk/nextjs";
1360
+
1361
+ export async function middleware(request: NextRequest) {
1362
+ const edit = await resolveEditRequest(request, publishedClient());
1363
+ const response = NextResponse.next({ request: { headers: edit.headers } });
1364
+ if (edit.cacheControl) response.headers.set("Cache-Control", edit.cacheControl);
1365
+ return response;
375
1366
  }
376
1367
  ```
377
1368
 
378
- Render it from your root layout only when `(await draftMode()).isEnabled`.
1369
+ ### 2. Start the overlay in edit mode
1370
+
1371
+ ```tsx
1372
+ // app/layout.tsx
1373
+ import { CapaOverlay } from "@capacms/sdk/nextjs/overlay";
1374
+
1375
+ {edit ? <CapaOverlay adminOrigins={["https://app.capacms.com"]} /> : null}
1376
+ ```
1377
+
1378
+ `@capacms/sdk/nextjs/overlay` is a client component and needs `react` and
1379
+ `next`. Without Next, call `startOverlay({ adminOrigins, onRefresh })` from
1380
+ `@capacms/sdk/overlay` in your own effect.
1381
+
1382
+ Render it from your root layout only in edit mode
1383
+ (`await editMode({ draftMode, headers })`), so a visitor never downloads it.
379
1384
  `startOverlay` returns a disposer and is safe to call twice. Outside a frame it
380
1385
  does nothing at all, and inside one it only listens to a parent window at one of
381
1386
  `adminOrigins`. Without `onRefresh` a save reloads the page; the scroll position
@@ -432,32 +1437,35 @@ every navigation, `select { entryId, field }` on a click, and `hover`.
432
1437
  `acceptMessage(event, allowedOrigins, parent)` is the site's filter, exported
433
1438
  for your own tests.
434
1439
 
435
- Since 1.0.0-next.2 the site also sends `visible { entryId, field }` while the
436
- page scrolls (at most every 150ms, and only when it changes): the tagged element
437
- at the centre of the viewport, chosen by `pickCentred`. At the very top of a
438
- page, where a heading can never reach the centre, it is the topmost visible
439
- element instead, and at the very bottom the bottommost. The editor's "Follow the
440
- page" scrolls the form to that field. It is additive and still `v: 1`: an older
441
- overlay never sends it and the editor simply does not follow.
1440
+ The site also sends `visible { entryId, field }` while the page scrolls (at
1441
+ most every 150ms, and only when it changes): the tagged element at the centre
1442
+ of the viewport, chosen by `pickCentred`. At the very top of a page, where a
1443
+ heading can never reach the centre, it is the topmost visible element instead,
1444
+ and at the very bottom the bottommost. The editor's "Follow the page" scrolls
1445
+ the form to that field. An overlay older than 1.0.0-next.2 never sends it, and
1446
+ the editor then does not follow.
442
1447
 
443
1448
  ## Not yet
444
1449
 
445
- `--select-from-depth`, `capa convert-url`, `capa persist`, GraphQL, and `/api/`
1450
+ `--select-from-depth`, `capa convert-url`, GraphQL mutations, and `/api/`
446
1451
  writes are not in this release.
447
1452
 
448
1453
  ## The legacy `/v2/api` client
449
1454
 
450
- A read client for Capa, plus two arrangement writes (`workspaces`,
451
- `models.setLayout`) and `capa-codegen`, a command that pulls a tenant's
452
- generated TypeScript so content is typed at the call site.
1455
+ `@capacms/sdk` is the client for sites that read `/v2/api` today. It takes a
1456
+ legacy key (`pk_`, `sk_` or unprefixed) and the tenant's id, and refuses a
1457
+ `cap_` key where it is built, since `/v2` answers one as an invalid key: a
1458
+ `cap_` key reads `/api/`, with `createClient` from `@capacms/sdk/next`.
1459
+ Besides reads, it schedules publishes, arranges the admin's workspaces and
1460
+ entry layouts, and manages webhook endpoints.
453
1461
 
454
1462
  ```ts
455
1463
  import { createClient } from "@capacms/sdk";
456
1464
  import type { BlogPost } from "./capa-types"; // written by capa-codegen
457
1465
 
458
1466
  const capa = createClient({
459
- baseUrl: process.env.CAPA_BASE_URL!,
460
- apiKey: process.env.CAPA_API_KEY!,
1467
+ baseUrl: process.env.CAPA_API_URL!,
1468
+ apiKey: process.env.CAPA_KEY!, // a pk_ key, not a cap_ key
461
1469
  tenantId: process.env.CAPA_TENANT_ID!,
462
1470
  });
463
1471
 
@@ -465,80 +1473,31 @@ const page = await capa.listContent<BlogPost>("blog_post", { limit: 10 });
465
1473
  const one = await capa.findOne<BlogPost>("blog_post", { handle: "hello" });
466
1474
  ```
467
1475
 
468
- ## Codegen
469
-
470
- ```
471
- CAPA_BASE_URL=... CAPA_API_KEY=... CAPA_TENANT_ID=... capa-codegen --out src/capa-types.ts
472
- ```
473
-
474
- Exit codes: `0` wrote or already current, `1` failed, `2` `--check` found a diff.
475
- `--check` is the CI mode — it fails when the committed file is stale.
476
-
477
- **Commit the output.** Not generated at install (needs credentials during
478
- `npm install`, which breaks CI images and Docker builds) and not at build time
479
- (makes every build network-dependent, and a Capa outage a build outage). A
480
- committed file is also what makes the feature useful: "a model changed, so the
481
- build fails" only helps if a human can see *what* changed, and a committed file
482
- turns that into a reviewable diff instead of a wall of `tsc` errors.
483
-
484
- Re-running when nothing changed costs **one conditional request returning 0
485
- bytes** — see below.
486
-
487
- ## Three server-side rough edges, handled here
1476
+ ### An entry's id: read `Page.ids`
488
1477
 
489
- The API is mid-port to a byte-parity bar, so changing a customer-facing response
490
- costs a signed exception. None of these is worth one, because each can be
491
- handled on this side.
492
-
493
- **1. `/v2/schema/types` has no ETag,** so it cannot be revalidated. `/v2/schema`
494
- *does* — it returns a `checksum` and honours `If-None-Match` with a 304, and that
495
- checksum is `sha256({models, relations})`, exactly what the types are generated
496
- from. So codegen gates on it and fetches types only when the schema moved.
497
-
498
- **2. The generator orders models by display name,** while interfaces are *named*
499
- from the namespace (`blogs_home_section` → `BlogsHomeSection`). Renaming a
500
- model's display name therefore reorders the whole file without changing a single
501
- type — a false "your types changed" in the exact mechanism this feature rests
502
- on. `normalizeTypes` re-sorts by interface name, so the committed file is stable
503
- under renames.
504
-
505
- **3. A model field named `id` overwrites the instance id** (#4628). Rows are
506
- built as `{...instance, ...formatInstanceData(instance)}` (`routes/v2/api.ts`).
507
- Hoisted fields are spread second, so `id`, `title` and `tags` are overwritten.
508
- In one ordinary tenant, 20 of 20 models shadow `title` and 7 of 20 shadow `id`.
509
-
510
- A server-side fix for this shipped briefly as #4628 (`instanceId` appended to
511
- every public row) and was reverted on 2026-09-20 under the public-surface
512
- freeze: `/v2/api`, `/v3/api`, the `/v1` public reads and `/files` answer the
513
- bytes the legacy API answers, and a row key that resolves the shadowing is
514
- planned for the next API version. **So a row from today's server carries no
515
- `instanceId`, and `row.id` may be a content value.**
516
-
517
- The client already handles both: `instanceIdOf` prefers `instanceId` when a row
518
- has one and falls back to `id` when that is a UUID, so nothing here changes when
519
- the next version starts sending it.
520
-
521
- So **read `Page.ids`, not `row.id`**:
1478
+ A `/v2/api` row puts the model's own fields at its top level, so a model
1479
+ with a field named `id` replaces the entry's id in the row, and the same goes
1480
+ for `title` and `tags`. Many models have one: every Shopify model names a
1481
+ field `id`. So read `Page.ids`, not `row.id`:
522
1482
 
523
1483
  ```ts
524
1484
  const page = await capa.listContent("shopify_page");
525
- page.ids[0] // the instance UUID when the row has one to give
1485
+ page.ids[0] // the entry's UUID when the row has one to give
526
1486
  page.data[0].id // the model's OWN id field whenever one exists
527
1487
  ```
528
1488
 
529
- `Page.ids` is `string | null` per row: `null` means the model shadows `id` and
530
- the server sent nothing else to read it from, which is the state of every server
531
- today. `getContentById` throws on a non-UUID rather than spending a request on a
532
- guaranteed 400.
533
-
534
- `search()` is the same story. `/v2/api/search?extended=true` hoists fields the
535
- same way, so `SearchHit.id` is read through the same `instanceIdOf` and has the
536
- same `string | null` type.
1489
+ `Page.ids` is `string | null` per row, in the order of `data`: `null` means
1490
+ the model shadows `id` and the row carries nothing else to read the entry's
1491
+ id from. `instanceIdOf(row)` reads one row the same way, and prefers an
1492
+ `instanceId` when a row carries one. `getContentById` throws for an id that is
1493
+ not a UUID rather than spending a request on a certain 400. `search()` reads
1494
+ its hits the same way, so `SearchHit.id` is `string | null` too. `/api/` has
1495
+ no such collision: an entry's system keys sit beside its `fields`.
537
1496
 
538
- ## Preview is a key, not a flag
1497
+ ### Preview is a key, not a flag
539
1498
 
540
- Capa gates unpublished content on the API key's `environment` column, not on
541
- anything in the request (`routes/v2/api.ts:203`). There is no `?preview=true`, so
1499
+ Capa shows unpublished content to a key whose environment is not
1500
+ `production`, whatever the request says. There is no `?preview=true`, so
542
1501
  preview is a second client:
543
1502
 
544
1503
  ```ts
@@ -546,67 +1505,89 @@ const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY });
546
1505
  const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY });
547
1506
  ```
548
1507
 
549
- The comparison is exact and case-sensitive against a free-text column, so **any**
550
- environment that is not literally `production` returns drafts — including a typo
551
- like `Production`. And a client cannot find out which it holds: `apiKeyEnvironment`
552
- is consumed internally and returned by no endpoint, so a site handed a `draft`
553
- key serves unpublished content publicly with no way to detect it.
1508
+ The comparison is exact and case-sensitive, so **any** environment that is
1509
+ not literally `production` returns drafts, a typo like `Production`
1510
+ included. And a `/v2` client cannot find out which it holds: no `/v2`
1511
+ endpoint reports a key's environment, so a site handed a `draft` key serves
1512
+ unpublished content publicly with no way to detect it. `/api/` reports it:
1513
+ `capa.me()` on `@capacms/sdk/next` answers the key's `environment`.
554
1514
 
555
- ## Not included
1515
+ ### Codegen
556
1516
 
557
- **Caching.** Every framework does it differently and owning it means
558
- reimplementing all of them badly. Plain `fetch` returning plain data lets Next's
559
- fetch cache and React Router loaders work natively. What the host cannot know is
560
- which surrogate keys a response carries, so those are surfaced as
561
- `Page.cacheTags` for CDN purging.
562
-
563
- **`getBySlug`.** Capa has no slug convention — the field is `handle` on
564
- `shopify_page`, `product_handle` on `judge_me_review`, `bloghandle` on
565
- `shopify_article`, and nothing marks one as *the* slug. `findOne(ns, {handle})`
566
- is explicit instead. A slug marker belongs on the model, in Capa.
567
-
568
- **Content writes.** `/v2/agent/model-instances` exists and an API key reaches
569
- it, so an SDK that can silently publish a draft is now technically possible.
570
- That wants a deliberate surface of its own rather than another method quietly
571
- added to the client, so it is absent rather than half-built. The one exception
572
- is `scheduledActions`, below, and the exception is narrow on purpose: a schedule
573
- is a request that leaves a row you can read, show and cancel. Publishing *now*
574
- leaves the caller holding the only copy of what happened.
1517
+ ```sh
1518
+ CAPA_API_URL=... CAPA_KEY=pk_... CAPA_TENANT_ID=... capa-codegen --out src/capa-types.ts
1519
+ ```
575
1520
 
576
- This section used to say the SDK could not write at all, because `verifyApiKey`
577
- guarded five route files carrying 15 GETs and no write verb. That stopped being
578
- true with the agent surface (`#383`) and `tenant_api_keys.permission` becoming a
579
- real control (`#4646`). Two things write today, and both touch arrangement
580
- rather than content:
1521
+ Without `--graphql`, `capa-codegen` reads the legacy `/v2` schema, which
1522
+ takes a `pk_` key and `CAPA_TENANT_ID`. A `cap_` key, which `/v2` refuses,
1523
+ writes the same interfaces from the key's GraphQL schema, with no tenant id,
1524
+ and so does `--schema <file>`, a schema `capa-codegen --graphql --save-schema`
1525
+ wrote. GraphQL does not say three things, so those types say less: every
1526
+ field is optional, an enum is a `string`, and a field GraphQL leaves out is
1527
+ `unknown`. There is no `CAPA_SCHEMA_CHECKSUM`, since that is the `/v2`
1528
+ schema's checksum. Where the API does not serve GraphQL, use a `pk_` key. It
1529
+ reads `.env.local` and `.env` too, as `next dev` does.
1530
+
1531
+ Exit codes: `0` wrote or already current, `1` failed, `2` `--check` found a
1532
+ diff. `--check` is the CI mode: it fails when the committed file is stale.
1533
+
1534
+ **Commit the output.** Generating at install needs credentials during
1535
+ `npm install`, which breaks CI images and Docker builds, and generating at
1536
+ build time makes every build depend on the network. A committed file also
1537
+ turns "a model changed, so the build fails" into a reviewable diff instead of
1538
+ a wall of `tsc` errors.
1539
+
1540
+ Re-running when nothing changed costs one conditional request that returns
1541
+ no body: codegen stamps the schema's checksum in the file and fetches the
1542
+ types only when the schema moved. Models are written in the order of their
1543
+ interface names, so renaming a model's display name does not reorder the
1544
+ file.
1545
+
1546
+ ### What it leaves out
1547
+
1548
+ **Caching.** Every framework caches differently, so the client returns plain
1549
+ data from plain `fetch`, and Next's fetch cache and React Router loaders work
1550
+ with it as they are. What your host cannot know is which surrogate keys a
1551
+ response carries, so `Page.cacheTags` holds them for purging a CDN.
1552
+
1553
+ **`getBySlug`.** Capa has no slug convention: the field is `handle` on
1554
+ `shopify_page`, `product_handle` on `judge_me_review` and `bloghandle` on
1555
+ `shopify_article`, and nothing marks one as the slug. `findOne(ns, { handle })`
1556
+ says which.
1557
+
1558
+ **Content writes.** The client writes no entry content. It schedules
1559
+ publishes (`scheduledActions`, below), a request that leaves a row you can
1560
+ read, show and cancel, and it writes arrangement, which touches no entry:
581
1561
 
582
1562
  ```ts
583
- await capa.workspaces.apply(id, { tree }); // the admin rail (0h, 0t)
584
- await capa.models.setLayout(id, layout); // the entry editor (0m)
1563
+ await capa.workspaces.apply(id, { tree }); // the admin's left rail
1564
+ await capa.models.setLayout(id, layout); // the entry editor
585
1565
  await capa.models.setLayout(id, null); // back to the linear editor
586
1566
  ```
587
1567
 
588
- Reading has two shapes. `models.getLayout(id)` is the document or null;
589
- `models.getLayoutInfo(id)` is the same read with the model's `embedByDefault`
590
- beside it, which is what decides a relation field with no `display` on its
591
- layout node (section 0m.8, and it applies in linear mode too):
1568
+ ### Workspaces and entry layouts
1569
+
1570
+ Reading a layout has two shapes. `models.getLayout(id)` is the document or
1571
+ null; `models.getLayoutInfo(id)` is the same read with the model's
1572
+ `embedByDefault` beside it, which decides how a relation field with no
1573
+ `display` renders, in linear mode too:
592
1574
 
593
1575
  ```ts
594
1576
  const { layout, embedByDefault } = await capa.models.getLayoutInfo(id);
595
1577
  ```
596
1578
 
597
- A workspace `tree` is a flat `WorkspaceDocNode[]` since 0t: one list of folders
598
- the customer named, each holding any mix of `{model}`, `{instance}`,
599
- `{media_folder}` and further folders. The 0h shape, `{ model, content, media }`,
600
- is still accepted for one release and lands in folders called Models, Content
601
- and Media.
1579
+ A workspace `tree` is a flat `WorkspaceDocNode[]`: one list of folders you
1580
+ named, each holding any mix of `{model}`, `{instance}`, `{media_folder}` and
1581
+ further folders. The older shape, `{ model, content, media }`, is still
1582
+ accepted and lands in folders called Models, Content and Media.
602
1583
 
603
1584
  `workspaces.*` needs a key with `write`; `models.setLayout` needs one with
604
- `agent`, because it writes a MODEL and that is the gate every other model write
605
- asks for. `models.getLayout` needs only `read`. A refused document comes back as
606
- `CapaError` carrying the API's own `{ error, path }`, where `path` names the
607
- node to fix. `apps/api/LAYOUT.md` has the rules and a worked example.
1585
+ `agent`, because it writes a model and that is the permission every model
1586
+ write asks for. `models.getLayout` needs only `read`. A refused document comes
1587
+ back as `CapaError` carrying the API's own `{ error, path }`, where `path`
1588
+ names the node to fix.
608
1589
 
609
- ## Scheduling a publish
1590
+ ### Scheduling a publish
610
1591
 
611
1592
  ```ts
612
1593
  const { batchId, resolved, actions, replaced } = await capa.scheduledActions.create({
@@ -617,10 +1598,9 @@ const { batchId, resolved, actions, replaced } = await capa.scheduledActions.cre
617
1598
  });
618
1599
  ```
619
1600
 
620
- **Instance targets only, from a key.** A `{ type: "model" }` target needs the
621
- `model:publish` permission, and no API key bundle carries it in phase 1, so the
622
- server answers 403 whatever the key. Scheduling a schema publish is an admin
623
- session job until a bundle grants it.
1601
+ **Entry targets only, from a key.** A `{ type: "model" }` target needs the
1602
+ `model:publish` permission, which no API key carries, so the server answers
1603
+ 403 whatever the key. Scheduling a model publish is done in the Capa admin.
624
1604
 
625
1605
  **Send a wall clock and a zone, not an instant.** `wallTime` carrying a `Z` or a
626
1606
  `+02:00` is refused, here and by the server, because the two together are the
@@ -668,18 +1648,17 @@ right now and `cancel` answers 409 `already_running`. `resultVersionId` on a
668
1648
  `done` row is the version that went live.
669
1649
 
670
1650
  Reading needs a `read` key; every write here needs `instance:publish`, which the
671
- `write`, `delete` and `agent` bundles carry. A refusal is a `CapaError` with the
672
- API's own `{ error, code }`. The operator's side of all of this, including what
673
- to do when an action fails at 3am, is `docs/PUBLISHING.md`.
1651
+ `write`, `delete` and `agent` keys carry. A refusal is a `CapaError` with the
1652
+ API's own `{ error, code }`.
674
1653
 
675
- ## Webhooks
1654
+ ### Webhooks
676
1655
 
677
1656
  Two halves that do not need each other. The verifier is clientless, so a
678
1657
  receiver imports one function and nothing else. The resource manages endpoints,
679
1658
  and it is the only thing in this SDK that needs a session token rather than an
680
1659
  API key.
681
1660
 
682
- ### Verifying a delivery
1661
+ #### Verifying a delivery
683
1662
 
684
1663
  ```ts
685
1664
  import { verifyWebhookSignature } from "@capacms/sdk";
@@ -718,12 +1697,12 @@ During the 24 hours after a rotation Capa sends two `v1=` entries, the new
718
1697
  secret first. Either one verifies, so a receiver can be updated any time inside
719
1698
  that window without dropping a request.
720
1699
 
721
- ### Managing endpoints
1700
+ #### Managing endpoints
722
1701
 
723
1702
  ```ts
724
1703
  const capa = createClient({
725
- baseUrl: process.env.CAPA_BASE_URL!,
726
- apiKey: process.env.CAPA_API_KEY!,
1704
+ baseUrl: process.env.CAPA_API_URL!,
1705
+ apiKey: process.env.CAPA_KEY!,
727
1706
  tenantId: process.env.CAPA_TENANT_ID!,
728
1707
  accessToken: sessionJwt, // from POST /v2/user/login
729
1708
  });
@@ -738,19 +1717,20 @@ const { secret, ...endpoint } = await capa.webhooks.endpoints.create({
738
1717
 
739
1718
  `createClient` still needs `apiKey` and `tenantId` to construct at all, so a
740
1719
  script that only ever calls `webhooks.*` has to pass something non-empty for
741
- both. Any placeholder will do, because no webhook call reads either one.
1720
+ both. Any legacy-looking placeholder will do, because no webhook call reads
1721
+ either one.
742
1722
 
743
1723
  **The tenant comes from the session, not from `tenantId`.** These routes read
744
1724
  the current tenant off the logged-in user's token, so the `tenantId` you passed
745
1725
  to `createClient` is ignored for every `webhooks.*` call: a client built with one
746
1726
  `tenantId` operates on whatever tenant that user is currently on.
747
1727
 
748
- **`accessToken`, not `apiKey`.** The webhook routes have no API key mount at
749
- all (publishing spec D8): a key that can add an endpoint can forward every
750
- content change in the tenant to a URL of its choosing, and a key is a string in
751
- a config file nobody rotates. So these routes want a logged-in person, and every
752
- `webhooks.*` method throws `@capacms/sdk: webhooks need accessToken; API keys
753
- cannot manage endpoints.` before sending anything when the token is absent.
1728
+ **`accessToken`, not `apiKey`.** The webhook routes take no API key at all: a
1729
+ key that can add an endpoint can forward every content change in the project
1730
+ to a URL of its choosing, and a key is a string in a config file nobody
1731
+ rotates. So these routes want a logged-in person, and every `webhooks.*`
1732
+ method throws `@capacms/sdk: webhooks need accessToken; API keys cannot manage
1733
+ endpoints.` before sending anything when the token is absent.
754
1734
 
755
1735
  The rest of the resource:
756
1736
 
@@ -773,9 +1753,9 @@ await capa.webhooks.deliveries.redeliver(deliveryId); // same event id
773
1753
  ```
774
1754
 
775
1755
  `test` answers `{ eventId }`, and that value is an **opaque request id**: the
776
- event that actually arrives carries a different `id` (`evt_` plus the outbox row
777
- id), so there is nothing to correlate on. Read `deliveries(id)` and take the
778
- newest `webhook.test` row to see what the test did.
1756
+ event that actually arrives carries a different `id` (`evt_` plus another
1757
+ id), so there is nothing to correlate on. Read `deliveries(id)` and take the newest
1758
+ `webhook.test` row to see what the test did.
779
1759
 
780
1760
  `resume` without `backfill` is deliberately a two-step: it enables the endpoint
781
1761
  and tells you how many events it missed, so you can show a person
@@ -783,10 +1763,7 @@ and tells you how many events it missed, so you can show a person
783
1763
  Call it again with `{ backfill: true, since }` if they say yes.
784
1764
 
785
1765
  Reads need `webhook:read`, writes need the `webhook:*` write actions, and
786
- `revealSecret` needs `secret:reveal`, which no role bundle holds: it is an owner
787
- or an explicit override, and every call leaves an audit row whether it was
788
- allowed or denied. A server without `CAPA_SECRET_KEY` set answers `503` with
789
- code `webhooks_not_configured` to every write while reads keep working.
790
-
791
- The operator's side of all of this, including the retry ladder, auto-pause, the
792
- SSRF rules and how to run a receiver locally, is `docs/WEBHOOKS.md`.
1766
+ `revealSecret` needs `secret:reveal`, which no role holds by default: it is an
1767
+ owner or an explicit grant, and every call leaves an audit row whether it was
1768
+ allowed or denied. A server without webhook secrets configured answers `503`
1769
+ with code `webhooks_not_configured` to every write while reads keep working.