@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,725 @@
1
+ /**
2
+ * build.mjs — a query spec as GraphQL text and as the equal REST request.
3
+ *
4
+ * The spec is the small JSON `capa_graphql_build` takes ("these fields of
5
+ * articles, newest first, five of them"). It is resolved once against the
6
+ * key's schema summary, then printed twice: GraphQL in the one format every
7
+ * Capa builder emits (G plan 1.3), and the `/api/entries` URL the API itself
8
+ * would translate it to.
9
+ *
10
+ * `@capacms/sdk` has the same builder in TypeScript (`buildGraphQLQuery`,
11
+ * `graphqlToSelect`). This package has no dependencies, so it cannot import
12
+ * it; instead both are tested against one vector file,
13
+ * `packages/sdk/test/fixtures/graphql-vectors.json`, byte for byte.
14
+ */
15
+ import { didYouMean } from "../suggest.mjs";
16
+ import { isObject, typedFilter } from "./filter-values.mjs";
17
+ import { writeName, writePath, writePathSegment } from "./names.mjs";
18
+
19
+ export { didYouMean };
20
+
21
+ export class BuildError extends Error {
22
+ constructor(message, didYouMean = [], available = []) {
23
+ super(message);
24
+ this.name = "BuildError";
25
+ this.didYouMean = didYouMean;
26
+ this.available = available;
27
+ }
28
+ }
29
+
30
+ /**
31
+ * A model the key reads that GraphQL leaves out (N1), named: REST serves it,
32
+ * and no GraphQL tool can. Never a spelling to correct, so no didYouMean.
33
+ */
34
+ export class RestOnlyModel extends BuildError {
35
+ constructor(namespace, restOnly) {
36
+ // N1 leaves a model out when its type name would collide with another's, as
37
+ // these spellings do; for the sentence only, never for a name that is sent.
38
+ const letters = (name) => name.toLowerCase().replace(/[^0-9a-z]/g, "");
39
+ const partners = restOnly.filter((other) => other !== namespace && letters(other) === letters(namespace));
40
+ const named = ["it", ...partners];
41
+ const why = partners.length
42
+ ? `${named.slice(0, -1).join(", ")} and ${named.at(-1)} would have the same GraphQL type name`
43
+ : "its namespace gives no GraphQL type name of its own";
44
+ super(`${namespace} is readable over REST only: GET /api/entries/${namespace}. GraphQL leaves it out, since ${why}.`);
45
+ this.name = "RestOnlyModel";
46
+ this.namespace = namespace;
47
+ }
48
+ }
49
+
50
+ /** N6: the system fields every model type has. */
51
+ export const SYSTEM_FIELDS = ["id", "model", "status", "createdAt", "updatedAt", "publishedAt", "_version", "_tags", "_folder"];
52
+ const MEDIA_FIELDS = ["id", "url", "alt", "type", "width", "height"];
53
+ const MEDIA_DEFAULT = ["id", "url", "alt"];
54
+ /** Relations below the root entry: the API's 5 levels of entries count the root as the first (spec 17, amendment 78). */
55
+ export const MAX_RELATION_DEPTH = 4;
56
+ /** How deep a relation may nest, as every explanation and guide here says it. */
57
+ export const RELATION_DEPTH_RULE = `at most ${MAX_RELATION_DEPTH} relations below the root entry (${MAX_RELATION_DEPTH + 1} levels of entries)`;
58
+ /** The API's page sizes when `first` is not given: a list root, and a relation list inside an entry. */
59
+ const LIST_DEFAULT = 25;
60
+ export const NESTED_DEFAULT = 100;
61
+ /** REST's `before=end`: the last entries of the list, read back from its end (spec 17, amendment 132). */
62
+ export const END_OF_LIST = "end";
63
+ const RELATION_OPERATORS = new Set(["eq", "ne", "in", "nin", "exists", "null", "has", "hasAny", "hasAll"]);
64
+ /**
65
+ * Field kinds whose filter is its own operators plus dotted paths below it (a
66
+ * relation's hops, a media value's id). A relation the key cannot read is one
67
+ * too, so a hop through it prints as REST's dotted path, never a nested object.
68
+ */
69
+ const HOP_KINDS = new Set(["relation", "relationList", "id", "idList", "media"]);
70
+
71
+ /** A model's display name, the one the Capa admin shows: its description's first line without `(model <namespace>)`. */
72
+ export function displayName(model) {
73
+ return model.description.split("\n")[0].replace(/ \(model [^()]+\)$/, "") || model.namespace;
74
+ }
75
+
76
+ /**
77
+ * A model by namespace or type name, then by any of those or its display name
78
+ * ignoring case (`Writers`, `scalar specimens`). An unknown name is refused
79
+ * with the nearest models, each by namespace, whichever of its names was near.
80
+ */
81
+ export function findModel(summary, wanted) {
82
+ const exact = summary.models.find((m) => m.namespace === wanted || m.typeName === wanted);
83
+ if (exact) return exact;
84
+ const restOnly = summary.restOnly ?? [];
85
+ if (restOnly.includes(wanted)) throw new RestOnlyModel(wanted, restOnly);
86
+ const lower = String(wanted).toLowerCase();
87
+ const loose = summary.models.filter((m) => [m.namespace, m.typeName, displayName(m)].some((name) => name.toLowerCase() === lower));
88
+ if (loose.length === 1) return loose[0];
89
+ const looseRestOnly = restOnly.filter((name) => name.toLowerCase() === lower);
90
+ if (!loose.length && looseRestOnly.length === 1) throw new RestOnlyModel(looseRestOnly[0], restOnly);
91
+ const namespaces = summary.models.map((m) => m.namespace);
92
+ if (loose.length > 1) {
93
+ throw new BuildError(`model ${wanted} is ambiguous: pass one of ${orList(loose.map((m) => m.namespace))}`, loose.map((m) => m.namespace), namespaces);
94
+ }
95
+ const namespaceOf = new Map(summary.models.flatMap((m) => [m.namespace, m.typeName, displayName(m)].map((name) => [name, m.namespace])));
96
+ const near = [...new Set(didYouMean(wanted, [...namespaceOf.keys()]).map((name) => namespaceOf.get(name)))];
97
+ throw new BuildError(`unknown model ${wanted}`, near, namespaces);
98
+ }
99
+
100
+ const targetOf = (summary, field) => summary.models.find((m) => m.namespace === field?.target);
101
+ const selectableNames = (model) => [...SYSTEM_FIELDS, ...model.fields.map((f) => f.name)];
102
+
103
+ /** By GraphQL name, then a system field, then by namespace: `createdAt` is the system field even beside a `createdAt_field`. */
104
+ function findField(model, wanted) {
105
+ const byName = model.fields.find((f) => f.name === wanted);
106
+ if (byName) return byName;
107
+ if (SYSTEM_FIELDS.includes(wanted)) return null;
108
+ const byNamespace = model.fields.find((f) => f.namespace === wanted);
109
+ if (byNamespace) return byNamespace;
110
+ throw new BuildError(`unknown field ${wanted} on ${model.typeName}`, didYouMean(wanted, selectableNames(model)), selectableNames(model));
111
+ }
112
+
113
+ function checkFirst(value, where) {
114
+ if (!Number.isInteger(value) || value < 1 || value > 200) throw new BuildError(`${where} must be a whole number from 1 to 200`);
115
+ return value;
116
+ }
117
+
118
+ function checkSort(value, model, where) {
119
+ if (typeof value !== "string" || !model.sortValues.includes(value)) {
120
+ const written = typeof value === "string" ? restSortValue(value) : null;
121
+ if (written && model.sortValues.includes(written)) {
122
+ throw new BuildError(`${where} ${JSON.stringify(value)} is REST's sort syntax: write ${JSON.stringify(written)}`, [written]);
123
+ }
124
+ throw new BuildError(`${where} ${JSON.stringify(value)} is not a value of ${model.sortType}`, didYouMean(String(value), model.sortValues), model.sortValues);
125
+ }
126
+ return value;
127
+ }
128
+
129
+ // ------------------------------------------------------------ REST forms ---
130
+ //
131
+ // An agent that has read a REST twin writes REST: `author.name` or
132
+ // `author(name)` for a relation's fields, `*` for every field, `-publishedAt`
133
+ // for a sort. Each is refused with what to write instead, spelled out, since
134
+ // the same read is one edit away. @capacms/sdk refuses them in the same
135
+ // words, pinned by packages/sdk/test/fixtures/graphql-rest-forms.json.
136
+
137
+ /** A REST sort key as a sort value: `-publishedAt` is `publishedAt_DESC`, `author.name` is `author__name_ASC`. */
138
+ function restSortValue(value) {
139
+ const match = /^(-?)([^\s-][^\s]*)$/.exec(value);
140
+ if (!match || /_(ASC|DESC)$/.test(match[2])) return null;
141
+ return `${match[2].split(".").join("__")}_${match[1] ? "DESC" : "ASC"}`;
142
+ }
143
+
144
+ /** Split REST select text on the commas outside parentheses; null when the parentheses do not pair. */
145
+ function restItems(text) {
146
+ const items = [];
147
+ let depth = 0;
148
+ let start = 0;
149
+ for (let at = 0; at < text.length; at++) {
150
+ if (text[at] === "(") depth++;
151
+ else if (text[at] === ")" && --depth < 0) return null;
152
+ else if (text[at] === "," && depth === 0) {
153
+ items.push(text.slice(start, at).trim());
154
+ start = at + 1;
155
+ }
156
+ }
157
+ if (depth !== 0) return null;
158
+ items.push(text.slice(start).trim());
159
+ return items.filter(Boolean);
160
+ }
161
+
162
+ /** One REST select item as a field of a spec: `author(name,limit:5)` is `{ field: "author", fields: ["name"], first: 5 }`. */
163
+ function restItemSpec(text) {
164
+ const open = text.indexOf("(");
165
+ const dot = text.indexOf(".");
166
+ if (open === -1 && dot === -1) return text;
167
+ if (open === -1 || (dot !== -1 && dot < open)) return { field: text.slice(0, dot), fields: [restItemSpec(text.slice(dot + 1))] };
168
+ if (!text.endsWith(")")) return null;
169
+ const inner = restItems(text.slice(open + 1, -1));
170
+ if (!inner) return null;
171
+ const spec = { field: text.slice(0, open) };
172
+ const fields = [];
173
+ const args = {};
174
+ for (const item of inner) {
175
+ const modifier = /^(limit|sort|after):(.+)$/.exec(item);
176
+ if (!modifier) {
177
+ const nested = restItemSpec(item);
178
+ if (nested === null) return null;
179
+ fields.push(nested);
180
+ } else if (modifier[1] === "limit") args.first = Number(modifier[2]);
181
+ else if (modifier[1] === "sort") args.sort = restSortValue(modifier[2]) ?? modifier[2];
182
+ else args.after = modifier[2];
183
+ }
184
+ if (fields.length) spec.fields = fields;
185
+ return { ...spec, ...args };
186
+ }
187
+
188
+ /**
189
+ * The refusal for a field item written as REST writes a select, or null when
190
+ * `wanted` is not one. A dotted or parenthesised item counts only when what
191
+ * comes first is a field of `model`: a namespace may itself hold a dot.
192
+ */
193
+ function restFormRefusal(model, wanted, at) {
194
+ if (wanted === "*") {
195
+ return new BuildError(`${at} "*" is REST's select syntax: leave fields out for every field that is not a relation, or name the fields you want`);
196
+ }
197
+ const head = /^[^.(]+/.exec(wanted)?.[0];
198
+ if (!head || head === wanted || !model.fields.some((f) => f.name === head || f.namespace === head)) return null;
199
+ const spec = restItemSpec(wanted);
200
+ if (spec === null || typeof spec === "string") return null;
201
+ return new BuildError(`${at} ${JSON.stringify(wanted)} is REST's select syntax: write ${JSON.stringify(spec)}`);
202
+ }
203
+
204
+ /**
205
+ * A relation list's sort: one value, as its argument takes (N7). A list of
206
+ * one, the root's shape, is that value; a longer list is refused, since the
207
+ * API sorts a relation list by one key.
208
+ */
209
+ function oneSort(value, name) {
210
+ if (!Array.isArray(value)) return value;
211
+ if (value.length === 1) return value[0];
212
+ throw new BuildError(`sort on ${name} takes one value: a relation list sorts by one key, unlike the root's up to 3`);
213
+ }
214
+
215
+ function mediaChildren(fields, owner) {
216
+ const planned = [{ name: "id", field: null }];
217
+ for (const item of fields ?? MEDIA_DEFAULT) {
218
+ if (typeof item !== "string" || !MEDIA_FIELDS.includes(item)) {
219
+ const name = typeof item === "string" ? item : item?.field;
220
+ throw new BuildError(`unknown field ${name} on Media (${owner})`, didYouMean(name, MEDIA_FIELDS), MEDIA_FIELDS);
221
+ }
222
+ if (item !== "id") planned.push({ name: item, field: null });
223
+ }
224
+ return planned;
225
+ }
226
+
227
+ /** Every current non-relation field, single relations as `{ id }`, media as `{ id url alt }`; array relations and deprecated fields left out. */
228
+ function defaultFields(model) {
229
+ return model.fields.filter((f) => f.kind !== "relationList" && !f.deprecationReason).map((f) => f.name);
230
+ }
231
+
232
+ /** The keys a relation spec (`{ field, fields, first, sort }`) takes. */
233
+ const FIELD_SPEC_KEYS = ["field", "fields", "first", "sort"];
234
+
235
+ /**
236
+ * A key the builder does not read is refused by name, at any depth: a typo
237
+ * dropped silently (`frist`) builds a query without the argument and reports
238
+ * that it worked.
239
+ */
240
+ function checkSpecKeys(spec, path) {
241
+ for (const key of Object.keys(spec)) {
242
+ if (FIELD_SPEC_KEYS.includes(key)) continue;
243
+ const near = didYouMean(key, FIELD_SPEC_KEYS);
244
+ throw new BuildError(
245
+ `Unknown argument ${key} in ${path}.${near.length ? ` Did you mean ${near.join(" or ")}?` : ""} Allowed: ${FIELD_SPEC_KEYS.join(", ")}.`,
246
+ near,
247
+ );
248
+ }
249
+ }
250
+
251
+ /** `path` names where `specs` sit in the spec, `fields[1].fields`, for a refusal. */
252
+ function planFields(summary, model, specs, depth, path = "fields") {
253
+ if (specs !== undefined && !Array.isArray(specs)) throw new BuildError(`fields of ${model.typeName} must be an array`);
254
+ const planned = [{ name: "id", field: null }];
255
+ const seen = new Set(["id"]);
256
+ for (const [index, spec] of (specs ?? defaultFields(model)).entries()) {
257
+ const nested = typeof spec === "object" && spec !== null;
258
+ if (nested) checkSpecKeys(spec, `${path}[${index}]`);
259
+ const wanted = nested ? spec.field : spec;
260
+ if (typeof wanted !== "string" || wanted === "") {
261
+ throw new BuildError(`each field of ${model.typeName} must be a name or { field, fields }`);
262
+ }
263
+ let field;
264
+ try {
265
+ field = findField(model, wanted);
266
+ } catch (error) {
267
+ throw (!nested && restFormRefusal(model, wanted, `${path}[${index}]`)) || error;
268
+ }
269
+ const name = field?.name ?? wanted;
270
+ if (seen.has(name)) continue;
271
+ seen.add(name);
272
+ const kind = field?.kind ?? "scalar";
273
+ if (nested && (spec.first !== undefined || spec.sort !== undefined) && kind !== "relationList") {
274
+ throw new BuildError(`first and sort apply to array relations; ${name} on ${model.typeName} is not one`);
275
+ }
276
+ if (kind === "relation" || kind === "relationList") {
277
+ const target = targetOf(summary, field);
278
+ if (!target) throw new BuildError(`${name} on ${model.typeName} points at a model this key cannot read`);
279
+ if (depth >= MAX_RELATION_DEPTH) throw new BuildError(`${name} would expand past ${MAX_RELATION_DEPTH} relation levels. Select ${name} without fields for its id, or read it with a second query`);
280
+ const children = planFields(summary, target, nested ? spec.fields ?? ["id"] : ["id"], depth + 1, `${path}[${index}].fields`);
281
+ const entry = { name, field, target, children };
282
+ if (nested && spec.first !== undefined) entry.first = checkFirst(spec.first, `first on ${name}`);
283
+ if (nested && spec.sort !== undefined) entry.sort = checkSort(oneSort(spec.sort, name), target, `sort on ${name}`);
284
+ planned.push(entry);
285
+ } else if (kind === "media") {
286
+ planned.push({ name, field, children: mediaChildren(nested ? spec.fields : undefined, name) });
287
+ } else {
288
+ if (nested && spec.fields !== undefined) {
289
+ throw new BuildError(
290
+ kind === "id" || kind === "idList"
291
+ ? `${name} on ${model.typeName} is an id here: this key cannot read the model it points at. Select ${name} without fields for the id`
292
+ : `${name} on ${model.typeName} has no fields to select`,
293
+ );
294
+ }
295
+ planned.push({ name, field });
296
+ }
297
+ }
298
+ return planned;
299
+ }
300
+
301
+ /** The system fields `model`'s filter takes, as the schema declares them. */
302
+ const systemFilterNames = (model) => model.systemFilters.map((system) => system.name);
303
+
304
+ /** Every name a filter object on `model` may start with. */
305
+ export function filterableNames(model) {
306
+ return ["and", "or", "not", ...systemFilterNames(model), ...model.fields.filter((f) => f.filterOps.length > 0).map((f) => f.name)];
307
+ }
308
+
309
+ /** `a, b and c`, `a, b or c`: names in a sentence. */
310
+ const andList = (names) => `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
311
+ const orList = (names) => `${names.slice(0, -1).join(", ")} or ${names.at(-1)}`;
312
+
313
+ /** GraphQL scalars an operator takes; any other input type under a field's filter is a hop into a related field. */
314
+ const OPERAND_SCALARS = new Set(["String", "ID", "Float", "Int", "Boolean", "DateTime", "JSON"]);
315
+ /** The operators of a system field reached through a hop (`author: { id: { eq } }`), as the API's IDFilter and DateTimeFilter declare them. */
316
+ const SYSTEM_HOP_OPERATORS = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "nin", "exists", "null"];
317
+
318
+ /** Whether `key` under `field`'s filter is a hop into a field of the related model rather than an operator. */
319
+ export function isHop(field, key) {
320
+ const type = field.filterInputs?.[key];
321
+ return type !== undefined && !OPERAND_SCALARS.has(type);
322
+ }
323
+
324
+ /**
325
+ * The operator a bare value stands for under `operators`, and the value it
326
+ * takes: equality for one value, membership for a list, `null: true` for null.
327
+ */
328
+ function operatorFor(operators, value) {
329
+ if (value === null) return operators.includes("null") ? ["null", true] : null;
330
+ if (Array.isArray(value)) {
331
+ const op = ["in", "hasAny"].find((name) => operators.includes(name));
332
+ return op ? [op, value] : null;
333
+ }
334
+ const op = ["eq", "has", "id"].find((name) => operators.includes(name));
335
+ return op ? [op, value] : null;
336
+ }
337
+
338
+ /**
339
+ * A filter written without operators (`{ "featured": true }`) is refused with
340
+ * the operator form to write instead: GraphQL declares an object of operators
341
+ * there, so the shorthand would fail on the API and has no REST twin.
342
+ */
343
+ function refuseBareValue(label, operators, value, wrap) {
344
+ const operator = operatorFor(operators, value);
345
+ const fix = operator ? ` write ${JSON.stringify(wrap({ [operator[0]]: operator[1] }))}` : ` use one of ${orList(operators)}`;
346
+ throw new BuildError(`${label} takes operators, not a bare value:${fix}`, [], operators);
347
+ }
348
+
349
+ /**
350
+ * The keys under one field's filter: the input fields its filter type
351
+ * declares, each an operator or, on a relation, a hop into a field of the
352
+ * related model, which takes operators of its own. A relation the key cannot
353
+ * read is an id with no hop, refused without naming the model it points at.
354
+ */
355
+ function checkFieldFilter(summary, model, field, operations) {
356
+ if (!isObject(operations)) refuseBareValue(field.name, field.filterOps, operations, (ops) => ({ [field.name]: ops }));
357
+ for (const [key, inner] of Object.entries(operations)) {
358
+ if (!field.filterOps.includes(key)) {
359
+ if (field.kind === "id" || field.kind === "idList") {
360
+ throw new BuildError(
361
+ `${field.name} on ${model.typeName} is an id here: this key cannot read the model it points at, so a filter cannot reach ${key} through it. Filter ${field.name} by id with ${orList(field.filterOps)}`,
362
+ [],
363
+ field.filterOps,
364
+ );
365
+ }
366
+ throw new BuildError(`unknown filter ${key} under ${field.name} on ${model.filterType}`, didYouMean(key, field.filterOps), field.filterOps);
367
+ }
368
+ if (!isHop(field, key)) continue;
369
+ const hop = targetOf(summary, field)?.fields.find((f) => f.name === key);
370
+ const operators = hop ? hop.filterOps : SYSTEM_HOP_OPERATORS;
371
+ const wrap = (ops) => ({ [field.name]: { [key]: ops } });
372
+ if (!isObject(inner)) refuseBareValue(`${field.name}.${key}`, operators, inner, wrap);
373
+ for (const op of Object.keys(inner)) {
374
+ if (!operators.includes(op)) {
375
+ throw new BuildError(`unknown filter ${op} under ${field.name}.${key} on ${model.filterType}`, didYouMean(op, operators), operators);
376
+ }
377
+ }
378
+ }
379
+ }
380
+
381
+ /** A filter's names and shapes, through and, or and not. The system fields it takes are the ones the schema declares. */
382
+ function checkFilter(summary, model, filter) {
383
+ if (!isObject(filter)) throw new BuildError(`filter must be an object of ${model.filterType}`);
384
+ const allowed = filterableNames(model);
385
+ for (const [key, value] of Object.entries(filter)) {
386
+ if (key === "and" || key === "or") {
387
+ for (const part of Array.isArray(value) ? value : [value]) checkFilter(summary, model, part);
388
+ continue;
389
+ }
390
+ if (key === "not") {
391
+ checkFilter(summary, model, value);
392
+ continue;
393
+ }
394
+ if (!allowed.includes(key)) {
395
+ if (SYSTEM_FIELDS.includes(key)) {
396
+ throw new BuildError(
397
+ `${key} cannot be filtered: ${model.filterType} filters the system fields ${andList(systemFilterNames(model))} only`,
398
+ [],
399
+ allowed,
400
+ );
401
+ }
402
+ throw new BuildError(`unknown filter field ${key} on ${model.filterType}`, didYouMean(key, allowed), allowed);
403
+ }
404
+ const field = model.fields.find((f) => f.name === key);
405
+ const system = model.systemFilters.find((f) => f.name === key);
406
+ if (field) checkFieldFilter(summary, model, field, value);
407
+ else if (system && !isObject(value)) refuseBareValue(key, system.filterOps, value, (ops) => ({ [key]: ops }));
408
+ }
409
+ return filter;
410
+ }
411
+
412
+ /** Resolve and check a spec. Throws `BuildError`. */
413
+ export function planQuery(summary, spec) {
414
+ if (!spec || typeof spec !== "object") throw new BuildError("a query spec must be an object with model");
415
+ const model = findModel(summary, spec.model);
416
+ // An id reads that one entry: it is never dropped into a list of every entry.
417
+ const mode = spec.mode ?? (spec.id !== undefined ? "single" : "list");
418
+ if (mode !== "list" && mode !== "single") throw new BuildError(`mode must be list or single, not ${String(mode)}`);
419
+ if (mode === "list" && spec.id !== undefined) {
420
+ throw new BuildError('id reads one entry, so it takes mode single: pass mode "single", or leave id out to list entries');
421
+ }
422
+ if (mode === "single") {
423
+ if (typeof spec.id !== "string" || spec.id === "") throw new BuildError("mode single needs id, the entry's UUID");
424
+ for (const key of ["first", "after", "before", "sort", "filter", "totalCount"]) {
425
+ if (spec[key] !== undefined) throw new BuildError(`${key} applies to mode list only`);
426
+ }
427
+ }
428
+ if (spec.after !== undefined && spec.before !== undefined) {
429
+ throw new BuildError("after and before page in opposite directions: pass after to read on, or before to read back, not both");
430
+ }
431
+ if (spec.after !== undefined && String(spec.after) === END_OF_LIST) {
432
+ throw new BuildError('after takes an endCursor, and "end" is none: pass before: "end" to read the last entries of the list');
433
+ }
434
+ const plan = {
435
+ model,
436
+ mode,
437
+ fields: planFields(summary, model, spec.fields, 0),
438
+ totalCount: spec.totalCount === true,
439
+ operationName: spec.operationName ?? `${model.typeName}${mode === "single" ? "ById" : "List"}`,
440
+ };
441
+ if (mode === "single") plan.id = spec.id;
442
+ if (spec.first !== undefined) plan.first = checkFirst(spec.first, "first");
443
+ if (spec.after !== undefined) plan.after = String(spec.after);
444
+ if (spec.before !== undefined) plan.before = String(spec.before);
445
+ if (spec.sort !== undefined) {
446
+ // One value may be given alone, as a relation list's sort is.
447
+ const sort = typeof spec.sort === "string" ? [spec.sort] : spec.sort;
448
+ if (!Array.isArray(sort) || sort.length === 0 || sort.length > 3) {
449
+ throw new BuildError(`sort takes 1 to 3 values of ${model.sortType}`, [], model.sortValues);
450
+ }
451
+ plan.sort = sort.map((value) => checkSort(value, model, "sort"));
452
+ }
453
+ if (spec.filter !== undefined) plan.filter = typedFilter(summary, model, checkFilter(summary, model, spec.filter));
454
+ return plan;
455
+ }
456
+
457
+ /**
458
+ * The most entries one entry of `fields` can read: itself, and for each
459
+ * expanded relation its limit (1 for a single relation; `first`, default 100,
460
+ * for a list) times what each of its entries can read. A list root reads
461
+ * `first` times this. It is never less than the API's pre-SQL bound, which
462
+ * counts a list with no `first` as 10 (spec 17, amendment 76), so a `first`
463
+ * that fits it fits both that bound and what the read returns.
464
+ */
465
+ export function entriesPerEntry(fields) {
466
+ let total = 1;
467
+ for (const field of fields) {
468
+ if (!field.target) continue;
469
+ const limit = field.field?.kind === "relationList" ? field.first ?? NESTED_DEFAULT : 1;
470
+ total += limit * entriesPerEntry(field.children);
471
+ }
472
+ return total;
473
+ }
474
+
475
+ /**
476
+ * The largest `first` that keeps a list plan inside `limit` entries: 0 when
477
+ * even one entry reads more (its relation lists are then what to lower), and
478
+ * null when the plan's own `first` already fits.
479
+ */
480
+ export function firstWithin(plan, limit) {
481
+ const first = Math.floor(limit / entriesPerEntry(plan.fields));
482
+ return first >= (plan.first ?? LIST_DEFAULT) ? null : first;
483
+ }
484
+
485
+ // ----------------------------------------------------------------- GraphQL ---
486
+
487
+ const INDENT = " ";
488
+
489
+ /**
490
+ * `pages` also selects each relation list's `pageInfo`, and `cursors` its
491
+ * entries' cursors beside it: what a tool that runs the query reads to say
492
+ * which lists hold more than they show. A planned `after` on a relation list
493
+ * (never from a spec) reads on from a cursor.
494
+ */
495
+ function printFields(fields, depth, pages = { pageInfo: false, cursors: false }) {
496
+ const pad = INDENT.repeat(depth);
497
+ const inner = pad + INDENT;
498
+ const lines = [];
499
+ for (const field of fields) {
500
+ if (!field.children) {
501
+ lines.push(`${pad}${field.name}`);
502
+ } else if (field.field?.kind === "relationList") {
503
+ const args = [];
504
+ if (field.first !== undefined) args.push(`first: ${field.first}`);
505
+ if (field.sort !== undefined) args.push(`sort: ${field.sort}`);
506
+ if (field.after !== undefined) args.push(`after: ${JSON.stringify(field.after)}`);
507
+ lines.push(`${pad}${field.name}${args.length ? `(${args.join(", ")})` : ""} {`, `${inner}nodes {`);
508
+ lines.push(...printFields(field.children, depth + 2, pages), `${inner}}`);
509
+ if (pages.cursors) lines.push(`${inner}edges {`, `${inner}${INDENT}cursor`, `${inner}}`);
510
+ if (pages.pageInfo) lines.push(`${inner}pageInfo {`, `${inner}${INDENT}hasNextPage`, `${inner}${INDENT}endCursor`, `${inner}}`);
511
+ lines.push(`${pad}}`);
512
+ } else {
513
+ lines.push(`${pad}${field.name} {`, ...printFields(field.children, depth + 1, pages), `${pad}}`);
514
+ }
515
+ }
516
+ return lines;
517
+ }
518
+
519
+ /**
520
+ * `{ query, variables, operationName }` for a checked plan, in plan 1.3's
521
+ * format. `cursors` also selects each entry's cursor (`edges { cursor }`),
522
+ * in the root list and in every relation list, for a caller that may cut a
523
+ * list and must then hand back the cursor of the last entry kept; it selects
524
+ * each relation list's `pageInfo` too, as `pages` does alone.
525
+ */
526
+ export function printGraphQL(plan, { cursors = false, pages = cursors } = {}) {
527
+ const nested = { pageInfo: pages || cursors, cursors };
528
+ const { model } = plan;
529
+ const variables = {};
530
+ const declared = [];
531
+ const passed = [];
532
+ const argument = (name, type, value) => {
533
+ if (value === undefined) return;
534
+ variables[name] = value;
535
+ declared.push(`$${name}: ${type}`);
536
+ passed.push(`${name}: $${name}`);
537
+ };
538
+ argument("id", "ID!", plan.id);
539
+ // REST's limit beside before is GraphQL's last beside before, the entries
540
+ // just before the cursor; the API refuses first there (spec 17, amendment 55).
541
+ // REST's before=end is last with no before, the end of the list (amendment 132).
542
+ const fromEnd = plan.before === END_OF_LIST;
543
+ argument(plan.before === undefined ? "first" : "last", "Int", fromEnd ? plan.first ?? LIST_DEFAULT : plan.first);
544
+ argument("after", "String", plan.after);
545
+ if (!fromEnd) argument("before", "String", plan.before);
546
+ argument("sort", `[${model.sortType}!]`, plan.sort);
547
+ argument("filter", model.filterType, plan.filter);
548
+ const root = plan.mode === "single" ? model.singleField : model.listField;
549
+ const lines = [`query ${plan.operationName}${declared.length ? `(${declared.join(", ")})` : ""} {`];
550
+ lines.push(`${INDENT}${root}${passed.length ? `(${passed.join(", ")})` : ""} {`);
551
+ if (plan.mode === "single") {
552
+ lines.push(...printFields(plan.fields, 2, nested));
553
+ } else {
554
+ const two = INDENT.repeat(2);
555
+ lines.push(`${two}nodes {`, ...printFields(plan.fields, 3, nested), `${two}}`);
556
+ if (cursors) lines.push(`${two}edges {`, `${two}${INDENT}cursor`, `${two}}`);
557
+ // A page read backward pages on back from its startCursor, as REST's page.prev.
558
+ const back = plan.before === undefined ? [] : [`${two}${INDENT}hasPreviousPage`, `${two}${INDENT}startCursor`];
559
+ lines.push(`${two}pageInfo {`, `${two}${INDENT}hasNextPage`, `${two}${INDENT}endCursor`, ...back, `${two}}`);
560
+ if (plan.totalCount) lines.push(`${two}totalCount`);
561
+ }
562
+ lines.push(`${INDENT}}`, "}");
563
+ return { query: lines.join("\n"), variables, operationName: plan.operationName };
564
+ }
565
+
566
+ export function buildGraphQLQuery(summary, spec) {
567
+ return printGraphQL(planQuery(summary, spec));
568
+ }
569
+
570
+ // -------------------------------------------------------------------- REST ---
571
+ //
572
+ // The REST twin is written exactly as the API writes it in
573
+ // `extensions.capa.rest`, so an agent sees one REST syntax everywhere: the
574
+ // filter as `where` JSON, keys in the filter input's declared order, the
575
+ // root's default page size left out, a relation list's `first` written as
576
+ // `limit:` whenever the spec gives it (REST's node bound counts an unwritten
577
+ // limit as 10 and a written one in full, spec 17 amendment 76), a system
578
+ // key a field shadows as `$tags`, and a namespace holding a character REST's
579
+ // grammars use quoted (`"price.usd"`, names.mjs, amendment 121).
580
+ // The vector file pins it to a live capture.
581
+
582
+ /** System fields a REST `select` names, in the API's order, with their REST keys. `id`, `model`, `status` always come back. */
583
+ const REST_SYSTEM_SELECT = [
584
+ ["createdAt", "createdAt"],
585
+ ["updatedAt", "updatedAt"],
586
+ ["publishedAt", "publishedAt"],
587
+ ["_version", "version"],
588
+ ["_folder", "folder"],
589
+ ["_tags", "tags"],
590
+ ];
591
+ /** GraphQL system field names to REST system keys, where the two differ. */
592
+ const REST_SYSTEM_KEY = { _version: "version", _folder: "folder", _tags: "tags" };
593
+ /**
594
+ * Whether a field of `model` takes the REST name `namespace`: one GraphQL
595
+ * exposes, or one N5 left out (the type description's `Not exposed:` line),
596
+ * which REST still reads by that name.
597
+ */
598
+ const hasField = (model, namespace) => model.fields.some((f) => f.namespace === namespace) || model.notExposed.includes(namespace);
599
+ /**
600
+ * REST reads a plain name as a model field first, so a system key a field
601
+ * shadows is written `$tags` (`$createdAt`, `author.$id`), as the API writes
602
+ * it; plain wherever no field takes the name.
603
+ */
604
+ const systemKeyName = (model, key) => (hasField(model, key) ? `$${key}` : key);
605
+ /** Every filter operator, in the order the API's filter input types declare them. */
606
+ const OPERATOR_ORDER = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "nin", "contains", "startsWith", "endsWith", "has", "hasAny", "hasAll", "exists", "null", "id"];
607
+
608
+ /** `keys` in `order` first, then the rest as they came. */
609
+ function ordered(keys, order) {
610
+ const rank = (key) => (order.indexOf(key) === -1 ? order.length : order.indexOf(key));
611
+ return keys.map((key, i) => ({ key, i })).sort((a, b) => rank(a.key) - rank(b.key) || a.i - b.i).map((k) => k.key);
612
+ }
613
+
614
+ function restSort(summary, model, value) {
615
+ const match = /^(.*)_(ASC|DESC)$/.exec(value);
616
+ const [relationName, targetName] = match[1].split("__");
617
+ const direction = match[2] === "DESC" ? "-" : "";
618
+ const field = model.fields.find((f) => f.name === relationName);
619
+ if (targetName === undefined) return `${direction}${field ? writeName(field.namespace) : systemKeyName(model, relationName)}`;
620
+ const targetField = targetOf(summary, field)?.fields.find((f) => f.name === targetName);
621
+ return `${direction}${writePath([field?.namespace ?? relationName, targetField?.namespace ?? targetName])}`;
622
+ }
623
+
624
+ /**
625
+ * One level of `select`: system keys (`$tags` where a field is called `tags`),
626
+ * then fields, each namespace bare or quoted as REST writes it, a relation
627
+ * always with parentheses; a level that selects nothing else is `id` (`$id`
628
+ * beside a field called id).
629
+ */
630
+ function restSelectText(summary, model, fields) {
631
+ const system = new Set(fields.filter((f) => !f.field).map((f) => f.name));
632
+ const items = REST_SYSTEM_SELECT.filter(([name]) => system.has(name)).map(([, key]) => systemKeyName(model, key));
633
+ for (const planned of fields) {
634
+ if (!planned.field) continue;
635
+ const namespace = writeName(planned.field.namespace);
636
+ if (!planned.target) {
637
+ items.push(namespace);
638
+ continue;
639
+ }
640
+ const args = [restSelectText(summary, planned.target, planned.children)];
641
+ if (planned.first !== undefined) args.push(`limit:${planned.first}`);
642
+ if (planned.sort !== undefined) args.push(`sort:${restSort(summary, planned.target, planned.sort)}`);
643
+ items.push(`${namespace}(${args.join(",")})`);
644
+ }
645
+ return items.length ? items.join(",") : systemKeyName(model, "id");
646
+ }
647
+
648
+ /** An operator object without null members, in operator order; null when empty. */
649
+ function restOperations(value, order = OPERATOR_ORDER) {
650
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
651
+ const out = {};
652
+ for (const key of ordered(Object.keys(value), order)) if (value[key] !== null && value[key] !== undefined) out[key] = value[key];
653
+ return Object.keys(out).length ? out : null;
654
+ }
655
+
656
+ /** A GraphQL filter as the REST `where` object, key for key as the API prints it. */
657
+ function restWhere(summary, model, filter) {
658
+ const where = {};
659
+ const order = ["and", "or", "not", ...systemFilterNames(model), ...model.fields.map((f) => f.name)];
660
+ for (const key of ordered(Object.keys(filter), order)) {
661
+ const value = filter[key];
662
+ if (value === null || value === undefined) continue;
663
+ if (key === "and" || key === "or") {
664
+ where[key] = (Array.isArray(value) ? value : [value]).map((part) => restWhere(summary, model, part));
665
+ continue;
666
+ }
667
+ if (key === "not") {
668
+ const inner = restWhere(summary, model, value);
669
+ if (Object.keys(inner).length) where.not = inner;
670
+ continue;
671
+ }
672
+ const field = model.fields.find((f) => f.name === key);
673
+ const declared = (field ?? model.systemFilters.find((f) => f.name === key))?.filterOps;
674
+ const operations = restOperations(value, declared?.length ? declared : OPERATOR_ORDER);
675
+ if (!operations) continue;
676
+ if (!field) {
677
+ // A system field: `_tags` is REST's `tags`, or `$tags` beside a field called tags (amendment 56).
678
+ where[systemKeyName(model, REST_SYSTEM_KEY[key] ?? key)] = operations;
679
+ continue;
680
+ }
681
+ const written = writeName(field.namespace);
682
+ if (!HOP_KINDS.has(field.kind)) {
683
+ where[written] = operations;
684
+ continue;
685
+ }
686
+ const target = targetOf(summary, field);
687
+ const own = {};
688
+ const hops = [];
689
+ for (const [name, inner] of Object.entries(operations)) {
690
+ if (field.kind === "media" && name === "id") hops.push([`${written}.id`, { eq: inner }]);
691
+ else if (field.kind === "media" || RELATION_OPERATORS.has(name)) own[name] = inner;
692
+ else {
693
+ const hop = restOperations(inner);
694
+ const segment = target ? target.fields.find((f) => f.name === name)?.namespace ?? systemKeyName(target, name) : name;
695
+ if (hop) hops.push([`${written}.${writePathSegment(segment)}`, hop]);
696
+ }
697
+ }
698
+ if (Object.keys(own).length) where[written] = own;
699
+ for (const [path, hop] of hops) where[path] = hop;
700
+ }
701
+ return where;
702
+ }
703
+
704
+ const encode = (value) => encodeURIComponent(value).replace(/%2C/g, ",").replace(/%3A/g, ":").replace(/%24/g, "$");
705
+
706
+ /** `{ path, select, params, url }`: the `/api/entries` request a plan is equal to, as the API prints it. */
707
+ export function printRest(summary, plan) {
708
+ const { model } = plan;
709
+ const select = restSelectText(summary, model, plan.fields);
710
+ const params = [["select", select]];
711
+ if (plan.filter) {
712
+ const where = restWhere(summary, model, plan.filter);
713
+ if (Object.keys(where).length) params.push(["where", JSON.stringify(where)]);
714
+ }
715
+ if (plan.sort) params.push(["sort", plan.sort.map((value) => restSort(summary, model, value)).join(",")]);
716
+ if (plan.first !== undefined && plan.first !== LIST_DEFAULT) params.push(["limit", String(plan.first)]);
717
+ if (plan.after !== undefined) params.push(["after", plan.after]);
718
+ if (plan.before !== undefined) params.push(["before", plan.before]);
719
+ if (plan.totalCount) params.push(["count", "true"]);
720
+ const path =
721
+ plan.mode === "single"
722
+ ? `/api/entries/${encodeURIComponent(model.namespace)}/${encodeURIComponent(plan.id)}`
723
+ : `/api/entries/${encodeURIComponent(model.namespace)}`;
724
+ return { path, select, params, url: `${path}?${params.map(([k, v]) => `${k}=${encode(v)}`).join("&")}` };
725
+ }