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