@capacms/sdk 1.0.0-next.1 → 1.0.0-next.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +450 -0
- package/README.md +1698 -193
- package/bin/capa-codegen.js +192 -5
- package/bin/capa.js +235 -0
- package/bin/graphql-project.js +142 -0
- package/bin/project-env.js +58 -0
- package/dist/client.d.ts +5 -0
- package/dist/client.js +17 -0
- package/dist/codegen.d.ts +55 -0
- package/dist/codegen.js +320 -39
- package/dist/config.d.ts +5 -36
- package/dist/config.js +47 -1
- package/dist/esm/image/index.d.ts +120 -0
- package/dist/esm/image/index.js +250 -0
- package/dist/esm/image/shared-params.generated.d.ts +190 -0
- package/dist/esm/image/shared-params.generated.js +461 -0
- package/dist/esm/nextjs/image-loader.d.ts +60 -0
- package/dist/esm/nextjs/image-loader.js +67 -0
- package/dist/esm/nextjs/overlay.d.ts +30 -0
- package/dist/esm/nextjs/overlay.js +75 -0
- package/dist/esm/overlay/index.d.ts +32 -0
- package/dist/esm/overlay/index.js +576 -0
- package/dist/esm/overlay/protocol.d.ts +187 -0
- package/dist/esm/overlay/protocol.js +240 -0
- package/dist/esm/package.json +4 -0
- package/dist/graphql-codegen.d.ts +117 -0
- package/dist/graphql-codegen.js +705 -0
- package/dist/http.js +1 -1
- package/dist/image/index.d.ts +120 -0
- package/dist/image/index.js +257 -0
- package/dist/image/shared-params.generated.d.ts +190 -0
- package/dist/image/shared-params.generated.js +471 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -1
- package/dist/next/attrs.d.ts +84 -10
- package/dist/next/attrs.js +119 -2
- package/dist/next/client.d.ts +176 -32
- package/dist/next/client.js +212 -90
- package/dist/next/entry-fields.d.ts +162 -0
- package/dist/next/entry-fields.js +2 -0
- package/dist/next/errors.d.ts +136 -0
- package/dist/next/errors.js +214 -0
- package/dist/next/field-names.d.ts +37 -0
- package/dist/next/field-names.js +145 -0
- package/dist/next/graphql/build.d.ts +27 -0
- package/dist/next/graphql/build.js +98 -0
- package/dist/next/graphql/documents.d.ts +67 -0
- package/dist/next/graphql/documents.js +35 -0
- package/dist/next/graphql/edit-mode.d.ts +16 -0
- package/dist/next/graphql/edit-mode.js +93 -0
- package/dist/next/graphql/filter-values.d.ts +34 -0
- package/dist/next/graphql/filter-values.js +96 -0
- package/dist/next/graphql/introspection.d.ts +89 -0
- package/dist/next/graphql/introspection.js +102 -0
- package/dist/next/graphql/plan.d.ts +115 -0
- package/dist/next/graphql/plan.js +531 -0
- package/dist/next/graphql/request.d.ts +228 -0
- package/dist/next/graphql/request.js +283 -0
- package/dist/next/graphql/rest.d.ts +66 -0
- package/dist/next/graphql/rest.js +502 -0
- package/dist/next/graphql/selection.d.ts +55 -0
- package/dist/next/graphql/selection.js +212 -0
- package/dist/next/graphql/sha256.d.ts +13 -0
- package/dist/next/graphql/sha256.js +86 -0
- package/dist/next/graphql/summary.d.ts +83 -0
- package/dist/next/graphql/summary.js +151 -0
- package/dist/next/graphql/tree-layout.d.ts +36 -0
- package/dist/next/graphql/tree-layout.js +20 -0
- package/dist/next/graphql/tree.d.ts +171 -0
- package/dist/next/graphql/tree.js +249 -0
- package/dist/next/graphql/typed.d.ts +261 -0
- package/dist/next/graphql/typed.js +146 -0
- package/dist/next/index.d.ts +30 -5
- package/dist/next/index.js +32 -1
- package/dist/next/inflate.d.ts +51 -0
- package/dist/next/inflate.js +243 -0
- package/dist/next/key-family.d.ts +31 -0
- package/dist/next/key-family.js +66 -0
- package/dist/next/select-types.d.ts +58 -5
- package/dist/next/system-keys.d.ts +27 -0
- package/dist/next/system-keys.js +42 -0
- package/dist/nextjs/image-loader.d.ts +60 -0
- package/dist/nextjs/image-loader.js +71 -0
- package/dist/nextjs/index.d.ts +484 -5
- package/dist/nextjs/index.js +688 -6
- package/dist/nextjs/overlay.d.ts +30 -0
- package/dist/nextjs/overlay.js +78 -0
- package/dist/overlay/index.d.ts +14 -2
- package/dist/overlay/index.js +282 -43
- package/dist/overlay/protocol.d.ts +98 -2
- package/dist/overlay/protocol.js +151 -4
- package/package.json +63 -15
|
@@ -0,0 +1,531 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAX_RELATION_DEPTH = exports.MEDIA_FIELDS = exports.SYSTEM_FIELDS = exports.CapaBuildError = exports.LIST_DEFAULT = exports.END_OF_LIST = void 0;
|
|
4
|
+
exports.didYouMean = didYouMean;
|
|
5
|
+
exports.findModel = findModel;
|
|
6
|
+
exports.isHop = isHop;
|
|
7
|
+
exports.planQuery = planQuery;
|
|
8
|
+
/**
|
|
9
|
+
* plan.ts — a query spec, checked against a key's schema summary.
|
|
10
|
+
*
|
|
11
|
+
* The spec is the small JSON an Explorer, an agent or a script writes ("these
|
|
12
|
+
* fields of articles, newest first, five of them"). Resolving it once, here,
|
|
13
|
+
* gives both printers (GraphQL in build.ts, REST in rest.ts) the same checked
|
|
14
|
+
* tree, so the two can only differ in syntax. Every refusal is a
|
|
15
|
+
* `CapaBuildError` naming the closest real names, so a caller can correct
|
|
16
|
+
* itself in one step.
|
|
17
|
+
*/
|
|
18
|
+
const filter_values_1 = require("./filter-values");
|
|
19
|
+
const summary_1 = require("./summary");
|
|
20
|
+
/** REST's `before=end`: the last entries of the list, read back from its end (spec 17, amendment 132). */
|
|
21
|
+
exports.END_OF_LIST = "end";
|
|
22
|
+
/** REST's and GraphQL's root page size when none is given. */
|
|
23
|
+
exports.LIST_DEFAULT = 25;
|
|
24
|
+
class CapaBuildError extends Error {
|
|
25
|
+
/** The closest real names, nearest first. */
|
|
26
|
+
didYouMean;
|
|
27
|
+
/** Every name that would have been accepted. */
|
|
28
|
+
available;
|
|
29
|
+
constructor(message, didYouMean = [], available = []) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = "CapaBuildError";
|
|
32
|
+
this.didYouMean = didYouMean;
|
|
33
|
+
this.available = available;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
exports.CapaBuildError = CapaBuildError;
|
|
37
|
+
/** The system fields every model type has (N6), in schema order. */
|
|
38
|
+
exports.SYSTEM_FIELDS = [
|
|
39
|
+
"id",
|
|
40
|
+
"model",
|
|
41
|
+
"status",
|
|
42
|
+
"createdAt",
|
|
43
|
+
"updatedAt",
|
|
44
|
+
"publishedAt",
|
|
45
|
+
"_version",
|
|
46
|
+
"_tags",
|
|
47
|
+
"_folder",
|
|
48
|
+
];
|
|
49
|
+
exports.MEDIA_FIELDS = ["id", "url", "alt", "type", "width", "height"];
|
|
50
|
+
const MEDIA_DEFAULT = ["id", "url", "alt"];
|
|
51
|
+
/**
|
|
52
|
+
* Relations a query may nest below its root entry. The API reads 5 levels of
|
|
53
|
+
* entries per root field and counts the root's own as the first, so 4
|
|
54
|
+
* relations below it (spec 17, amendment 78).
|
|
55
|
+
*/
|
|
56
|
+
exports.MAX_RELATION_DEPTH = 4;
|
|
57
|
+
function levenshtein(a, b) {
|
|
58
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
59
|
+
for (let i = 1; i <= a.length; i++) {
|
|
60
|
+
let previous = row[0];
|
|
61
|
+
row[0] = i;
|
|
62
|
+
for (let j = 1; j <= b.length; j++) {
|
|
63
|
+
const current = row[j];
|
|
64
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
65
|
+
previous = current;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return row[b.length];
|
|
69
|
+
}
|
|
70
|
+
/** Names within two edits of `wanted`, nearest first then by name, at most three. */
|
|
71
|
+
function didYouMean(wanted, candidates) {
|
|
72
|
+
const needle = wanted.toLowerCase();
|
|
73
|
+
return [...new Set(candidates)]
|
|
74
|
+
.map((name) => ({ name, distance: levenshtein(needle, name.toLowerCase()) }))
|
|
75
|
+
.filter((c) => c.distance <= 2)
|
|
76
|
+
.sort((a, b) => a.distance - b.distance || (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
77
|
+
.slice(0, 3)
|
|
78
|
+
.map((c) => c.name);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The refusal for a model the key reads that GraphQL leaves out (N1): REST
|
|
82
|
+
* serves it, so the message names that read and why, and suggests no other
|
|
83
|
+
* model. `@capacms/mcp` says the same, pinned by a test.
|
|
84
|
+
*/
|
|
85
|
+
function restOnlyModel(namespace, restOnly) {
|
|
86
|
+
// N1 leaves models out when their type names would collide, as these
|
|
87
|
+
// spellings do. For the sentence only, never for a name that is sent.
|
|
88
|
+
const letters = (name) => name.toLowerCase().replace(/[^0-9a-z]/g, "");
|
|
89
|
+
const partners = restOnly.filter((other) => other !== namespace && letters(other) === letters(namespace));
|
|
90
|
+
const named = ["it", ...partners];
|
|
91
|
+
const why = partners.length
|
|
92
|
+
? `${named.slice(0, -1).join(", ")} and ${named[named.length - 1]} would have the same GraphQL type name`
|
|
93
|
+
: "its namespace gives no GraphQL type name of its own";
|
|
94
|
+
return new CapaBuildError(`${namespace} is readable over REST only: GET /api/entries/${namespace}. GraphQL leaves it out, since ${why}.`);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* A model by namespace or type name, then by any of those or its display name
|
|
98
|
+
* ignoring case (`Writers`, `scalar specimens`). An unknown name is refused
|
|
99
|
+
* with the nearest models, each by namespace, whichever of its names was near.
|
|
100
|
+
*/
|
|
101
|
+
function findModel(summary, wanted) {
|
|
102
|
+
const exact = summary.models.find((m) => m.namespace === wanted || m.typeName === wanted);
|
|
103
|
+
if (exact)
|
|
104
|
+
return exact;
|
|
105
|
+
const { restOnly } = summary;
|
|
106
|
+
if (restOnly.includes(wanted))
|
|
107
|
+
throw restOnlyModel(wanted, restOnly);
|
|
108
|
+
const lower = String(wanted).toLowerCase();
|
|
109
|
+
const names = (m) => [m.namespace, m.typeName, (0, summary_1.modelDisplayName)(m)];
|
|
110
|
+
const loose = summary.models.filter((m) => names(m).some((name) => name.toLowerCase() === lower));
|
|
111
|
+
if (loose.length === 1)
|
|
112
|
+
return loose[0];
|
|
113
|
+
const looseRestOnly = restOnly.filter((name) => name.toLowerCase() === lower);
|
|
114
|
+
if (!loose.length && looseRestOnly.length === 1)
|
|
115
|
+
throw restOnlyModel(looseRestOnly[0], restOnly);
|
|
116
|
+
const namespaces = summary.models.map((m) => m.namespace);
|
|
117
|
+
if (loose.length > 1) {
|
|
118
|
+
throw new CapaBuildError(`model ${wanted} is ambiguous: pass one of ${orList(loose.map((m) => m.namespace))}`, loose.map((m) => m.namespace), namespaces);
|
|
119
|
+
}
|
|
120
|
+
const namespaceOf = new Map(summary.models.flatMap((m) => names(m).map((name) => [name, m.namespace])));
|
|
121
|
+
const near = [...new Set(didYouMean(String(wanted), [...namespaceOf.keys()]).map((name) => namespaceOf.get(name)))];
|
|
122
|
+
throw new CapaBuildError(`unknown model ${wanted}`, near, namespaces);
|
|
123
|
+
}
|
|
124
|
+
function modelByNamespace(summary, namespace) {
|
|
125
|
+
return namespace === null ? undefined : summary.models.find((m) => m.namespace === namespace);
|
|
126
|
+
}
|
|
127
|
+
function selectableNames(model) {
|
|
128
|
+
return [...exports.SYSTEM_FIELDS, ...model.fields.map((f) => f.name)];
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* A field by its GraphQL name, then a system field, then a field by its
|
|
132
|
+
* namespace: `createdAt` is the system field even where a field's namespace
|
|
133
|
+
* is `createdAt` (that field is `createdAt_field`, N5).
|
|
134
|
+
*/
|
|
135
|
+
function findField(model, wanted) {
|
|
136
|
+
const byName = model.fields.find((f) => f.name === wanted);
|
|
137
|
+
if (byName)
|
|
138
|
+
return byName;
|
|
139
|
+
if (exports.SYSTEM_FIELDS.includes(wanted))
|
|
140
|
+
return null;
|
|
141
|
+
const byNamespace = model.fields.find((f) => f.namespace === wanted);
|
|
142
|
+
if (byNamespace)
|
|
143
|
+
return byNamespace;
|
|
144
|
+
throw new CapaBuildError(`unknown field ${wanted} on ${model.typeName}`, didYouMean(wanted, selectableNames(model)), selectableNames(model));
|
|
145
|
+
}
|
|
146
|
+
function checkFirst(value, where) {
|
|
147
|
+
if (!Number.isInteger(value) || value < 1 || value > 200) {
|
|
148
|
+
throw new CapaBuildError(`${where} must be a whole number from 1 to 200`);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* A relation list's sort: one value, as its argument takes (N7). A list of
|
|
154
|
+
* one, the root's shape, is that value; a longer list is refused, since the
|
|
155
|
+
* API sorts a relation list by one key.
|
|
156
|
+
*/
|
|
157
|
+
function oneSort(value, name) {
|
|
158
|
+
if (!Array.isArray(value))
|
|
159
|
+
return value;
|
|
160
|
+
if (value.length === 1)
|
|
161
|
+
return value[0];
|
|
162
|
+
throw new CapaBuildError(`sort on ${name} takes one value: a relation list sorts by one key, unlike the root's up to 3`);
|
|
163
|
+
}
|
|
164
|
+
function checkSort(value, model, where) {
|
|
165
|
+
if (typeof value !== "string" || !model.sortValues.includes(value)) {
|
|
166
|
+
const written = typeof value === "string" ? restSortValue(value) : null;
|
|
167
|
+
if (written && model.sortValues.includes(written)) {
|
|
168
|
+
throw new CapaBuildError(`${where} ${JSON.stringify(value)} is REST's sort syntax: write ${JSON.stringify(written)}`, [written]);
|
|
169
|
+
}
|
|
170
|
+
throw new CapaBuildError(`${where} ${JSON.stringify(value)} is not a value of ${model.sortType}`, didYouMean(String(value), model.sortValues), model.sortValues);
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
}
|
|
174
|
+
// ------------------------------------------------------------ REST forms ---
|
|
175
|
+
//
|
|
176
|
+
// A spec written as REST writes a read (`author.name` or `author(name)` for a
|
|
177
|
+
// relation's fields, `*` for every field, `-publishedAt` for a sort) is
|
|
178
|
+
// refused with what to write instead, spelled out, since the same read is one
|
|
179
|
+
// edit away. @capacms/mcp refuses them in the same words, pinned by
|
|
180
|
+
// test/fixtures/graphql-rest-forms.json.
|
|
181
|
+
/** A REST sort key as a sort value: `-publishedAt` is `publishedAt_DESC`, `author.name` is `author__name_ASC`. */
|
|
182
|
+
function restSortValue(value) {
|
|
183
|
+
const match = /^(-?)([^\s-][^\s]*)$/.exec(value);
|
|
184
|
+
if (!match || /_(ASC|DESC)$/.test(match[2]))
|
|
185
|
+
return null;
|
|
186
|
+
return `${match[2].split(".").join("__")}_${match[1] ? "DESC" : "ASC"}`;
|
|
187
|
+
}
|
|
188
|
+
/** Split REST select text on the commas outside parentheses; null when the parentheses do not pair. */
|
|
189
|
+
function restItems(text) {
|
|
190
|
+
const items = [];
|
|
191
|
+
let depth = 0;
|
|
192
|
+
let start = 0;
|
|
193
|
+
for (let at = 0; at < text.length; at++) {
|
|
194
|
+
if (text[at] === "(")
|
|
195
|
+
depth++;
|
|
196
|
+
else if (text[at] === ")" && --depth < 0)
|
|
197
|
+
return null;
|
|
198
|
+
else if (text[at] === "," && depth === 0) {
|
|
199
|
+
items.push(text.slice(start, at).trim());
|
|
200
|
+
start = at + 1;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (depth !== 0)
|
|
204
|
+
return null;
|
|
205
|
+
items.push(text.slice(start).trim());
|
|
206
|
+
return items.filter(Boolean);
|
|
207
|
+
}
|
|
208
|
+
/** One REST select item as a field of a spec: `author(name,limit:5)` is `{ field: "author", fields: ["name"], first: 5 }`. */
|
|
209
|
+
function restItemSpec(text) {
|
|
210
|
+
const open = text.indexOf("(");
|
|
211
|
+
const dot = text.indexOf(".");
|
|
212
|
+
if (open === -1 && dot === -1)
|
|
213
|
+
return text;
|
|
214
|
+
if (open === -1 || (dot !== -1 && dot < open)) {
|
|
215
|
+
const nested = restItemSpec(text.slice(dot + 1));
|
|
216
|
+
return nested === null ? null : { field: text.slice(0, dot), fields: [nested] };
|
|
217
|
+
}
|
|
218
|
+
if (!text.endsWith(")"))
|
|
219
|
+
return null;
|
|
220
|
+
const inner = restItems(text.slice(open + 1, -1));
|
|
221
|
+
if (!inner)
|
|
222
|
+
return null;
|
|
223
|
+
const fields = [];
|
|
224
|
+
const args = {};
|
|
225
|
+
for (const item of inner) {
|
|
226
|
+
const modifier = /^(limit|sort|after):(.+)$/.exec(item);
|
|
227
|
+
if (!modifier) {
|
|
228
|
+
const nested = restItemSpec(item);
|
|
229
|
+
if (nested === null)
|
|
230
|
+
return null;
|
|
231
|
+
fields.push(nested);
|
|
232
|
+
}
|
|
233
|
+
else if (modifier[1] === "limit")
|
|
234
|
+
args.first = Number(modifier[2]);
|
|
235
|
+
else if (modifier[1] === "sort")
|
|
236
|
+
args.sort = restSortValue(modifier[2]) ?? modifier[2];
|
|
237
|
+
else
|
|
238
|
+
args.after = modifier[2];
|
|
239
|
+
}
|
|
240
|
+
return { field: text.slice(0, open), ...(fields.length ? { fields } : {}), ...args };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* The refusal for a field item written as REST writes a select, or null when
|
|
244
|
+
* `wanted` is not one. A dotted or parenthesised item counts only when what
|
|
245
|
+
* comes first is a field of `model`: a namespace may itself hold a dot.
|
|
246
|
+
*/
|
|
247
|
+
function restFormRefusal(model, wanted, at) {
|
|
248
|
+
if (wanted === "*") {
|
|
249
|
+
return new CapaBuildError(`${at} "*" is REST's select syntax: leave fields out for every field that is not a relation, or name the fields you want`);
|
|
250
|
+
}
|
|
251
|
+
const head = /^[^.(]+/.exec(wanted)?.[0];
|
|
252
|
+
if (!head || head === wanted || !model.fields.some((f) => f.name === head || f.namespace === head))
|
|
253
|
+
return null;
|
|
254
|
+
const spec = restItemSpec(wanted);
|
|
255
|
+
if (spec === null || typeof spec === "string")
|
|
256
|
+
return null;
|
|
257
|
+
return new CapaBuildError(`${at} ${JSON.stringify(wanted)} is REST's select syntax: write ${JSON.stringify(spec)}`);
|
|
258
|
+
}
|
|
259
|
+
function mediaChildren(fields, owner) {
|
|
260
|
+
const names = fields === undefined ? MEDIA_DEFAULT : fields;
|
|
261
|
+
const planned = [{ name: "id", field: null }];
|
|
262
|
+
for (const item of names) {
|
|
263
|
+
if (typeof item !== "string" || !exports.MEDIA_FIELDS.includes(item)) {
|
|
264
|
+
const name = typeof item === "string" ? item : item.field;
|
|
265
|
+
throw new CapaBuildError(`unknown field ${name} on Media (${owner})`, didYouMean(name, exports.MEDIA_FIELDS), [...exports.MEDIA_FIELDS]);
|
|
266
|
+
}
|
|
267
|
+
if (item !== "id")
|
|
268
|
+
planned.push({ name: item, field: null });
|
|
269
|
+
}
|
|
270
|
+
return planned;
|
|
271
|
+
}
|
|
272
|
+
/** Every current non-relation field, single relations as `{ id }`, media as `{ id url alt }`; array relations and deprecated fields left out. */
|
|
273
|
+
function defaultFields(model) {
|
|
274
|
+
return model.fields.filter((f) => f.kind !== "relationList" && !f.deprecationReason).map((f) => f.name);
|
|
275
|
+
}
|
|
276
|
+
/** The keys a relation spec (`{ field, fields, first, sort }`) takes, and `after` in a read of one entry. */
|
|
277
|
+
const FIELD_SPEC_KEYS = ["field", "fields", "first", "sort"];
|
|
278
|
+
const SINGLE_FIELD_SPEC_KEYS = [...FIELD_SPEC_KEYS, "after"];
|
|
279
|
+
/**
|
|
280
|
+
* A key the builder does not read is refused by name, at any depth: a typo
|
|
281
|
+
* dropped silently (`frist`) builds a query without the argument and reports
|
|
282
|
+
* that it worked. The types refuse it at compile time; this is for specs
|
|
283
|
+
* written in JSON or plain JavaScript. `after` names one entry's list, so a
|
|
284
|
+
* list read refuses it in the API's words.
|
|
285
|
+
*/
|
|
286
|
+
function checkSpecKeys(spec, path, single) {
|
|
287
|
+
const allowed = single ? SINGLE_FIELD_SPEC_KEYS : FIELD_SPEC_KEYS;
|
|
288
|
+
for (const key of Object.keys(spec)) {
|
|
289
|
+
if (allowed.includes(key))
|
|
290
|
+
continue;
|
|
291
|
+
if (key === "after") {
|
|
292
|
+
throw new CapaBuildError(`after: applies to a single entry: ${path}.after pages one entry's relation list. Read that entry by id to page it, or leave after out`);
|
|
293
|
+
}
|
|
294
|
+
const near = didYouMean(key, allowed);
|
|
295
|
+
throw new CapaBuildError(`Unknown argument ${key} in ${path}.${near.length ? ` Did you mean ${near.join(" or ")}?` : ""} Allowed: ${allowed.join(", ")}.`, near, [...allowed]);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* `path` names where `specs` sit in the spec, `fields[1].fields`, for a
|
|
300
|
+
* refusal. `single` is whether the read is of one entry, where a relation
|
|
301
|
+
* list may be read on from a cursor.
|
|
302
|
+
*/
|
|
303
|
+
function planFields(summary, model, specs, depth, single, path = "fields") {
|
|
304
|
+
if (specs !== undefined && !Array.isArray(specs)) {
|
|
305
|
+
throw new CapaBuildError(`fields of ${model.typeName} must be an array`);
|
|
306
|
+
}
|
|
307
|
+
const planned = [{ name: "id", field: null }];
|
|
308
|
+
const seen = new Set(["id"]);
|
|
309
|
+
for (const [index, spec] of (specs ?? defaultFields(model)).entries()) {
|
|
310
|
+
const nested = typeof spec === "object" && spec !== null;
|
|
311
|
+
if (nested)
|
|
312
|
+
checkSpecKeys(spec, `${path}[${index}]`, single);
|
|
313
|
+
const wanted = nested ? spec.field : spec;
|
|
314
|
+
if (typeof wanted !== "string" || wanted === "") {
|
|
315
|
+
throw new CapaBuildError(`each field of ${model.typeName} must be a name or { field, fields }`);
|
|
316
|
+
}
|
|
317
|
+
let field;
|
|
318
|
+
try {
|
|
319
|
+
field = findField(model, wanted);
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
throw (!nested && restFormRefusal(model, wanted, `${path}[${index}]`)) || error;
|
|
323
|
+
}
|
|
324
|
+
const name = field?.name ?? wanted;
|
|
325
|
+
if (seen.has(name))
|
|
326
|
+
continue;
|
|
327
|
+
seen.add(name);
|
|
328
|
+
const kind = field?.kind ?? "scalar";
|
|
329
|
+
if (nested && (spec.first !== undefined || spec.sort !== undefined) && kind !== "relationList") {
|
|
330
|
+
throw new CapaBuildError(`first and sort apply to array relations; ${name} on ${model.typeName} is not one`);
|
|
331
|
+
}
|
|
332
|
+
if (nested && spec.after !== undefined && kind !== "relationList") {
|
|
333
|
+
throw new CapaBuildError(`after applies to array relations; ${name} on ${model.typeName} is not one`);
|
|
334
|
+
}
|
|
335
|
+
if (kind === "relation" || kind === "relationList") {
|
|
336
|
+
const target = modelByNamespace(summary, field.target);
|
|
337
|
+
if (!target)
|
|
338
|
+
throw new CapaBuildError(`${name} on ${model.typeName} points at a model this key cannot read`);
|
|
339
|
+
if (depth >= exports.MAX_RELATION_DEPTH) {
|
|
340
|
+
throw new CapaBuildError(`${name} would expand past ${exports.MAX_RELATION_DEPTH} relation levels. Select ${name} without fields for its id, or read it with a second query`);
|
|
341
|
+
}
|
|
342
|
+
const children = planFields(summary, target, nested ? spec.fields ?? ["id"] : ["id"], depth + 1, single, `${path}[${index}].fields`);
|
|
343
|
+
const entry = { name, field, children, target };
|
|
344
|
+
if (nested && spec.first !== undefined)
|
|
345
|
+
entry.first = checkFirst(spec.first, `first on ${name}`);
|
|
346
|
+
if (nested && spec.sort !== undefined)
|
|
347
|
+
entry.sort = checkSort(oneSort(spec.sort, name), target, `sort on ${name}`);
|
|
348
|
+
if (nested && spec.after !== undefined) {
|
|
349
|
+
if (typeof spec.after !== "string" || spec.after === "")
|
|
350
|
+
throw new CapaBuildError(`after on ${name} must be a cursor: the list's endCursor`);
|
|
351
|
+
entry.after = spec.after;
|
|
352
|
+
}
|
|
353
|
+
planned.push(entry);
|
|
354
|
+
}
|
|
355
|
+
else if (kind === "media") {
|
|
356
|
+
planned.push({ name, field, children: mediaChildren(nested ? spec.fields : undefined, name) });
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
if (nested && spec.fields !== undefined) {
|
|
360
|
+
throw new CapaBuildError(kind === "id" || kind === "idList"
|
|
361
|
+
? `${name} on ${model.typeName} is an id here: this key cannot read the model it points at. Select ${name} without fields for the id`
|
|
362
|
+
: `${name} on ${model.typeName} has no fields to select`);
|
|
363
|
+
}
|
|
364
|
+
planned.push({ name, field });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return planned;
|
|
368
|
+
}
|
|
369
|
+
const systemFilterNames = (model) => model.systemFilters.map((system) => system.name);
|
|
370
|
+
function filterableNames(model) {
|
|
371
|
+
return ["and", "or", "not", ...systemFilterNames(model), ...model.fields.filter((f) => f.filterOps.length > 0).map((f) => f.name)];
|
|
372
|
+
}
|
|
373
|
+
/** `a, b and c`, `a, b or c`: names in a sentence. */
|
|
374
|
+
const andList = (names) => `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
375
|
+
const orList = (names) => `${names.slice(0, -1).join(", ")} or ${names[names.length - 1]}`;
|
|
376
|
+
/** GraphQL scalars an operator takes; any other input type under a field's filter is a hop into a related field. */
|
|
377
|
+
const OPERAND_SCALARS = new Set(["String", "ID", "Float", "Int", "Boolean", "DateTime", "JSON"]);
|
|
378
|
+
/** The operators of a system field reached through a hop (`author: { id: { eq } }`), as the API's IDFilter and DateTimeFilter declare them. */
|
|
379
|
+
const SYSTEM_HOP_OPERATORS = ["eq", "ne", "lt", "lte", "gt", "gte", "in", "nin", "exists", "null"];
|
|
380
|
+
/** Whether `key` under `field`'s filter is a hop into a field of the related model rather than an operator. */
|
|
381
|
+
function isHop(field, key) {
|
|
382
|
+
const type = field.filterInputs[key];
|
|
383
|
+
return type !== undefined && !OPERAND_SCALARS.has(type);
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* The operator a bare value stands for under `operators`, and the value it
|
|
387
|
+
* takes: equality for one value, membership for a list, `null: true` for null.
|
|
388
|
+
*/
|
|
389
|
+
function operatorFor(operators, value) {
|
|
390
|
+
if (value === null)
|
|
391
|
+
return operators.includes("null") ? ["null", true] : null;
|
|
392
|
+
if (Array.isArray(value)) {
|
|
393
|
+
const op = ["in", "hasAny"].find((name) => operators.includes(name));
|
|
394
|
+
return op ? [op, value] : null;
|
|
395
|
+
}
|
|
396
|
+
const op = ["eq", "has", "id"].find((name) => operators.includes(name));
|
|
397
|
+
return op ? [op, value] : null;
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* A filter written without operators (`{ featured: true }`) is refused with
|
|
401
|
+
* the operator form to write instead: GraphQL declares an object of operators
|
|
402
|
+
* there, so the shorthand would fail on the API and has no REST twin.
|
|
403
|
+
*/
|
|
404
|
+
function refuseBareValue(label, operators, value, wrap) {
|
|
405
|
+
const operator = operatorFor(operators, value);
|
|
406
|
+
const fix = operator ? ` write ${JSON.stringify(wrap({ [operator[0]]: operator[1] }))}` : ` use one of ${orList(operators)}`;
|
|
407
|
+
throw new CapaBuildError(`${label} takes operators, not a bare value:${fix}`, [], [...operators]);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The keys under one field's filter: its operators and, on a relation, the
|
|
411
|
+
* target fields one hop reaches, exactly the input fields its filter type
|
|
412
|
+
* declares, each hop taking operators of its own. A relation the key cannot
|
|
413
|
+
* read is an id, and its filter has no hop at all, so a hop through it is
|
|
414
|
+
* refused here without naming the model it points at (REST answers the same
|
|
415
|
+
* read `unknown_field`).
|
|
416
|
+
*/
|
|
417
|
+
function checkFieldFilter(summary, model, field, operations) {
|
|
418
|
+
if (!(0, filter_values_1.isObject)(operations))
|
|
419
|
+
refuseBareValue(field.name, field.filterOps, operations, (ops) => ({ [field.name]: ops }));
|
|
420
|
+
for (const [key, inner] of Object.entries(operations)) {
|
|
421
|
+
if (!field.filterOps.includes(key)) {
|
|
422
|
+
if (field.kind === "id" || field.kind === "idList") {
|
|
423
|
+
throw new CapaBuildError(`${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)}`, [], field.filterOps);
|
|
424
|
+
}
|
|
425
|
+
throw new CapaBuildError(`unknown filter ${key} under ${field.name} on ${model.filterType}`, didYouMean(key, field.filterOps), field.filterOps);
|
|
426
|
+
}
|
|
427
|
+
if (!isHop(field, key))
|
|
428
|
+
continue;
|
|
429
|
+
const hop = modelByNamespace(summary, field.target)?.fields.find((f) => f.name === key);
|
|
430
|
+
const operators = hop ? hop.filterOps : SYSTEM_HOP_OPERATORS;
|
|
431
|
+
const wrap = (ops) => ({ [field.name]: { [key]: ops } });
|
|
432
|
+
if (!(0, filter_values_1.isObject)(inner))
|
|
433
|
+
refuseBareValue(`${field.name}.${key}`, operators, inner, wrap);
|
|
434
|
+
for (const op of Object.keys(inner)) {
|
|
435
|
+
if (!operators.includes(op)) {
|
|
436
|
+
throw new CapaBuildError(`unknown filter ${op} under ${field.name}.${key} on ${model.filterType}`, didYouMean(op, operators), [...operators]);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* A filter's names, checked through `and`, `or` and `not` before anything is
|
|
443
|
+
* sent. The system fields it takes are the ones `filterType` declares
|
|
444
|
+
* (`systemFilters`): `_version`, `_folder`, `model` and `status` are
|
|
445
|
+
* selectable but never filterable, on REST too.
|
|
446
|
+
*/
|
|
447
|
+
function checkFilter(summary, model, filter) {
|
|
448
|
+
if (!(0, filter_values_1.isObject)(filter)) {
|
|
449
|
+
throw new CapaBuildError(`filter must be an object of ${model.filterType}`);
|
|
450
|
+
}
|
|
451
|
+
const allowed = filterableNames(model);
|
|
452
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
453
|
+
if (key === "and" || key === "or") {
|
|
454
|
+
for (const part of Array.isArray(value) ? value : [value])
|
|
455
|
+
checkFilter(summary, model, part);
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (key === "not") {
|
|
459
|
+
checkFilter(summary, model, value);
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (!allowed.includes(key)) {
|
|
463
|
+
if (exports.SYSTEM_FIELDS.includes(key)) {
|
|
464
|
+
throw new CapaBuildError(`${key} cannot be filtered: ${model.filterType} filters the system fields ${andList(systemFilterNames(model))} only`, [], allowed);
|
|
465
|
+
}
|
|
466
|
+
throw new CapaBuildError(`unknown filter field ${key} on ${model.filterType}`, didYouMean(key, allowed), allowed);
|
|
467
|
+
}
|
|
468
|
+
const field = model.fields.find((f) => f.name === key);
|
|
469
|
+
const system = model.systemFilters.find((f) => f.name === key);
|
|
470
|
+
if (field)
|
|
471
|
+
checkFieldFilter(summary, model, field, value);
|
|
472
|
+
else if (system && !(0, filter_values_1.isObject)(value))
|
|
473
|
+
refuseBareValue(key, system.filterOps, value, (ops) => ({ [key]: ops }));
|
|
474
|
+
}
|
|
475
|
+
return filter;
|
|
476
|
+
}
|
|
477
|
+
/** Resolve and check a spec. Throws `CapaBuildError`. */
|
|
478
|
+
function planQuery(summary, spec) {
|
|
479
|
+
if (!spec || typeof spec !== "object")
|
|
480
|
+
throw new CapaBuildError("a query spec must be an object with model");
|
|
481
|
+
const model = findModel(summary, spec.model);
|
|
482
|
+
// An id reads that one entry: it is never dropped into a list of every entry.
|
|
483
|
+
const mode = spec.mode ?? (spec.id !== undefined ? "single" : "list");
|
|
484
|
+
if (mode !== "list" && mode !== "single")
|
|
485
|
+
throw new CapaBuildError(`mode must be list or single, not ${String(mode)}`);
|
|
486
|
+
if (mode === "list" && spec.id !== undefined) {
|
|
487
|
+
throw new CapaBuildError('id reads one entry, so it takes mode single: pass mode "single", or leave id out to list entries');
|
|
488
|
+
}
|
|
489
|
+
if (mode === "single" && (typeof spec.id !== "string" || spec.id === "")) {
|
|
490
|
+
throw new CapaBuildError("mode single needs id, the entry's UUID");
|
|
491
|
+
}
|
|
492
|
+
if (mode === "single") {
|
|
493
|
+
for (const key of ["first", "after", "before", "sort", "filter", "totalCount"]) {
|
|
494
|
+
if (spec[key] !== undefined)
|
|
495
|
+
throw new CapaBuildError(`${key} applies to mode list only`);
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (spec.after !== undefined && spec.before !== undefined) {
|
|
499
|
+
throw new CapaBuildError("after and before page in opposite directions: pass after to read on, or before to read back, not both");
|
|
500
|
+
}
|
|
501
|
+
if (spec.after !== undefined && String(spec.after) === exports.END_OF_LIST) {
|
|
502
|
+
throw new CapaBuildError('after takes an endCursor, and "end" is none: pass before: "end" to read the last entries of the list');
|
|
503
|
+
}
|
|
504
|
+
const planned = {
|
|
505
|
+
model,
|
|
506
|
+
mode,
|
|
507
|
+
fields: planFields(summary, model, spec.fields, 0, mode === "single"),
|
|
508
|
+
totalCount: spec.totalCount === true,
|
|
509
|
+
operationName: spec.operationName ?? `${model.typeName}${mode === "single" ? "ById" : "List"}`,
|
|
510
|
+
summary,
|
|
511
|
+
};
|
|
512
|
+
if (mode === "single")
|
|
513
|
+
planned.id = spec.id;
|
|
514
|
+
if (spec.first !== undefined)
|
|
515
|
+
planned.first = checkFirst(spec.first, "first");
|
|
516
|
+
if (spec.after !== undefined)
|
|
517
|
+
planned.after = String(spec.after);
|
|
518
|
+
if (spec.before !== undefined)
|
|
519
|
+
planned.before = String(spec.before);
|
|
520
|
+
if (spec.sort !== undefined) {
|
|
521
|
+
// One value may be given alone, as a relation list's sort is.
|
|
522
|
+
const sort = typeof spec.sort === "string" ? [spec.sort] : spec.sort;
|
|
523
|
+
if (!Array.isArray(sort) || sort.length === 0 || sort.length > 3) {
|
|
524
|
+
throw new CapaBuildError(`sort takes 1 to 3 values of ${model.sortType}`, [], model.sortValues);
|
|
525
|
+
}
|
|
526
|
+
planned.sort = sort.map((value) => checkSort(value, model, "sort"));
|
|
527
|
+
}
|
|
528
|
+
if (spec.filter !== undefined)
|
|
529
|
+
planned.filter = (0, filter_values_1.typedFilter)(summary, model, checkFilter(summary, model, spec.filter));
|
|
530
|
+
return planned;
|
|
531
|
+
}
|