@capacms/mcp 0.2.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.
@@ -0,0 +1,750 @@
1
+ /**
2
+ * error-guide.mjs — what a Capa API error means and what to do next.
3
+ *
4
+ * Every `/api/` error carries a `code` and usually a `hint` written in the
5
+ * deployment's own values. This guide adds the two things an agent still has
6
+ * to work out: what the code means in general, and which tool to call next.
7
+ * It is offline on purpose, so an agent can explain an error it saw anywhere
8
+ * (a site's logs, a user's paste, another tool) without a key.
9
+ *
10
+ * The codes are the REST envelope's (apps/api/src/api-next/errors.ts) and the
11
+ * GraphQL table of the G plan (1.4).
12
+ */
13
+ import { TIMEOUT_NEXT, UNREACHABLE_NEXT } from "./client.mjs";
14
+ import { MAX_RELATION_DEPTH, RELATION_DEPTH_RULE } from "./graphql/build.mjs";
15
+
16
+ const GUIDE = {
17
+ missing_key: {
18
+ meaning: "The request carried no x-api-key header.",
19
+ fix: "Send the key in x-api-key. For this server, set CAPA_KEY.",
20
+ next: null,
21
+ },
22
+ invalid_key: {
23
+ meaning: "The key is unknown, revoked or expired.",
24
+ fix: "Use a current key from Developers > Keys in the Capa admin.",
25
+ next: null,
26
+ },
27
+ subscription_required: {
28
+ meaning: "The project's plan does not include API access right now.",
29
+ fix: "The project owner has to renew or upgrade the plan. Nothing in the request can change this.",
30
+ next: null,
31
+ },
32
+ scope_missing: {
33
+ meaning: "The key is valid but lacks the scope this route needs.",
34
+ fix: "The hint names the scope. Use a key that holds it, or ask for one.",
35
+ next: "capa_graphql_schema",
36
+ },
37
+ origin_refused: {
38
+ meaning: "The key is restricted to other origins than the one this request came from.",
39
+ fix: "Call from an allowed origin, or use a key without an origin list for server-side reads.",
40
+ next: null,
41
+ },
42
+ edge_only: {
43
+ meaning: "The request reached the origin directly; this deployment only serves /api/ through its CDN.",
44
+ fix: "Use the public API host, not the origin's address.",
45
+ next: null,
46
+ },
47
+ invalid_version: {
48
+ meaning: "Capa-Version names a date this deployment does not serve.",
49
+ fix: "Use one of the dates in the hint, or set CAPA_API_VERSION to one.",
50
+ next: null,
51
+ },
52
+ contract_not_found: {
53
+ meaning: "Capa-Contract names a contract this project does not have. Only contract 1 exists today.",
54
+ fix: "Drop the Capa-Contract header or send 1.",
55
+ next: null,
56
+ },
57
+ model_not_found: {
58
+ meaning: "The model in the path does not exist, or this key cannot read it.",
59
+ fix: "Use a namespace from the model list.",
60
+ next: "capa_graphql_schema",
61
+ },
62
+ entry_not_found: {
63
+ meaning: "No entry with that id is visible to this key. A production key sees published entries only.",
64
+ fix: "Check the id, or use a development key to read drafts.",
65
+ next: "capa_graphql_query",
66
+ },
67
+ unknown_field: {
68
+ meaning: "A select, filter or sort names a field the model does not have.",
69
+ fix: "Use the field names the hint lists.",
70
+ next: "capa_graphql_schema",
71
+ },
72
+ invalid_filter_value: {
73
+ meaning: "A filter value does not fit the field's type, for example text for a number or a non-ISO date.",
74
+ fix: "Send numbers for number fields, true or false for true_false fields, and ISO 8601 dates.",
75
+ next: "capa_graphql_schema",
76
+ },
77
+ invalid_operator: {
78
+ meaning: "The filter or sort uses an operator this field type does not support.",
79
+ fix: "Use one of the operators the hint lists for that field.",
80
+ next: "capa_graphql_schema",
81
+ },
82
+ invalid_cursor: {
83
+ meaning: "The after or before cursor is malformed, was minted for another sort, or belongs to another parent entry.",
84
+ fix: "Pass a cursor from a page of the same query, unchanged, with the same sort: its endCursor as after, or its startCursor as before.",
85
+ next: "capa_graphql_query",
86
+ },
87
+ invalid_select: {
88
+ meaning: "The selection cannot be planned, for example a relation modifier where it does not apply.",
89
+ fix: `Follow the hint; nest ${RELATION_DEPTH_RULE}.`,
90
+ next: "capa_graphql_build",
91
+ },
92
+ invalid_parameter: {
93
+ meaning: "A request parameter is missing or out of range: first outside 1 to 200, more than 3 sort values, a non-UUID id, bad JSON in variables.",
94
+ fix: "Change the argument the message names to a value the hint allows.",
95
+ next: "capa_graphql_build",
96
+ },
97
+ query_too_complex: {
98
+ meaning:
99
+ "The query is over a budget, and the message says which, what it measured and the limit. The budgets: 5,000 entries " +
100
+ "(each totalCount and each filter or sort through a relation costs 500 more), " +
101
+ `10 root fields, 25 lists, 1,000 fields, depth 8, 32 KB documents, entries ${MAX_RELATION_DEPTH + 1} levels deep (${MAX_RELATION_DEPTH} relations below the root), ` +
102
+ "12 relations expanded per root field, 200 values per list operator, 50 filter conditions nested at most 8 deep.",
103
+ fix: "Ask for fewer fields, a smaller first, or fewer nested relations; split one query into several.",
104
+ next: "capa_graphql_build",
105
+ },
106
+ count_unavailable: {
107
+ meaning: "totalCount could not be computed cheaply for this filter (a relation filter matching over 50,000 entries).",
108
+ fix: "Drop totalCount, or narrow the filter.",
109
+ next: "capa_graphql_query",
110
+ },
111
+ query_timeout: {
112
+ meaning: "The database stopped the query at the statement timeout. Later root fields were not run.",
113
+ fix: "Ask for fewer entries or fewer nested relations, or filter on fewer relation hops.",
114
+ next: "capa_graphql_build",
115
+ },
116
+ graphql_parse_failed: {
117
+ meaning: "The GraphQL document has a syntax error.",
118
+ fix: "Fix the text at the line and column given. Let capa_graphql_build write the document instead.",
119
+ next: "capa_graphql_build",
120
+ },
121
+ graphql_validation_failed: {
122
+ meaning: "The document parses but asks for something the key's schema does not have: a field, an argument, a type or a missing variable.",
123
+ fix: "Use the names the message suggests; the schema only holds models this key can read.",
124
+ next: "capa_graphql_schema",
125
+ },
126
+ persisted_query_not_found: {
127
+ meaning: "No document is stored for that hash. Only a development key stores one; a production key never does.",
128
+ fix:
129
+ "Send the query text beside the same hash: it runs either way, and the SDK and Apollo do this for you. " +
130
+ "To stop the miss, register the documents at build time with a development key (capa persist).",
131
+ next: null,
132
+ },
133
+ persisted_query_hash_mismatch: {
134
+ meaning: "The sha256 sent does not match the query text sent.",
135
+ fix: "Hash the exact query string, UTF-8, lowercase hex.",
136
+ next: null,
137
+ },
138
+ mutations_not_enabled: {
139
+ meaning:
140
+ "The request tried to write. Capa's GraphQL API reads only, and /api/ writes are not enabled here. A POST to a path " +
141
+ "the API does not serve gets the same answer, so a query sent to /api/graphql where GraphQL is switched off " +
142
+ "(CAPA_API_GRAPHQL=off) gets it too, without a hint.",
143
+ // The GraphQL handler's own refusal of a mutation carries a hint; the
144
+ // answer of a path the API does not serve has none. Only without one can
145
+ // the request have been a query sent where GraphQL is off.
146
+ fix: (error) =>
147
+ "Send a query instead; nothing here can write. Content is edited in the Capa admin." +
148
+ (error.hint
149
+ ? ""
150
+ : " If it was already a query, GraphQL is off on this deployment: read over REST instead, GET /api/entries/<model>."),
151
+ next: null,
152
+ },
153
+ route_not_found: {
154
+ meaning:
155
+ "This deployment does not serve that path. GraphQL may be switched off (CAPA_API_GRAPHQL=off), pages may be off " +
156
+ "(CAPA_SITE_PREVIEW, off by default), or the path is mistyped.",
157
+ fix: "Check the path; use /api/entries for REST reads if GraphQL is off. Pages cannot be read where they are off.",
158
+ next: "capa_graphql_schema",
159
+ },
160
+ page_not_found: {
161
+ meaning: "No model declares that page and no read has reported it.",
162
+ fix: "Use a page from the page list.",
163
+ next: "capa_list_pages",
164
+ },
165
+ preview_token_invalid: {
166
+ meaning: "The preview token is forged, mangled or for another project.",
167
+ fix: "Open a fresh preview link from the Capa editor.",
168
+ next: null,
169
+ },
170
+ preview_token_expired: {
171
+ meaning: "The preview token was real but has expired.",
172
+ fix: "Open a fresh preview link from the Capa editor.",
173
+ next: null,
174
+ },
175
+ rate_limit_exceeded: {
176
+ meaning:
177
+ "Capa refused the request to protect the service, for one of two reasons the message names: too many reads running at " +
178
+ "once (the API's read gate, per client, per key or per project), or too many uncached requests from this key in a " +
179
+ "minute (the CDN's limit).",
180
+ // The API's read gate says "running at once" (read-gate.ts); the CDN's
181
+ // limiter counts origin requests "in a minute" (cutover/HAND_CHANGES.md).
182
+ fix: (error) => {
183
+ const message = String(error.message ?? "");
184
+ if (/across its keys/.test(message)) {
185
+ return "Send fewer reads at once across this project's keys, a few at a time rather than all together, then retry after the Retry-After seconds.";
186
+ }
187
+ if (/running at once/.test(message)) {
188
+ return "Send fewer reads at once with this key, one after another or a few at a time, then retry after the Retry-After seconds.";
189
+ }
190
+ if (/in a minute/.test(message)) {
191
+ return "Wait for the Retry-After seconds, then retry; cache GET responses where you can, since cached reads are not counted.";
192
+ }
193
+ return "Send fewer reads at once and retry after the Retry-After seconds; if the message counts requests in a minute, also cache GET responses, since cached reads are not counted.";
194
+ },
195
+ next: null,
196
+ },
197
+ internal: {
198
+ meaning: "Capa hit an unexpected error. The body has no details, by design.",
199
+ fix: "Retry once. If it repeats, report the requestId to Capa support.",
200
+ next: null,
201
+ },
202
+ };
203
+
204
+ export const KNOWN_CODES = Object.keys(GUIDE);
205
+
206
+ /**
207
+ * Every budget refusal states what the request measured and the limit it
208
+ * passed (spec 17, amendments 32 and 44), on GraphQL and on REST:
209
+ * "bins: could read 40,200 entries, over the limit of 5,000.". GraphQL writes
210
+ * counts with thousands separators and REST's plain. Each row reads one kind
211
+ * of refusal and turns it into the fix for THAT budget, and the tool that
212
+ * helps: a smaller first is a job for capa_graphql_build, a document with too
213
+ * many root fields has to be split and run piece by piece with
214
+ * capa_graphql_query.
215
+ *
216
+ * `measure` names the budget. Named groups: `root` (the root field, when the
217
+ * message names one), `value` (what was measured), `atLeast` (the count
218
+ * stopped at the first item past the limit) and `limit`. A fix is handed each
219
+ * count as the API wrote it (`value`, `limit`, `measured`: "at least 1,001"),
220
+ * so an agent reads the numbers of the message it was sent, and the numbers
221
+ * (`valueCount`, `limitCount`) for arithmetic.
222
+ */
223
+ /** A count as the API writes it, with or without thousands separators: `40,200` or `40200`. */
224
+ const count = (text) => Number(String(text).replace(/,/g, ""));
225
+
226
+ const BUDGETS = [
227
+ {
228
+ measure: "entries",
229
+ pattern: /^(?<root>[_A-Za-z][_0-9A-Za-z]*): could read (?<value>[\d,]+) entries, over the limit of (?<limit>[\d,]+)\./,
230
+ fix: (b) => `${b.root} could read ${b.value} entries, over the limit of ${b.limit}. Lower first on ${b.root} or on the relation lists inside it: every entry counts once, and every item of a relation list inside it once more.`,
231
+ next: "capa_graphql_build",
232
+ },
233
+ {
234
+ measure: "entries",
235
+ pattern: /^This document could read (?<value>[\d,]+) entries, over the limit of (?<limit>[\d,]+)\./,
236
+ fix: (b) => `The root fields together could read ${b.value} entries, over the limit of ${b.limit}. Lower first on them, or run them as separate queries.`,
237
+ next: "capa_graphql_build",
238
+ },
239
+ {
240
+ measure: "entries",
241
+ pattern:
242
+ /^This document costs (?<value>[\d,]+) entries, over the limit of (?<limit>[\d,]+): (?<returns>[\d,]+) it could return, plus (?<scans>\d+) (?:count|counts)/,
243
+ fix: (b) =>
244
+ `The query costs ${b.value} entries, over the limit of ${b.limit}: ${b.returns} it could return, plus 500 for each of its ${b.scans} totalCount fields and filters or sorts through a relation. ` +
245
+ "Drop totalCount, filter and sort on the model's own fields, or split the query.",
246
+ next: "capa_graphql_build",
247
+ },
248
+ {
249
+ measure: "entries",
250
+ pattern: /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): )?This request could return (?<value>[\d,]+) (?:entries|nodes), over the limit of (?<limit>[\d,]+)\./,
251
+ fix: (b) => `The read could return ${b.value} entries, over the limit of ${b.limit}. Lower first on the list or on its relation lists.`,
252
+ next: "capa_graphql_build",
253
+ },
254
+ {
255
+ measure: "entries",
256
+ pattern: /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): )?This request read (?<atLeast>at least )?(?<value>[\d,]+) (?:entries|nodes), over the limit of (?<limit>[\d,]+)\./,
257
+ fix: (b) => `The read passed ${b.limit} entries before it finished. Lower first on the list or on its relation lists.`,
258
+ next: "capa_graphql_build",
259
+ },
260
+ {
261
+ measure: "sortedEntries",
262
+ pattern: /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): )?(?<relation>\S+) references (?<value>[\d,]+) entries to order, over the limit of (?<limit>[\d,]+)\./,
263
+ fix: (b) => `Sorting ${b.relation} means ordering every entry it points at, ${b.value} here, over the limit of ${b.limit}. Drop its sort, or read fewer entries at a time (a smaller first).`,
264
+ next: "capa_graphql_build",
265
+ },
266
+ {
267
+ measure: "rootFields",
268
+ pattern: /^This document has (?<value>[\d,]+) entry root fields, over the limit of (?<limit>[\d,]+)\./,
269
+ fix: (b) => `It has ${b.value} root fields and the limit is ${b.limit}. Split it into queries of at most ${b.limit} root fields and run each.`,
270
+ next: "capa_graphql_query",
271
+ },
272
+ {
273
+ measure: "connections",
274
+ pattern: /^This document selects (?<atLeast>at least )?(?<value>[\d,]+) connections, over the limit of (?<limit>[\d,]+)\./,
275
+ fix: (b) => `It selects ${b.measured} lists (list roots and relation lists) and the limit is ${b.limit}. Split it into several queries.`,
276
+ next: "capa_graphql_query",
277
+ },
278
+ {
279
+ measure: "entryDepth",
280
+ pattern: /^(?<root>[_A-Za-z][_0-9A-Za-z]*): reads related entries (?<value>[\d,]+) levels deep, the root entry counted, over the limit of (?<limit>[\d,]+)\./,
281
+ fix: (b) => `${b.root} reads entries ${b.value} levels deep and the limit is ${b.limit}, its own entries counted: nest at most ${b.limitCount - 1} relations below them. Read deeper entries with a second query by id.`,
282
+ next: "capa_graphql_build",
283
+ },
284
+ {
285
+ measure: "entryDepth",
286
+ pattern: /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): )?select is (?<atLeast>at least )?(?<value>[\d,]+) levels deep, over the limit of (?<limit>[\d,]+)\./,
287
+ fix: (b) => `select nests ${b.measured} levels and the limit is ${b.limit}, the entry itself counted: nest at most ${b.limitCount - 1} relations. Read deeper entries with a second request by id.`,
288
+ next: "capa_graphql_build",
289
+ },
290
+ {
291
+ measure: "expansions",
292
+ pattern: /^(?<root>[_A-Za-z][_0-9A-Za-z]*): expands (?<value>[\d,]+) relations, over the limit of (?<limit>[\d,]+)\./,
293
+ fix: (b) => `${b.root} expands ${b.value} relations and the limit is ${b.limit}. Select the others without fields, for their ids, or read them with a second query.`,
294
+ next: "capa_graphql_build",
295
+ },
296
+ {
297
+ measure: "expansions",
298
+ pattern: /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): )?select expands (?<atLeast>at least )?(?<value>[\d,]+) relations, over the limit of (?<limit>[\d,]+)\./,
299
+ fix: (b) => `select expands ${b.measured} relations and the limit is ${b.limit}. Name the others without parentheses, for their ids.`,
300
+ next: "capa_graphql_build",
301
+ },
302
+ {
303
+ measure: "listValues",
304
+ pattern: /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): )?(?<field>.+) with (?<operator>\w+) was sent (?<value>[\d,]+) values, over the limit of (?<limit>[\d,]+)\./,
305
+ fix: (b) => `${b.operator} on ${b.field} was sent ${b.value} values and takes at most ${b.limit}. Split the values across queries.`,
306
+ next: "capa_graphql_query",
307
+ },
308
+ {
309
+ measure: "filterDepth",
310
+ pattern:
311
+ /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): |Variable "\$[_A-Za-z][_0-9A-Za-z]*": )?(?:filter|where) nests (?<atLeast>at least )?(?<value>[\d,]+) levels deep, over the limit of (?<limit>[\d,]+)\./,
312
+ fix: (b) => `The filter nests ${b.measured} levels of and, or and not, and the limit is ${b.limit}. Flatten it: keys in one object already all have to match.`,
313
+ next: "capa_graphql_build",
314
+ },
315
+ {
316
+ measure: "filterConditions",
317
+ pattern: /^(?:(?<root>[_A-Za-z][_0-9A-Za-z]*): )?(?:filter|where) has (?<atLeast>at least )?(?<value>[\d,]+) conditions, over the limit of (?<limit>[\d,]+)\./,
318
+ fix: (b) => `The filter has ${b.measured} conditions and the limit is ${b.limit}. Merge them: in, nin, hasAny and hasAll take many values in one condition.`,
319
+ next: "capa_graphql_build",
320
+ },
321
+ {
322
+ measure: "documentBytes",
323
+ pattern: /^This document is (?<value>[\d,]+) bytes, over the limit of (?<limit>[\d,]+)\./,
324
+ fix: (b) => `It is ${b.value} bytes and the limit is ${b.limit}. Send only the operation you run, without unused fragments; capa_graphql_build writes a compact one.`,
325
+ next: "capa_graphql_build",
326
+ },
327
+ {
328
+ measure: "fields",
329
+ pattern: /^This document selects (?<atLeast>at least )?(?<value>[\d,]+) fields, over the limit of (?<limit>[\d,]+)\./,
330
+ fix: (b) => `It selects ${b.measured} fields, fragments expanded, and the limit is ${b.limit}. Select fewer fields.`,
331
+ next: "capa_graphql_build",
332
+ },
333
+ {
334
+ measure: "fields",
335
+ pattern: /^This document has (?<value>[\d,]+) fields across its operations and fragments, over the limit of (?<limit>[\d,]+)\./,
336
+ fix: (b) => `Every operation and fragment in a document counts: ${b.value} fields, and the limit is ${b.limit}. Send only the operation you run.`,
337
+ next: "capa_graphql_query",
338
+ },
339
+ {
340
+ measure: "depth",
341
+ pattern: /^This document is (?<atLeast>at least )?(?<value>[\d,]+) levels deep, over the limit of (?<limit>[\d,]+)\./,
342
+ fix: (b) => `It nests ${b.measured} levels and the limit is ${b.limit} (edges, node, nodes and pageInfo count zero). Nest fewer relations.`,
343
+ next: "capa_graphql_build",
344
+ },
345
+ {
346
+ measure: "depth",
347
+ pattern: /^This document nests brackets (?<value>[\d,]+) levels deep, over the limit of (?<limit>[\d,]+)\./,
348
+ fix: (b) => `It nests brackets ${b.value} levels deep and the limit is ${b.limit}, far deeper than any query the API can answer. Write the selection and the filter out flat; capa_graphql_build writes one.`,
349
+ next: "capa_graphql_build",
350
+ },
351
+ {
352
+ measure: "introspectionDepth",
353
+ pattern: /^This introspection query is (?<atLeast>at least )?(?<value>[\d,]+) levels deep, over the limit of (?<limit>[\d,]+)\./,
354
+ fix: (b) => `It nests ${b.measured} levels and introspection allows ${b.limit}, which the standard introspection query fits. capa_graphql_schema reads the schema for you.`,
355
+ next: "capa_graphql_schema",
356
+ },
357
+ {
358
+ measure: "sortKeys",
359
+ pattern: /^(?<root>[_A-Za-z][_0-9A-Za-z]*): sort has (?<value>[\d,]+) keys, over the limit of (?<limit>[\d,]+)\./,
360
+ fix: (b) => `${b.root} sorts by ${b.value} keys and takes at most ${b.limit}. Keep the ones that decide the order.`,
361
+ next: "capa_graphql_build",
362
+ code: "invalid_parameter",
363
+ },
364
+ ];
365
+
366
+ /**
367
+ * The budget a refusal's message states, with its numbers and the fix and
368
+ * tool for it, or null for any other message. `measured` and `limit` are
369
+ * numbers an agent can compare; `written` and the fix quote them as the
370
+ * message wrote them.
371
+ *
372
+ * budgetOf("bins: could read 40,200 entries, over the limit of 5,000.")
373
+ * // { root: "bins", measure: "entries", measured: "40200", limit: 5000, written: { measured: "40,200", limit: "5,000" },
374
+ * // fix: "bins could read 40,200 entries, over the limit of 5,000. ...", next: "capa_graphql_build", code: "query_too_complex" }
375
+ */
376
+ export function budgetOf(message) {
377
+ for (const budget of BUDGETS) {
378
+ const match = budget.pattern.exec(String(message ?? "").trim());
379
+ if (!match) continue;
380
+ const { atLeast, value, limit } = match.groups;
381
+ const valueCount = count(value);
382
+ const limitCount = count(limit);
383
+ const written = { ...match.groups, measured: `${atLeast ? "at least " : ""}${value}`, valueCount, limitCount };
384
+ const out = {
385
+ measure: budget.measure,
386
+ measured: `${atLeast ? "at least " : ""}${valueCount}`,
387
+ limit: limitCount,
388
+ written: { measured: written.measured, limit },
389
+ fix: budget.fix(written),
390
+ next: budget.next,
391
+ code: budget.code ?? "query_too_complex",
392
+ };
393
+ return match.groups.root ? { root: match.groups.root, ...out } : out;
394
+ }
395
+ return null;
396
+ }
397
+
398
+ /**
399
+ * The sentence of an entries-budget hint that names the change, as the API
400
+ * wrote it: "Pass coauthors(first: 24) to fit, or lower first elsewhere." or
401
+ * "No one first fits. Select fewer relation lists, or split the document into
402
+ * several requests." (apps/api node-budget.ts). Null when the hint has none.
403
+ * `field`, `argument` and `value` are the change the first form names.
404
+ */
405
+ export function hintedFix(hint) {
406
+ const match =
407
+ /(Pass (?<field>[_A-Za-z][_0-9A-Za-z]*)\((?<argument>first|last): (?<value>\d[\d,]*)\) to fit[^.]*\.|No one first fits\.[^.]*\.)/.exec(String(hint ?? ""));
408
+ if (!match) return null;
409
+ const { field, argument, value } = match.groups;
410
+ return { sentence: match[1], ...(field ? { field, argument, value } : {}) };
411
+ }
412
+
413
+ /**
414
+ * The model type a derived type belongs to (N2): `ArticlesConnection`,
415
+ * `ArticlesEdge`, `AuthorsRelationConnection` and `ArticlesSort` all name
416
+ * their model's type, which capa_graphql_schema takes as `model`.
417
+ */
418
+ const DERIVED = /^(.+?)(RelationConnection|Connection|Edge|Sort|RelationListFilter|RelationFilter|Filter)$/;
419
+ const modelTypeOf = (type) => DERIVED.exec(type)?.[1] ?? type;
420
+
421
+ /** What GraphQL calls the arguments an agent writes with REST's names. */
422
+ const REST_ARGUMENTS = {
423
+ where: "filter",
424
+ limit: "first",
425
+ orderBy: "sort",
426
+ order: "sort",
427
+ offset: "after (a cursor from pageInfo.endCursor)",
428
+ skip: "after (a cursor from pageInfo.endCursor)",
429
+ page: "after (a cursor from pageInfo.endCursor)",
430
+ };
431
+
432
+ /** The fields of the shared types, which are the same on every schema (G plan N7, N10). */
433
+ const SHARED_FIELDS = {
434
+ PageInfo: "hasNextPage, hasPreviousPage, startCursor and endCursor",
435
+ Media: "id, url, alt, type, width and height",
436
+ };
437
+
438
+ /** A field asked of a type that does not have it: where it goes instead, and which tool shows the names. */
439
+ function misplacedField(field, type, suggested) {
440
+ if (/Connection$/.test(type)) {
441
+ return {
442
+ fix: `${type} is a list, not an entry: select ${field} inside nodes { ... }, e.g. { nodes { id ${field} } pageInfo { hasNextPage endCursor } }.`,
443
+ next: "capa_graphql_build",
444
+ };
445
+ }
446
+ if (/Edge$/.test(type)) return { fix: `${type} holds cursor and node: select ${field} inside node { ... }.`, next: "capa_graphql_build" };
447
+ // Quoted, as the message quotes it: the name is the caller's own, never one the schema offered.
448
+ if (type === "Query") {
449
+ return {
450
+ fix: `"${field}" is not a root field for this key. Each model it can read has a list root and a single root, and capa_graphql_schema lists them.`,
451
+ next: "capa_graphql_schema",
452
+ };
453
+ }
454
+ if (SHARED_FIELDS[type]) return { fix: `${type} has ${SHARED_FIELDS[type]}.`, next: null };
455
+ if (suggested) return { fix: `Field ${field} is not on ${type}: use the name the message suggests.`, next: "capa_graphql_query" };
456
+ return { fix: `Field ${field} is not on ${type}. Call capa_graphql_schema with model "${type}" for its fields.`, next: "capa_graphql_schema" };
457
+ }
458
+
459
+ /** The operator a bare filter value stands for, by the filter type that refused it. */
460
+ const operatorOf = (filterType) => (/ListFilter$/.test(filterType) ? "has" : filterType === "MediaFilter" ? "id" : "eq");
461
+
462
+ /** The place and the condition a filter hint gives: "and takes a list of conditions, such as and: [{ title: { eq: "text" } }]." */
463
+ const CONDITION_EXAMPLE = /(\S+) takes a (?:list|set) of conditions, such as (.+?[}\]])\.(?:\s|$)/;
464
+
465
+ /** The operation names a refusal's hint lists: "Operations in this document: A, B." */
466
+ const operationsIn = (hint) => /Operations in this document: ([^.]+)\./.exec(String(hint ?? ""))?.[1] ?? null;
467
+
468
+ /**
469
+ * Patterns in graphql-js validation and coercion messages, and in the API's
470
+ * own refusals of a document, each with the concrete fix and the tool for it.
471
+ * The names they quote are the message's own, or its hint's, which the API
472
+ * already sends to this key.
473
+ */
474
+ const VALIDATION_PATTERNS = [
475
+ [
476
+ /operations, so operationName is required\.|has no operation named /,
477
+ (_m, error) => {
478
+ const names = operationsIn(error.hint);
479
+ return {
480
+ fix: names
481
+ ? `Pass operationName with the one to run: ${names}.`
482
+ : "Pass operationName with the name of the operation to run, or send one operation per request.",
483
+ next: "capa_graphql_query",
484
+ };
485
+ },
486
+ ],
487
+ [/Cannot query field "([^"]+)" on type "([^"]+)"\.( Did you mean)?/, (m) => misplacedField(m[1], m[2], Boolean(m[3]))],
488
+ [
489
+ /Unknown argument "([^"]+)" on field "(?:[^".]+\.)?([^"]+)"/,
490
+ (m) => ({
491
+ fix:
492
+ `${m[2]} takes no argument ${m[1]}.${REST_ARGUMENTS[m[1]] ? ` GraphQL calls it ${REST_ARGUMENTS[m[1]]}.` : ""} ` +
493
+ "A list root takes first, after, before, sort and filter; a relation list first, after and sort; a single root id.",
494
+ next: "capa_graphql_build",
495
+ }),
496
+ ],
497
+ [/Variable "\$([^"]+)" of required type "([^"]+)" was not provided/, (m) => ({ fix: `Pass variables.${m[1]} (${m[2]}).`, next: "capa_graphql_query" })],
498
+ [
499
+ /got invalid value (.+?) at "(?:[^"]*\.)?([^".]+)"; Expected type "([^"]+Filter)" to be an object/,
500
+ (m) => ({
501
+ fix: `${m[2]} takes operators, not a bare value: write { "${m[2]}": { "${operatorOf(m[3])}": ${m[1]} } }.`,
502
+ next: "capa_graphql_build",
503
+ }),
504
+ ],
505
+ [
506
+ /Expected value of type "([^"!]+Filter)!?", found ([^;]+?)\.(?:$|\s)/,
507
+ (m, error) => {
508
+ // The API's hint gives a real condition for the place (spec 17, amendment 144): a model's filter holds
509
+ // conditions on fields, and and or a list of them, so an operator such as eq would be refused again.
510
+ const shown = CONDITION_EXAMPLE.exec(error.hint ?? "");
511
+ if (shown) return { fix: `Write ${shown[1]} as ${shown[2]}.`, next: "capa_graphql_build" };
512
+ // Only an item of and or or is a filter that cannot be null, so a null there is an item to drop or fill.
513
+ if (m[2] === "null" && m[0].includes("!")) {
514
+ return { fix: `${m[1]}! is an item of and or or: remove the null, or write a condition in its place.`, next: "capa_graphql_build" };
515
+ }
516
+ return { fix: `${m[1]} takes an object of operators, e.g. { ${operatorOf(m[1])}: ${m[2]} }.`, next: "capa_graphql_build" };
517
+ },
518
+ ],
519
+ [
520
+ // A part of the filter that is not the shape it takes, named by its place (amendment 144): "filter.and[1].not is null."
521
+ /\b((?:filter|where)(?:\.\S+)?) is (?:null|not a JSON object|not an array|empty)\./,
522
+ (m) => ({
523
+ fix: /\.(?:and|or)$/.test(m[1])
524
+ ? `${m[1]} takes a list of conditions, [{ <field>: { <operator>: <value> } }], or leave it out.`
525
+ : `${m[1]} takes one condition, { <field>: { <operator>: <value> } }, or leave it out.`,
526
+ next: "capa_graphql_build",
527
+ }),
528
+ ],
529
+ [
530
+ / with (\w+) expects at least one value\./,
531
+ (m) => ({ fix: `${m[1]} needs at least one value: send one, or leave the condition out when the list is empty.`, next: "capa_graphql_build" }),
532
+ ],
533
+ [
534
+ /Field "([^"]+)" of type "([^"]+)" must have a selection of subfields/,
535
+ (m) => ({ fix: `${m[1]} is an object (${m[2]}); select fields inside it, at least { id }.`, next: "capa_graphql_query" }),
536
+ ],
537
+ [
538
+ /Value "([^"]+)" does not exist in "([^"]+)" enum/,
539
+ (m) => ({ fix: `${m[1]} is not a value of ${m[2]}. Call capa_graphql_schema with model "${modelTypeOf(m[2])}" for its sort values.`, next: "capa_graphql_schema" }),
540
+ ],
541
+ ];
542
+
543
+ function asObject(value) {
544
+ if (typeof value !== "string") return value;
545
+ try {
546
+ return JSON.parse(value);
547
+ } catch {
548
+ return value;
549
+ }
550
+ }
551
+
552
+ /**
553
+ * Why a request never reached Capa, read from what `fetch`, undici, Node or
554
+ * this server wrote about it: a system code, "timeout", or "fetch failed" for
555
+ * a failure that names no cause. Null for anything else.
556
+ */
557
+ function transportOf(text) {
558
+ const value = String(text ?? "");
559
+ if (/did not answer .* within|\bTimeoutError\b|aborted due to timeout|\bUND_ERR_(?:CONNECT|HEADERS|BODY)_TIMEOUT\b/.test(value)) return "timeout";
560
+ const code = /\b(ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|UND_ERR_SOCKET)\b/.exec(value)?.[1];
561
+ if (code) return code === "ETIMEDOUT" ? "timeout" : code;
562
+ return /\bfetch failed\b|Could not reach Capa|socket hang up/.test(value) ? "fetch failed" : null;
563
+ }
564
+
565
+ /** An HTTP status a text states as one ("HTTP 404", "status 400", "Capa API 401"), never a number that is part of an address. */
566
+ const statusIn = (text) => /\b(?:HTTP|status|Capa API)[:\s]\s*([1-5]\d\d)\b/i.exec(String(text ?? ""))?.[1];
567
+
568
+ /** The name Node prints before a thrown error's message: `CapaError: `, `TypeError [ERR_X]: `. */
569
+ const ERROR_NAME = /^\s*(?:Uncaught )?(?:[A-Z][A-Za-z]*)?Error(?: \[[^\]]+\])?: /;
570
+
571
+ /** A snake_case word: what every Capa error code is, and what a path such as node_modules or task_queues is too. */
572
+ const SNAKE = /\b[a-z]+(?:_[a-z]+)+\b/g;
573
+
574
+ /**
575
+ * A field `util.inspect` prints on a line of its own, ` code: 'query_too_complex',`,
576
+ * as the SDK's CapaError prints in a server log: the string in whichever quotes
577
+ * inspect chose, or a number. Its first line, since a CapaError's own fields
578
+ * come before the `graphqlErrors` it holds.
579
+ */
580
+ function inspectedField(text, name) {
581
+ const match = new RegExp(`^ {2}${name}: (?:(['"\`])((?:\\\\.|(?!\\1).)*)\\1|(\\d+)),?$`, "m").exec(text);
582
+ if (!match) return undefined;
583
+ if (match[3] !== undefined) return Number(match[3]);
584
+ return match[2].replace(/\\(.)/g, (_all, char) => (char === "n" ? "\n" : char));
585
+ }
586
+
587
+ /**
588
+ * Messages whose wording names their code: graphql-js's validation messages,
589
+ * which the API sends as `graphql_validation_failed`, and the API's own for a
590
+ * cursor. How `String(error)` of the SDK's CapaError, which prints the message
591
+ * alone, still names its code. A budget's wording is read by `budgetOf`.
592
+ */
593
+ const WORDED_CODES = [
594
+ [/Cannot query field "|Unknown argument "|must have a selection of subfields|does not exist in "[^"]+" enum|Expected value of type "/, "graphql_validation_failed"],
595
+ [/\bthis cursor is not valid\b/, "invalid_cursor"],
596
+ [/\b(?:filter|where)(?:\.\S+)? is (?:null|not a JSON object|not an array|empty)\.| with \w+ expects at least one value\./, "invalid_filter_value"],
597
+ ];
598
+
599
+ /**
600
+ * One error from pasted text: plain words, a code and message, or a thrown
601
+ * error as Node prints it, `String(error)`, `error.stack` or `util.inspect`.
602
+ * The code is the one inspect printed, else the first word of the text that is
603
+ * a code this guide knows (never `task_queues` from a stack line), else what a
604
+ * budget refusal's or a validation message's own wording says.
605
+ */
606
+ function textError(text) {
607
+ const printed = ERROR_NAME.test(text);
608
+ const message = printed ? text.split("\n")[0].replace(ERROR_NAME, "") : text;
609
+ const inspected = inspectedField(text, "code");
610
+ const known =
611
+ (typeof inspected === "string" && /^[a-z]+(?:_[a-z]+)+$/.test(inspected) ? inspected : undefined) ??
612
+ message.match(SNAKE)?.find((word) => GUIDE[word]) ??
613
+ text.match(SNAKE)?.find((word) => GUIDE[word]) ??
614
+ budgetOf(message)?.code ??
615
+ WORDED_CODES.find(([wording]) => wording.test(message))?.[1];
616
+ const transport = known ? null : transportOf(text);
617
+ if (transport) return { transport, message: text };
618
+ const status = inspectedField(text, "status") ?? Number(statusIn(text) ?? NaN);
619
+ const hint = inspectedField(text, "hint");
620
+ const param = inspectedField(text, "param");
621
+ return {
622
+ code: known,
623
+ message,
624
+ ...(typeof hint === "string" ? { hint } : {}),
625
+ ...(typeof param === "string" ? { param } : {}),
626
+ status: Number.isInteger(status) ? status : undefined,
627
+ };
628
+ }
629
+
630
+ /** Every error item in whatever shape it arrived: GraphQL, REST, a tool's output or plain text. */
631
+ export function normalizeErrors(input) {
632
+ const value = asObject(input);
633
+ if (typeof value === "string") return [textError(value)];
634
+ if (Array.isArray(value)) return value.flatMap(normalizeErrors);
635
+ if (!value || typeof value !== "object") return [];
636
+ if (Array.isArray(value.errors)) return value.errors.flatMap(normalizeErrors);
637
+ if (value.error && typeof value.error === "object") return normalizeErrors(value.error);
638
+ const ext = value.extensions ?? {};
639
+ const transport = value.code ?? ext.code ? null : transportOf(`${value.message ?? ""} ${value.cause?.code ?? value.cause?.message ?? ""}`);
640
+ if (transport) return [{ transport, message: value.message }];
641
+ return [
642
+ {
643
+ code: value.code ?? ext.code,
644
+ message: value.message,
645
+ hint: value.hint ?? ext.hint,
646
+ param: value.param ?? ext.param,
647
+ path: value.path,
648
+ status: value.status,
649
+ },
650
+ ];
651
+ }
652
+
653
+ /**
654
+ * The fix and the tool for one error: a budget refusal's own numbers, else
655
+ * the concrete fix a validation or coercion message spells out, else the
656
+ * code's general fix. Null for a message and code this guide does not know.
657
+ * The same advice reaches a tool's Next line and capa_explain_error.
658
+ */
659
+ function adviceFor(error) {
660
+ const budget = budgetOf(error.message);
661
+ if (budget) {
662
+ // The API's hint names the exact change when one first fits: its message and that sentence are the fix, as sent.
663
+ const hinted = hintedFix(error.hint);
664
+ return { fix: hinted ? `${String(error.message).trim()} ${hinted.sentence}` : budget.fix, next: budget.next, budget };
665
+ }
666
+ for (const [pattern, advice] of VALIDATION_PATTERNS) {
667
+ const match = pattern.exec(error.message ?? "");
668
+ if (match) return advice(match, error);
669
+ }
670
+ const entry = error.code ? GUIDE[error.code] : undefined;
671
+ if (!entry) return null;
672
+ return { fix: typeof entry.fix === "function" ? entry.fix(error) : entry.fix, next: entry.next };
673
+ }
674
+
675
+ /**
676
+ * What to do about a refused request, one line per distinct fix (at most
677
+ * three), each with the tool that helps, if any: a mutation is not a naming
678
+ * problem, and neither is a budget, whose line carries what the request
679
+ * measured and the limit.
680
+ *
681
+ * Next:
682
+ * - query_too_complex: articles: could read 40,200 entries, over the limit of 5,000. Pass coauthors(first: 24) to fit, or lower first elsewhere. Tool: capa_graphql_build.
683
+ */
684
+ export function nextSteps(errors) {
685
+ const lines = [];
686
+ for (const error of errors) {
687
+ if (!error.code) continue;
688
+ const advice = adviceFor(error);
689
+ const line = advice
690
+ ? `${error.code}: ${advice.fix}${advice.next ? ` Tool: ${advice.next}.` : ""}`
691
+ : `${error.code}: read the message and hint. Tool: capa_explain_error.`;
692
+ if (!lines.includes(line)) lines.push(line);
693
+ if (lines.length === 3) break;
694
+ }
695
+ if (!lines.length) lines.push("Read the message above. Tool: capa_explain_error explains any code.");
696
+ return `Next:\n${lines.map((line) => `- ${line}`).join("\n")}`;
697
+ }
698
+
699
+ /** The one tool to call next for these errors: the first known error's, or capa_explain_error. */
700
+ export function nextTool(errors) {
701
+ for (const error of errors) {
702
+ const advice = adviceFor(error);
703
+ if (advice) return advice.next ?? null;
704
+ }
705
+ return "capa_explain_error";
706
+ }
707
+
708
+ /**
709
+ * A request that never reached Capa: no API answered, so there is no code, no
710
+ * hint and no tool that helps, only the step this server gives for a host it
711
+ * cannot reach (client.mjs).
712
+ */
713
+ function explainTransport(error) {
714
+ const timeout = error.transport === "timeout";
715
+ // Node 18's fetch tried only ::1 for localhost, which an API listening on IPv4 refuses.
716
+ const ipv6 = !timeout && /\[?::1\]?:\d+/.test(error.message ?? "")
717
+ ? " The address tried was ::1, IPv6's localhost: an API listening on IPv4 only answers at http://127.0.0.1:<port>, so use that in CAPA_API_URL, or run Node 20 or later."
718
+ : "";
719
+ const next = timeout ? TIMEOUT_NEXT : UNREACHABLE_NEXT;
720
+ return {
721
+ code: "unreachable",
722
+ transport: error.transport,
723
+ ...(error.message ? { message: error.message } : {}),
724
+ meaning: timeout
725
+ ? "Capa did not answer in time, so no API error code or hint came back."
726
+ : "The request never reached Capa's API, so no error code or hint came back: the connection itself failed.",
727
+ fix: `${next[0].toUpperCase()}${next.slice(1)}${ipv6}`,
728
+ };
729
+ }
730
+
731
+ /** One explained error: the API's own words first, then the guide's, a budget's numbers passed through. */
732
+ export function explainOne(error) {
733
+ if (error.transport) return explainTransport(error);
734
+ const entry = error.code ? GUIDE[error.code] : undefined;
735
+ const advice = adviceFor(error);
736
+ const out = { code: error.code ?? "unknown" };
737
+ if (error.status) out.status = error.status;
738
+ if (error.message) out.message = error.message;
739
+ if (error.param) out.param = error.param;
740
+ if (error.path) out.path = error.path;
741
+ if (error.hint) out.apiHint = error.hint;
742
+ out.meaning = entry?.meaning ?? "Not a code this guide knows. Read message and apiHint; they come from the API.";
743
+ if (advice) out.fix = advice.fix;
744
+ if (advice?.budget) out.budget = { measured: advice.budget.measured, limit: advice.budget.limit };
745
+ // Auth, plan and transport errors have no tool that helps; say nothing then.
746
+ if (advice?.next) out.next = advice.next;
747
+ else if (!advice) out.next = "capa_graphql_schema";
748
+ if (error.code && entry) out.docs = `https://docs.capacms.com/errors/${error.code}`;
749
+ return out;
750
+ }