@capacms/sdk 1.0.0-next.0
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/README.md +678 -0
- package/bin/capa-codegen.js +57 -0
- package/dist/client.d.ts +413 -0
- package/dist/client.js +288 -0
- package/dist/codegen.d.ts +69 -0
- package/dist/codegen.js +188 -0
- package/dist/config.d.ts +60 -0
- package/dist/config.js +15 -0
- package/dist/http.d.ts +67 -0
- package/dist/http.js +144 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +21 -0
- package/dist/next/client.d.ts +294 -0
- package/dist/next/client.js +408 -0
- package/dist/next/index.d.ts +3 -0
- package/dist/next/index.js +9 -0
- package/dist/next/select-types.d.ts +39 -0
- package/dist/next/select-types.js +2 -0
- package/dist/nextjs/index.d.ts +53 -0
- package/dist/nextjs/index.js +165 -0
- package/dist/webhook-signature.d.ts +88 -0
- package/dist/webhook-signature.js +161 -0
- package/dist/webhooks.d.ts +244 -0
- package/dist/webhooks.js +130 -0
- package/package.json +69 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.instanceIdOf = instanceIdOf;
|
|
4
|
+
exports.createClient = createClient;
|
|
5
|
+
/**
|
|
6
|
+
* client.ts — the read client, plus the one thing it can write.
|
|
7
|
+
*
|
|
8
|
+
* This file used to say the SDK was read-only "because the surface it talks to
|
|
9
|
+
* is". That was true when it was written and is not any more: the agent surface
|
|
10
|
+
* (#383) put `/v2/agent/*` behind `verifyApiKey`, ADMIN_UI_OVERHAUL 0h.4b added
|
|
11
|
+
* `/v2/agent/workspaces`, and `tenant_api_keys.permission` became a real
|
|
12
|
+
* control (#4646).
|
|
13
|
+
*
|
|
14
|
+
* So `workspaces` writes, and nothing else does. A workspace is NAVIGATION —
|
|
15
|
+
* which models, entries and media folders the admin's left rail keeps in reach,
|
|
16
|
+
* in which folders — so applying one touches no record and deletes nothing. The
|
|
17
|
+
* content writes an SDK could now technically reach need a surface designed for
|
|
18
|
+
* them, not a fourth method quietly added to this object.
|
|
19
|
+
*/
|
|
20
|
+
const config_1 = require("./config");
|
|
21
|
+
const http_1 = require("./http");
|
|
22
|
+
const webhooks_1 = require("./webhooks");
|
|
23
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
24
|
+
/**
|
|
25
|
+
* The Capa instance id for a row.
|
|
26
|
+
*
|
|
27
|
+
* `instanceId` FIRST, and it is the answer on any current server: #4628 added
|
|
28
|
+
* it to every public read row as the last key, precisely so that one key always
|
|
29
|
+
* means the instance id.
|
|
30
|
+
*
|
|
31
|
+
* `id` is the fallback, and it is a fallback because it is not trustworthy.
|
|
32
|
+
* `GET /v2/api/:ns` builds each row as `{...instance, ...formatInstanceData(instance)}`
|
|
33
|
+
* — the model's own fields are hoisted to the top level and spread SECOND, so a
|
|
34
|
+
* field named `id` overwrites the instance's id and the real UUID is then absent
|
|
35
|
+
* from the row, not merely moved. The same happens to `title` and `tags`. In one
|
|
36
|
+
* ordinary tenant 20 of 20 models have a field named `title` and 7 of 20 have
|
|
37
|
+
* one named `id` (every shopify_* model, plus judge_me_review), so this is the
|
|
38
|
+
* common case. The UUID test is therefore kept for servers older than the fix,
|
|
39
|
+
* where it is the only thing separating an instance id from a Shopify numeric
|
|
40
|
+
* id that happens to live in a field called `id`.
|
|
41
|
+
*
|
|
42
|
+
* `null` means the server sent no usable id at all: an old server AND a
|
|
43
|
+
* shadowing model. `getContentById` cannot be reached for such a row.
|
|
44
|
+
*/
|
|
45
|
+
function instanceIdOf(row) {
|
|
46
|
+
const r = row;
|
|
47
|
+
if (typeof r?.instanceId === "string" && UUID_RE.test(r.instanceId))
|
|
48
|
+
return r.instanceId;
|
|
49
|
+
const id = r?.id;
|
|
50
|
+
return typeof id === "string" && UUID_RE.test(id) ? id : null;
|
|
51
|
+
}
|
|
52
|
+
/** Capa wraps most values as `{type, value, sortOrder}` — including `title`. */
|
|
53
|
+
const unwrap = (v) => v && typeof v === "object" && !Array.isArray(v) && "value" in v
|
|
54
|
+
? v.value
|
|
55
|
+
: v ?? null;
|
|
56
|
+
function contentQuery(options = {}) {
|
|
57
|
+
const q = {
|
|
58
|
+
limit: options.limit,
|
|
59
|
+
page: options.page,
|
|
60
|
+
depth: options.depth,
|
|
61
|
+
sort: options.sort,
|
|
62
|
+
};
|
|
63
|
+
for (const [k, v] of Object.entries(options.where ?? {}))
|
|
64
|
+
q[k] = v;
|
|
65
|
+
return q;
|
|
66
|
+
}
|
|
67
|
+
function createClient(config) {
|
|
68
|
+
const resolved = (0, config_1.resolveConfig)(config);
|
|
69
|
+
async function listContent(namespace, options = {}) {
|
|
70
|
+
const { body, cacheTags } = await (0, http_1.getJson)(resolved, `/v2/api/${encodeURIComponent(namespace)}`, contentQuery(options));
|
|
71
|
+
const rows = body.data ?? [];
|
|
72
|
+
return { data: rows, ids: rows.map(instanceIdOf), meta: body.meta ?? {}, cacheTags };
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
listContent,
|
|
76
|
+
async getContentById(namespace, id, options = {}) {
|
|
77
|
+
// Fail here rather than spending a request on a guaranteed 400. See
|
|
78
|
+
// instanceIdOf: for a shadowing model, the "id" on a listContent row is
|
|
79
|
+
// the model's own field and the API rejects it as invalid.
|
|
80
|
+
if (!UUID_RE.test(String(id ?? ""))) {
|
|
81
|
+
throw new Error(`@capacms/sdk: "${id}" is not a Capa instance id (they are UUIDs). ` +
|
|
82
|
+
`If it came from a listContent row, read \`row.instanceId\` or \`Page.ids\` instead ` +
|
|
83
|
+
`of \`row.id\`: the model "${namespace}" has a field named "id" that overwrites the ` +
|
|
84
|
+
`instance id in that response. See #4628.`);
|
|
85
|
+
}
|
|
86
|
+
const { body } = await (0, http_1.getJson)(resolved, `/v2/api/${encodeURIComponent(namespace)}`, { ids: id, depth: options.depth, limit: 1 });
|
|
87
|
+
return (body.data ?? [])[0] ?? null;
|
|
88
|
+
},
|
|
89
|
+
/**
|
|
90
|
+
* One row matched by field values — `findOne("shopify_page", {handle})`.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately NOT called `getBySlug`. Capa has no slug convention: the
|
|
93
|
+
* field is `handle` on shopify_page, `product_handle` on judge_me_review
|
|
94
|
+
* and `bloghandle` on shopify_article, and nothing marks one as the slug.
|
|
95
|
+
* Naming this `getBySlug` would imply a convention that does not exist and
|
|
96
|
+
* bake each consumer's guess into the API. A slug marker on the model is
|
|
97
|
+
* the right fix, and belongs in Capa rather than here.
|
|
98
|
+
*/
|
|
99
|
+
async findOne(namespace, where, options = {}) {
|
|
100
|
+
const page = await listContent(namespace, { ...options, where, limit: 1 });
|
|
101
|
+
return page.data[0] ?? null;
|
|
102
|
+
},
|
|
103
|
+
async search(q, options = {}) {
|
|
104
|
+
const text = String(q ?? "").trim();
|
|
105
|
+
// The server answers an absent `q` with 500 carrying `Cannot read
|
|
106
|
+
// properties of undefined (reading 'trim')` — the parameter is trimmed
|
|
107
|
+
// before it is checked. #4627 made it a 400 and the public-surface freeze
|
|
108
|
+
// put the 500 back on 2026-09-20, so this guard is what a caller actually
|
|
109
|
+
// gets: a named error, at no request cost, on every server.
|
|
110
|
+
if (!text)
|
|
111
|
+
throw new Error("@capacms/sdk: search(q) requires a non-empty query.");
|
|
112
|
+
const { body } = await (0, http_1.getJson)(resolved, "/v2/api/search", {
|
|
113
|
+
q: text,
|
|
114
|
+
modelNamespace: options.namespace,
|
|
115
|
+
size: options.limit ?? 20,
|
|
116
|
+
page: options.page,
|
|
117
|
+
// The default (non-extended) branch answers {id, title} and says
|
|
118
|
+
// nothing about which model a hit belongs to, so the namespace has to
|
|
119
|
+
// be bought with a hydration round trip. #4627 briefly added it to the
|
|
120
|
+
// default row; the public-surface freeze reverted that on 2026-09-20.
|
|
121
|
+
extended: true,
|
|
122
|
+
depth: 0,
|
|
123
|
+
});
|
|
124
|
+
return (body.data ?? []).map((row) => ({
|
|
125
|
+
// instanceIdOf, not row.id. `/v2/api/search?extended=true` hoists the
|
|
126
|
+
// model's own fields exactly as `/v2/api/:ns` does, so a model with a
|
|
127
|
+
// field named `id` shadows the id here too; #4628 now sends
|
|
128
|
+
// `instanceId` on these rows as well. `null` means an old server AND a
|
|
129
|
+
// shadowing model, the same as `Page.ids`.
|
|
130
|
+
id: instanceIdOf(row),
|
|
131
|
+
namespace: row.dataModel?.namespace ?? row.modelNamespace ?? null,
|
|
132
|
+
model: row.dataModel?.modelName ?? undefined,
|
|
133
|
+
title: unwrap(row.title),
|
|
134
|
+
}));
|
|
135
|
+
},
|
|
136
|
+
/**
|
|
137
|
+
* Saved workspaces, over `/v2/agent/workspaces` (ADMIN_UI_OVERHAUL 0h.4b).
|
|
138
|
+
*
|
|
139
|
+
* `list`, `get` and `getDocument` need a `read` key. `apply` and `create`
|
|
140
|
+
* need one with `write` permission and throw `CapaError` with status 403
|
|
141
|
+
* otherwise — the API's own body, not a message invented here.
|
|
142
|
+
*/
|
|
143
|
+
workspaces: {
|
|
144
|
+
async list(options = {}) {
|
|
145
|
+
const { body } = await (0, http_1.getJson)(resolved, "/v2/agent/workspaces", options.includePrivate ? { all: "true" } : {});
|
|
146
|
+
return {
|
|
147
|
+
workspaces: body.workspaces ?? [],
|
|
148
|
+
currentId: body.currentId ?? null,
|
|
149
|
+
defaultId: body.defaultId ?? null,
|
|
150
|
+
};
|
|
151
|
+
},
|
|
152
|
+
/**
|
|
153
|
+
* One summary row. Served from the LIST rather than a GET /:id, because
|
|
154
|
+
* there is no such route: 0h.4 gives a workspace `/tree` and `/document`,
|
|
155
|
+
* and the summary a caller wants alongside them is the switcher row.
|
|
156
|
+
* Doing this here beats every caller hand-rolling the same filter.
|
|
157
|
+
*/
|
|
158
|
+
async get(id) {
|
|
159
|
+
const { body } = await (0, http_1.getJson)(resolved, "/v2/agent/workspaces", { all: "true" });
|
|
160
|
+
return (body.workspaces ?? []).find((w) => w.id === id) ?? null;
|
|
161
|
+
},
|
|
162
|
+
async getDocument(id) {
|
|
163
|
+
const { body } = await (0, http_1.getJson)(resolved, `/v2/agent/workspaces/${encodeURIComponent(id)}/document`);
|
|
164
|
+
return body;
|
|
165
|
+
},
|
|
166
|
+
async apply(id, document, options = {}) {
|
|
167
|
+
// "merge" by default: the mode that adds and never removes. A default
|
|
168
|
+
// of "replace" would make a caller's first, smallest experiment delete
|
|
169
|
+
// the arrangement a team had built.
|
|
170
|
+
return (0, http_1.writeJson)(resolved, "PUT", `/v2/agent/workspaces/${encodeURIComponent(id)}/tree`, { ...document, mode: options.mode ?? "merge" });
|
|
171
|
+
},
|
|
172
|
+
async create(input) {
|
|
173
|
+
if (!input?.name)
|
|
174
|
+
throw new Error("@capacms/sdk: workspaces.create needs a name.");
|
|
175
|
+
// `team`, always. Private means "visible to its creator", and an API
|
|
176
|
+
// key has no person behind it, so a private workspace made this way
|
|
177
|
+
// would be visible to nobody at all — the API refuses it for the same
|
|
178
|
+
// reason. Asked for explicitly rather than left to a server default.
|
|
179
|
+
return (0, http_1.writeJson)(resolved, "POST", "/v2/agent/workspaces", {
|
|
180
|
+
...input,
|
|
181
|
+
visibility: "team",
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
/**
|
|
186
|
+
* The entry-editor layout, over `/v2/agent/models` (ADMIN_UI_OVERHAUL 0m.4).
|
|
187
|
+
*
|
|
188
|
+
* `getLayout` needs a `read` key. `setLayout` WRITES the model and needs a
|
|
189
|
+
* key whose permission is `agent` — `write` and `delete` keys do not carry
|
|
190
|
+
* `model:update`, which is the same gate every other model write asks for,
|
|
191
|
+
* and it throws `CapaError` with status 403 otherwise.
|
|
192
|
+
*
|
|
193
|
+
* `id` is the model's id OR its namespace, the same two spellings
|
|
194
|
+
* `GET /v2/models/:id` accepts, so a layout can be written for a model an
|
|
195
|
+
* agent only knows by namespace.
|
|
196
|
+
*/
|
|
197
|
+
models: {
|
|
198
|
+
async getLayoutInfo(id) {
|
|
199
|
+
const { body } = await (0, http_1.getJson)(resolved, `/v2/agent/models/${encodeURIComponent(id)}`);
|
|
200
|
+
return { layout: body.layout ?? null, embedByDefault: body.embedByDefault === true };
|
|
201
|
+
},
|
|
202
|
+
async getLayout(id) {
|
|
203
|
+
// The same read, narrowed. Kept because it is the call that reads as
|
|
204
|
+
// what it does when the pass-through flag is not what you are after.
|
|
205
|
+
return (await this.getLayoutInfo(id)).layout;
|
|
206
|
+
},
|
|
207
|
+
async setLayout(id, layout) {
|
|
208
|
+
// PUT with an explicit null rather than the DELETE route: one verb, one
|
|
209
|
+
// body, and `setLayout(id, null)` reads as the reset it is.
|
|
210
|
+
const res = await (0, http_1.writeJson)(resolved, "PUT", `/v2/agent/models/${encodeURIComponent(id)}/layout`, { layout });
|
|
211
|
+
return res?.layout ?? null;
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
/**
|
|
215
|
+
* Scheduled publishes, over `/v2/agent/scheduled-actions`.
|
|
216
|
+
*
|
|
217
|
+
* This is the one content-affecting write the SDK offers, and it is offered
|
|
218
|
+
* because it is the one with a receipt: every call leaves a row that says
|
|
219
|
+
* who asked, for when, in which zone, and what happened. `instances.publish`
|
|
220
|
+
* is deliberately still absent (see the README) — scheduling is a request
|
|
221
|
+
* the ledger can show you and cancel; publishing now is not.
|
|
222
|
+
*
|
|
223
|
+
* Reads need a `read` key. Every write here needs `instance:publish`, which
|
|
224
|
+
* the `write`, `delete` and `agent` key bundles carry, and a refusal comes
|
|
225
|
+
* back as `CapaError` with the API's own body.
|
|
226
|
+
*
|
|
227
|
+
* Times: send `wallTime` ("2026-10-01T09:00", no offset) and an IANA
|
|
228
|
+
* `timezone`. The server converts and tells you what it decided in
|
|
229
|
+
* `resolved`, including a `note` when daylight saving moved the time.
|
|
230
|
+
*/
|
|
231
|
+
scheduledActions: {
|
|
232
|
+
async create(input) {
|
|
233
|
+
if (!input?.action)
|
|
234
|
+
throw new Error("@capacms/sdk: scheduledActions.create needs an action.");
|
|
235
|
+
if (!input.targets?.length)
|
|
236
|
+
throw new Error("@capacms/sdk: scheduledActions.create needs at least one target.");
|
|
237
|
+
if (!input.wallTime || !input.timezone) {
|
|
238
|
+
throw new Error("@capacms/sdk: scheduledActions.create needs wallTime and timezone.");
|
|
239
|
+
}
|
|
240
|
+
// An offset in wallTime is a 400 from the server. Caught here because
|
|
241
|
+
// "2026-10-01T09:00Z" is the obvious thing to try and the round trip
|
|
242
|
+
// teaches nothing the message cannot.
|
|
243
|
+
if (/[Zz]$|[+-]\d{2}:?\d{2}$/.test(input.wallTime)) {
|
|
244
|
+
throw new Error("@capacms/sdk: wallTime carries an offset. Send a local wall clock (\"2026-10-01T09:00\") and name the zone in timezone.");
|
|
245
|
+
}
|
|
246
|
+
return (0, http_1.writeJson)(resolved, "POST", "/v2/agent/scheduled-actions", input);
|
|
247
|
+
},
|
|
248
|
+
async list(options = {}) {
|
|
249
|
+
const { body } = await (0, http_1.getJson)(resolved, "/v2/agent/scheduled-actions", {
|
|
250
|
+
status: options.status?.length ? options.status.join(",") : undefined,
|
|
251
|
+
targetId: options.targetId,
|
|
252
|
+
batchId: options.batchId,
|
|
253
|
+
from: options.from,
|
|
254
|
+
to: options.to,
|
|
255
|
+
page: options.page,
|
|
256
|
+
limit: options.limit,
|
|
257
|
+
});
|
|
258
|
+
return {
|
|
259
|
+
actions: body.actions ?? [],
|
|
260
|
+
pagination: body.pagination ?? { total: 0, page: 1, limit: 0, totalPages: 0, hasMore: false },
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
async get(id) {
|
|
264
|
+
try {
|
|
265
|
+
const { body } = await (0, http_1.getJson)(resolved, `/v2/agent/scheduled-actions/${encodeURIComponent(id)}`);
|
|
266
|
+
return body ?? null;
|
|
267
|
+
}
|
|
268
|
+
catch (err) {
|
|
269
|
+
// A 404 here means "no such action for this tenant", which is an
|
|
270
|
+
// answer rather than a failure. Everything else is still thrown.
|
|
271
|
+
if (err instanceof http_1.CapaError && err.status === 404)
|
|
272
|
+
return null;
|
|
273
|
+
throw err;
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
async reschedule(id, when) {
|
|
277
|
+
return (0, http_1.writeJson)(resolved, "PATCH", `/v2/agent/scheduled-actions/${encodeURIComponent(id)}`, when);
|
|
278
|
+
},
|
|
279
|
+
async cancel(id) {
|
|
280
|
+
return (0, http_1.writeJson)(resolved, "POST", `/v2/agent/scheduled-actions/${encodeURIComponent(id)}/cancel`, {});
|
|
281
|
+
},
|
|
282
|
+
async retry(id) {
|
|
283
|
+
return (0, http_1.writeJson)(resolved, "POST", `/v2/agent/scheduled-actions/${encodeURIComponent(id)}/retry`, {});
|
|
284
|
+
},
|
|
285
|
+
},
|
|
286
|
+
webhooks: (0, webhooks_1.createWebhooks)(resolved),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codegen.ts — pull a tenant's generated types and write them to disk.
|
|
3
|
+
*
|
|
4
|
+
* WHY A COMMAND, AND NOT INSTALL-TIME OR BUILD-TIME
|
|
5
|
+
* Install-time generation needs credentials present at `npm install`, which
|
|
6
|
+
* breaks CI images and Docker builds. Build-time generation makes every build
|
|
7
|
+
* network-dependent and non-reproducible, and turns a Capa outage into a build
|
|
8
|
+
* outage for every customer site.
|
|
9
|
+
*
|
|
10
|
+
* So: an explicit command, with its OUTPUT COMMITTED to the consuming repo.
|
|
11
|
+
* That is also what makes the point of this whole feature work. "A model
|
|
12
|
+
* changed, so the build fails" is only useful if a human can see WHAT changed —
|
|
13
|
+
* and a committed generated file turns that into a reviewable line-level diff
|
|
14
|
+
* in a pull request instead of a wall of tsc errors with no cause attached.
|
|
15
|
+
*
|
|
16
|
+
* TWO SERVER-SIDE ROUGH EDGES, BOTH HANDLED HERE RATHER THAN PATCHED IN THE API
|
|
17
|
+
* The API is mid-port to a byte-parity bar, so changing a response on a
|
|
18
|
+
* customer-facing endpoint costs a signed exception. Neither of these is worth
|
|
19
|
+
* one, because both can be solved on this side and the fix is arguably better
|
|
20
|
+
* here anyway:
|
|
21
|
+
*
|
|
22
|
+
* 1. /v2/schema/types sets NO ETag and no Cache-Control, so it cannot be
|
|
23
|
+
* revalidated cheaply. But /v2/schema DOES — it returns a `checksum` and
|
|
24
|
+
* honours If-None-Match with a 304 (verified against a live tenant). That
|
|
25
|
+
* checksum is sha256 over `{models, relations}` (routes/v2/schema.ts:190),
|
|
26
|
+
* which is exactly what the types are generated FROM. So it is a sound
|
|
27
|
+
* change signal for the types, and we spend one conditional request
|
|
28
|
+
* returning 0 bytes when nothing has changed.
|
|
29
|
+
*
|
|
30
|
+
* 2. The generator orders models by `modelName` — the human display name —
|
|
31
|
+
* while interfaces are NAMED from the namespace (`blogs_home_section` ->
|
|
32
|
+
* `BlogsHomeSection`). So renaming a model's display name reorders the
|
|
33
|
+
* whole file without changing a single type, producing a large diff and a
|
|
34
|
+
* false "your types changed" signal in the exact mechanism this feature
|
|
35
|
+
* rests on. We re-sort by interface name locally, which makes the
|
|
36
|
+
* committed file stable under renames.
|
|
37
|
+
*/
|
|
38
|
+
import { type CapaConfig } from "./config";
|
|
39
|
+
export interface CodegenResult {
|
|
40
|
+
/** False when the schema checksum was unchanged and nothing was fetched. */
|
|
41
|
+
changed: boolean;
|
|
42
|
+
checksum: string | null;
|
|
43
|
+
source: string | null;
|
|
44
|
+
/** Interface names present in the output, sorted. */
|
|
45
|
+
types: string[];
|
|
46
|
+
}
|
|
47
|
+
export interface SchemaModelField {
|
|
48
|
+
namespace: string;
|
|
49
|
+
type: string;
|
|
50
|
+
arrayType?: string | null;
|
|
51
|
+
relationRef?: string | null;
|
|
52
|
+
}
|
|
53
|
+
export interface SchemaModel {
|
|
54
|
+
id?: string;
|
|
55
|
+
namespace: string;
|
|
56
|
+
fields?: SchemaModelField[];
|
|
57
|
+
}
|
|
58
|
+
export interface SchemaForTypes {
|
|
59
|
+
models?: SchemaModel[];
|
|
60
|
+
}
|
|
61
|
+
/** Read the checksum a previous run stamped into the generated file. */
|
|
62
|
+
export declare function readStampedChecksum(existing: string | null | undefined): string | null;
|
|
63
|
+
export declare function normalizeTypes(source: string, checksum: string | null, schema?: SchemaForTypes | null): string;
|
|
64
|
+
export declare function typeNames(source: string): string[];
|
|
65
|
+
/**
|
|
66
|
+
* Fetch types if — and only if — the tenant's schema has changed since the
|
|
67
|
+
* checksum stamped in `existing`.
|
|
68
|
+
*/
|
|
69
|
+
export declare function generate(config: CapaConfig, existing?: string | null): Promise<CodegenResult>;
|
package/dist/codegen.js
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.readStampedChecksum = readStampedChecksum;
|
|
4
|
+
exports.normalizeTypes = normalizeTypes;
|
|
5
|
+
exports.typeNames = typeNames;
|
|
6
|
+
exports.generate = generate;
|
|
7
|
+
/**
|
|
8
|
+
* codegen.ts — pull a tenant's generated types and write them to disk.
|
|
9
|
+
*
|
|
10
|
+
* WHY A COMMAND, AND NOT INSTALL-TIME OR BUILD-TIME
|
|
11
|
+
* Install-time generation needs credentials present at `npm install`, which
|
|
12
|
+
* breaks CI images and Docker builds. Build-time generation makes every build
|
|
13
|
+
* network-dependent and non-reproducible, and turns a Capa outage into a build
|
|
14
|
+
* outage for every customer site.
|
|
15
|
+
*
|
|
16
|
+
* So: an explicit command, with its OUTPUT COMMITTED to the consuming repo.
|
|
17
|
+
* That is also what makes the point of this whole feature work. "A model
|
|
18
|
+
* changed, so the build fails" is only useful if a human can see WHAT changed —
|
|
19
|
+
* and a committed generated file turns that into a reviewable line-level diff
|
|
20
|
+
* in a pull request instead of a wall of tsc errors with no cause attached.
|
|
21
|
+
*
|
|
22
|
+
* TWO SERVER-SIDE ROUGH EDGES, BOTH HANDLED HERE RATHER THAN PATCHED IN THE API
|
|
23
|
+
* The API is mid-port to a byte-parity bar, so changing a response on a
|
|
24
|
+
* customer-facing endpoint costs a signed exception. Neither of these is worth
|
|
25
|
+
* one, because both can be solved on this side and the fix is arguably better
|
|
26
|
+
* here anyway:
|
|
27
|
+
*
|
|
28
|
+
* 1. /v2/schema/types sets NO ETag and no Cache-Control, so it cannot be
|
|
29
|
+
* revalidated cheaply. But /v2/schema DOES — it returns a `checksum` and
|
|
30
|
+
* honours If-None-Match with a 304 (verified against a live tenant). That
|
|
31
|
+
* checksum is sha256 over `{models, relations}` (routes/v2/schema.ts:190),
|
|
32
|
+
* which is exactly what the types are generated FROM. So it is a sound
|
|
33
|
+
* change signal for the types, and we spend one conditional request
|
|
34
|
+
* returning 0 bytes when nothing has changed.
|
|
35
|
+
*
|
|
36
|
+
* 2. The generator orders models by `modelName` — the human display name —
|
|
37
|
+
* while interfaces are NAMED from the namespace (`blogs_home_section` ->
|
|
38
|
+
* `BlogsHomeSection`). So renaming a model's display name reorders the
|
|
39
|
+
* whole file without changing a single type, producing a large diff and a
|
|
40
|
+
* false "your types changed" signal in the exact mechanism this feature
|
|
41
|
+
* rests on. We re-sort by interface name locally, which makes the
|
|
42
|
+
* committed file stable under renames.
|
|
43
|
+
*/
|
|
44
|
+
const config_1 = require("./config");
|
|
45
|
+
const http_1 = require("./http");
|
|
46
|
+
const MARKER = "// @capa-schema-checksum ";
|
|
47
|
+
/**
|
|
48
|
+
* The same checksum as a VALUE, not just a comment, so a site can send it back.
|
|
49
|
+
*
|
|
50
|
+
* `MARKER` is what `--check` and the conditional request read; this is what the
|
|
51
|
+
* customer's own code imports and hands to `createClient({ schemaChecksum })`,
|
|
52
|
+
* which puts it on the wire as `Capa-Schema` and makes the `drift` insight
|
|
53
|
+
* possible. One source, two spellings, and stripping BOTH on a re-run is what
|
|
54
|
+
* keeps `normalizeTypes` idempotent.
|
|
55
|
+
*/
|
|
56
|
+
const CHECKSUM_CONST = "export const CAPA_SCHEMA_CHECKSUM = ";
|
|
57
|
+
/** Header lines this module writes, recognised so they can be stripped on re-run. */
|
|
58
|
+
const GENERATED_HEADER = new Set([
|
|
59
|
+
"// Generated by @capacms/sdk. Do not edit by hand.",
|
|
60
|
+
"// Re-run `capa-codegen` after changing a model in Capa.",
|
|
61
|
+
]);
|
|
62
|
+
/** Read the checksum a previous run stamped into the generated file. */
|
|
63
|
+
function readStampedChecksum(existing) {
|
|
64
|
+
if (!existing)
|
|
65
|
+
return null;
|
|
66
|
+
for (const line of existing.split("\n", 20)) {
|
|
67
|
+
if (line.startsWith(MARKER))
|
|
68
|
+
return line.slice(MARKER.length).trim() || null;
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Sort declarations by name so the file is stable regardless of the order the
|
|
74
|
+
* server emitted them in. The leading comment block is kept as a preamble; the
|
|
75
|
+
* shared `Capa*` helpers are kept ahead of tenant types because they are the
|
|
76
|
+
* primitives everything else refers to.
|
|
77
|
+
*/
|
|
78
|
+
function toPascalCase(str) {
|
|
79
|
+
return str
|
|
80
|
+
.split(/[-_]/)
|
|
81
|
+
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
|
|
82
|
+
.join("");
|
|
83
|
+
}
|
|
84
|
+
function relationTarget(ref, models) {
|
|
85
|
+
const target = models.find((model) => model.id === ref || model.namespace === ref);
|
|
86
|
+
return target ? toPascalCase(target.namespace) : "unknown";
|
|
87
|
+
}
|
|
88
|
+
function hasRelations(schema) {
|
|
89
|
+
return !!schema?.models?.some((model) => (model.fields ?? []).some((field) => (field.type === "relation" || (field.type === "array" && field.arrayType === "relation")) && field.relationRef));
|
|
90
|
+
}
|
|
91
|
+
function rewriteRelationFields(block, schema) {
|
|
92
|
+
const name = (/^export interface (\w+)/.exec(block) || [])[1];
|
|
93
|
+
if (!name)
|
|
94
|
+
return block;
|
|
95
|
+
const model = schema.models.find((candidate) => toPascalCase(candidate.namespace) === name);
|
|
96
|
+
if (!model)
|
|
97
|
+
return block;
|
|
98
|
+
let out = block;
|
|
99
|
+
for (const field of model.fields ?? []) {
|
|
100
|
+
if (!field.relationRef)
|
|
101
|
+
continue;
|
|
102
|
+
const target = relationTarget(field.relationRef, schema.models);
|
|
103
|
+
const helper = field.type === "relation"
|
|
104
|
+
? `CapaRelation<${target}>`
|
|
105
|
+
: field.type === "array" && field.arrayType === "relation"
|
|
106
|
+
? `CapaRelationList<${target}>`
|
|
107
|
+
: null;
|
|
108
|
+
if (!helper)
|
|
109
|
+
continue;
|
|
110
|
+
const escaped = field.namespace.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
111
|
+
out = out.replace(new RegExp(`^(\\s*${escaped}\\??:\\s*)[^;]+;`, "m"), `$1${helper};`);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
function relationPreamble() {
|
|
116
|
+
return [
|
|
117
|
+
'export type CapaRelation<T> = T & { readonly __capaRelation: "one"; readonly __capaRelationTarget: T };',
|
|
118
|
+
'export type CapaRelationList<T> = T[] & { readonly __capaRelation: "many"; readonly __capaRelationTarget: T };',
|
|
119
|
+
"",
|
|
120
|
+
].join("\n");
|
|
121
|
+
}
|
|
122
|
+
function selectAliases(parts, schema) {
|
|
123
|
+
const names = new Set(parts.map((part) => (/^export interface (\w+)/.exec(part) || [])[1]).filter(Boolean));
|
|
124
|
+
return schema.models
|
|
125
|
+
.map((model) => toPascalCase(model.namespace))
|
|
126
|
+
.filter((name) => names.has(name))
|
|
127
|
+
.sort()
|
|
128
|
+
.map((name) => `export type ${name}Select = import("@capacms/sdk/next").Select<${name}>;`);
|
|
129
|
+
}
|
|
130
|
+
function normalizeTypes(source, checksum, schema) {
|
|
131
|
+
// Strip a header a previous run wrote, so normalize(normalize(x)) === normalize(x).
|
|
132
|
+
// Without this the old header is treated as preamble and a new one stacks on
|
|
133
|
+
// top of it, and the checksum marker this file is READ BACK for (--check,
|
|
134
|
+
// and the conditional request in generate) ends up duplicated.
|
|
135
|
+
const withoutHeader = source
|
|
136
|
+
.split("\n")
|
|
137
|
+
.filter((l) => !l.startsWith(MARKER) &&
|
|
138
|
+
!l.startsWith(CHECKSUM_CONST) &&
|
|
139
|
+
!GENERATED_HEADER.has(l.trim()))
|
|
140
|
+
.join("\n")
|
|
141
|
+
.replace(/^\n+/, "");
|
|
142
|
+
const parts = withoutHeader.split(/\n(?=export (?:interface|type) )/);
|
|
143
|
+
const preamble = parts.length && !/^export (?:interface|type) /.test(parts[0]) ? parts.shift() : "";
|
|
144
|
+
const nameOf = (b) => (/^export (?:interface|type) (\w+)/.exec(b) || [])[1] ?? "";
|
|
145
|
+
const byName = (a, b) => nameOf(a).localeCompare(nameOf(b));
|
|
146
|
+
const schemaWithRelations = hasRelations(schema) ? schema : null;
|
|
147
|
+
const rewritten = schemaWithRelations ? parts.map((part) => rewriteRelationFields(part, schemaWithRelations)) : parts;
|
|
148
|
+
const shared = rewritten.filter((p) => /^export (?:interface|type) Capa/.test(p)).sort(byName);
|
|
149
|
+
const rest = rewritten.filter((p) => !/^export (?:interface|type) Capa/.test(p)).sort(byName);
|
|
150
|
+
const relationHelpers = schemaWithRelations ? [relationPreamble().trimEnd()] : [];
|
|
151
|
+
const aliases = schemaWithRelations ? selectAliases(rest, schemaWithRelations) : [];
|
|
152
|
+
const head = [
|
|
153
|
+
"// Generated by @capacms/sdk. Do not edit by hand.",
|
|
154
|
+
"// Re-run `capa-codegen` after changing a model in Capa.",
|
|
155
|
+
checksum ? MARKER + checksum : null,
|
|
156
|
+
// Only when there is a checksum, the same rule the marker follows. An
|
|
157
|
+
// exported constant holding an empty string would be worse than an absent
|
|
158
|
+
// one: it would compile, reach the wire, be ignored as malformed, and
|
|
159
|
+
// leave a site looking permanently up to date.
|
|
160
|
+
checksum ? `${CHECKSUM_CONST}${JSON.stringify(checksum)};` : null,
|
|
161
|
+
]
|
|
162
|
+
.filter(Boolean)
|
|
163
|
+
.join("\n");
|
|
164
|
+
const body = [...shared, ...relationHelpers, ...rest, ...aliases].map((b) => b.replace(/\s+$/, "")).join("\n\n");
|
|
165
|
+
const kept = preamble.trim() ? preamble.trim() + "\n\n" : "";
|
|
166
|
+
return `${head}\n\n${kept}${body}\n`;
|
|
167
|
+
}
|
|
168
|
+
function typeNames(source) {
|
|
169
|
+
return [...source.matchAll(/^export (?:interface|type) (\w+)/gm)].map((m) => m[1]).sort();
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Fetch types if — and only if — the tenant's schema has changed since the
|
|
173
|
+
* checksum stamped in `existing`.
|
|
174
|
+
*/
|
|
175
|
+
async function generate(config, existing) {
|
|
176
|
+
const resolved = (0, config_1.resolveConfig)(config);
|
|
177
|
+
const stamped = readStampedChecksum(existing);
|
|
178
|
+
const etag = stamped ? `"${stamped}"` : null;
|
|
179
|
+
const schema = await (0, http_1.getJsonConditional)(resolved, "/v2/schema", etag);
|
|
180
|
+
if (schema === null) {
|
|
181
|
+
// 304 — the model set is byte-identical to what produced the file on disk.
|
|
182
|
+
return { changed: false, checksum: stamped, source: null, types: [] };
|
|
183
|
+
}
|
|
184
|
+
const checksum = schema.body?.checksum ?? (schema.etag ? schema.etag.replace(/"/g, "") : null);
|
|
185
|
+
const raw = await (0, http_1.getText)(resolved, "/v2/schema/types");
|
|
186
|
+
const source = normalizeTypes(raw, checksum, schema.body);
|
|
187
|
+
return { changed: source !== (existing ?? null), checksum, source, types: typeNames(source) };
|
|
188
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client configuration, and the one thing about it that is not obvious.
|
|
3
|
+
*
|
|
4
|
+
* PREVIEW IS A KEY, NOT A FLAG
|
|
5
|
+
* Capa gates unpublished content on the API key's `environment` column, not on
|
|
6
|
+
* anything in the request:
|
|
7
|
+
*
|
|
8
|
+
* // apps/api/src/routes/v2/api.ts:203
|
|
9
|
+
* const environment = req.apiKeyEnvironment || "production";
|
|
10
|
+
* const includeDrafted = environment === "production" ? false : true;
|
|
11
|
+
*
|
|
12
|
+
* So there is no `?preview=true` to pass, and `getContent(ns, id, {preview})`
|
|
13
|
+
* CANNOT be implemented against a published key — the server would ignore it.
|
|
14
|
+
* The honest surface is one client per key:
|
|
15
|
+
*
|
|
16
|
+
* const capa = createClient({ ...cfg, apiKey: PUBLISHED_KEY })
|
|
17
|
+
* const preview = createClient({ ...cfg, apiKey: PREVIEW_KEY })
|
|
18
|
+
*
|
|
19
|
+
* Note also that the comparison above is exact and case-sensitive against a
|
|
20
|
+
* free-text column, so ANY environment that is not literally "production"
|
|
21
|
+
* returns drafts — `staging`, `development`, `draft`, and equally a typo like
|
|
22
|
+
* "Production".
|
|
23
|
+
*
|
|
24
|
+
* AND A CLIENT CANNOT FIND OUT WHICH IT HAS.
|
|
25
|
+
* There is deliberately no `includesDrafts()` here, because it cannot be
|
|
26
|
+
* implemented: `apiKeyEnvironment` is set in verifyApiKey.ts:57, consumed
|
|
27
|
+
* internally to compute `includeDrafted`, and returned to the caller by NO
|
|
28
|
+
* endpoint. /v2/schema carries only {models, relations, checksum, generatedAt}.
|
|
29
|
+
*
|
|
30
|
+
* So a site handed a `draft`, `staging` or `development` key serves unpublished
|
|
31
|
+
* content to the public, and has no way to detect it — not at startup, not at
|
|
32
|
+
* runtime, not from any response. In production-shaped data 29 of 74 keys are
|
|
33
|
+
* non-production. Whatever this SDK offers, it cannot make that safe; the fix
|
|
34
|
+
* is for Capa to report the key's environment on a read a client already makes.
|
|
35
|
+
*/
|
|
36
|
+
export interface CapaConfig {
|
|
37
|
+
/** Base URL of the Capa API, e.g. https://api.example.com. No trailing slash required. */
|
|
38
|
+
baseUrl: string;
|
|
39
|
+
/** A tenant API key. Read scope is all the SDK needs. */
|
|
40
|
+
apiKey: string;
|
|
41
|
+
/** The tenant the key belongs to. */
|
|
42
|
+
tenantId: string;
|
|
43
|
+
/**
|
|
44
|
+
* A session access token, from `POST /v2/user/login`, sent as
|
|
45
|
+
* `Authorization: Bearer`.
|
|
46
|
+
*
|
|
47
|
+
* Only `webhooks.*` uses it, and only because it has to: the webhook routes
|
|
48
|
+
* have no API key mount (publishing spec D8), so endpoints are managed by a
|
|
49
|
+
* logged-in person or not at all. Every other method on the client ignores
|
|
50
|
+
* this field and keeps using `apiKey`.
|
|
51
|
+
*/
|
|
52
|
+
accessToken?: string;
|
|
53
|
+
/** Injected in tests; defaults to the global fetch. */
|
|
54
|
+
fetch?: typeof fetch;
|
|
55
|
+
}
|
|
56
|
+
export interface ResolvedConfig extends CapaConfig {
|
|
57
|
+
baseUrl: string;
|
|
58
|
+
fetchImpl: typeof fetch;
|
|
59
|
+
}
|
|
60
|
+
export declare function resolveConfig(config: CapaConfig): ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.resolveConfig = resolveConfig;
|
|
4
|
+
function resolveConfig(config) {
|
|
5
|
+
const missing = ["baseUrl", "apiKey", "tenantId"].filter((k) => !config[k]);
|
|
6
|
+
if (missing.length) {
|
|
7
|
+
throw new Error(`@capacms/sdk: missing ${missing.join(", ")}. ` +
|
|
8
|
+
`createClient needs baseUrl, apiKey and tenantId.`);
|
|
9
|
+
}
|
|
10
|
+
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
11
|
+
if (typeof fetchImpl !== "function") {
|
|
12
|
+
throw new Error("@capacms/sdk: no fetch available. Pass one via config.fetch on older runtimes.");
|
|
13
|
+
}
|
|
14
|
+
return { ...config, baseUrl: config.baseUrl.replace(/\/+$/, ""), fetchImpl };
|
|
15
|
+
}
|