@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.
- package/README.md +881 -0
- package/bin/capa-mcp.mjs +96 -0
- package/lib/annotations.mjs +27 -0
- package/lib/answers.mjs +69 -0
- package/lib/arguments.mjs +140 -0
- package/lib/bound.mjs +546 -0
- package/lib/client.mjs +512 -0
- package/lib/error-guide.mjs +750 -0
- package/lib/explore.mjs +471 -0
- package/lib/graphql/build.mjs +725 -0
- package/lib/graphql/document.mjs +388 -0
- package/lib/graphql/filter-values.mjs +92 -0
- package/lib/graphql/more.mjs +97 -0
- package/lib/graphql/names.mjs +131 -0
- package/lib/graphql/schema.mjs +237 -0
- package/lib/graphql/sdl.mjs +144 -0
- package/lib/graphql/served.mjs +82 -0
- package/lib/graphql-tools.mjs +1177 -0
- package/lib/guide.mjs +55 -0
- package/lib/instructions.mjs +32 -0
- package/lib/prompts.mjs +68 -0
- package/lib/registry.mjs +235 -0
- package/lib/resources.mjs +134 -0
- package/lib/rest-tools.mjs +176 -0
- package/lib/server.mjs +194 -0
- package/lib/session.mjs +90 -0
- package/lib/suggest.mjs +32 -0
- package/lib/tools.mjs +1158 -0
- package/package.json +24 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* document.mjs — the connections a GraphQL document reads, found by FIELD NAME
|
|
3
|
+
* rather than by the keys of its answer.
|
|
4
|
+
*
|
|
5
|
+
* A cut answer must stay pageable (lib/bound.mjs), which means knowing which
|
|
6
|
+
* lists are a connection's entries, which object is its pageInfo, and which
|
|
7
|
+
* way the page runs. The answer alone cannot say: `items: nodes` answers
|
|
8
|
+
* under `items`, `info: pageInfo` under `info`, and a page read with `last`
|
|
9
|
+
* and `before` looks exactly like one read with `first`. The document can, so
|
|
10
|
+
* this reads it: operations, fragments, inline fragments, aliases and
|
|
11
|
+
* argument values, the executable part of the GraphQL grammar and nothing
|
|
12
|
+
* else. It is written here because this package has no dependencies.
|
|
13
|
+
*
|
|
14
|
+
* A document this cannot read gives `null`, and the caller falls back to
|
|
15
|
+
* reading the answer by its literal key names. The API has already parsed
|
|
16
|
+
* and run the document by then, so that only happens on a reader bug.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const PUNCTUATORS = new Set(["!", "$", "&", "(", ")", ":", "=", "@", "[", "]", "{", "|", "}"]);
|
|
20
|
+
const NAME = /[_A-Za-z][_0-9A-Za-z]*/y;
|
|
21
|
+
const NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
22
|
+
/** The fields of a PageInfo that a cut repairs. */
|
|
23
|
+
const PAGE_FIELDS = ["hasNextPage", "hasPreviousPage", "startCursor", "endCursor"];
|
|
24
|
+
|
|
25
|
+
class Unreadable extends Error {}
|
|
26
|
+
|
|
27
|
+
/** The document's tokens: `{ kind: "punct" | "name" | "number" | "string", value, start, end }`, `end` exclusive. */
|
|
28
|
+
function tokenize(source) {
|
|
29
|
+
const tokens = [];
|
|
30
|
+
let at = 0;
|
|
31
|
+
while (at < source.length) {
|
|
32
|
+
const c = source[at];
|
|
33
|
+
if (c === "#") {
|
|
34
|
+
while (at < source.length && source[at] !== "\n" && source[at] !== "\r") at++;
|
|
35
|
+
} else if (c === " " || c === "\t" || c === "\n" || c === "\r" || c === "," || c === "") {
|
|
36
|
+
at++;
|
|
37
|
+
} else if (source.startsWith("...", at)) {
|
|
38
|
+
tokens.push({ kind: "punct", value: "...", start: at, end: at + 3 });
|
|
39
|
+
at += 3;
|
|
40
|
+
} else if (PUNCTUATORS.has(c)) {
|
|
41
|
+
tokens.push({ kind: "punct", value: c, start: at, end: at + 1 });
|
|
42
|
+
at++;
|
|
43
|
+
} else if (source.startsWith('"""', at)) {
|
|
44
|
+
const end = blockStringEnd(source, at + 3);
|
|
45
|
+
tokens.push({ kind: "string", value: source.slice(at + 3, end), start: at, end: end + 3 });
|
|
46
|
+
at = end + 3;
|
|
47
|
+
} else if (c === '"') {
|
|
48
|
+
const [value, end] = readString(source, at + 1);
|
|
49
|
+
tokens.push({ kind: "string", value, start: at, end });
|
|
50
|
+
at = end;
|
|
51
|
+
} else {
|
|
52
|
+
const token = matchAt(NAME, source, at, "name") ?? matchAt(NUMBER, source, at, "number");
|
|
53
|
+
if (!token) throw new Unreadable(`unexpected ${c}`);
|
|
54
|
+
tokens.push({ ...token, start: at, end: at + token.text.length });
|
|
55
|
+
at += token.text.length;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return tokens;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function matchAt(pattern, source, at, kind) {
|
|
62
|
+
pattern.lastIndex = at;
|
|
63
|
+
const match = pattern.exec(source);
|
|
64
|
+
return match ? { kind, value: kind === "number" ? Number(match[0]) : match[0], text: match[0] } : null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function blockStringEnd(source, from) {
|
|
68
|
+
for (let at = from; at < source.length; at++) {
|
|
69
|
+
if (source[at] === "\\" && source.startsWith('\\"""', at)) at += 3;
|
|
70
|
+
else if (source.startsWith('"""', at)) return at;
|
|
71
|
+
}
|
|
72
|
+
throw new Unreadable("unterminated block string");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readString(source, from) {
|
|
76
|
+
let at = from;
|
|
77
|
+
let raw = "";
|
|
78
|
+
while (at < source.length && source[at] !== '"') {
|
|
79
|
+
if (source[at] === "\n" || source[at] === "\r") break;
|
|
80
|
+
if (source[at] === "\\") {
|
|
81
|
+
raw += source.slice(at, at + (source[at + 1] === "u" ? 6 : 2));
|
|
82
|
+
at += source[at + 1] === "u" ? 6 : 2;
|
|
83
|
+
} else {
|
|
84
|
+
raw += source[at++];
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (source[at] !== '"') throw new Unreadable("unterminated string");
|
|
88
|
+
return [JSON.parse(`"${raw}"`), at + 1];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A recursive-descent reader over the tokens, for the executable definitions only. */
|
|
92
|
+
function parse(source) {
|
|
93
|
+
const tokens = tokenize(source);
|
|
94
|
+
let at = 0;
|
|
95
|
+
const peek = (value) => tokens[at]?.kind === "punct" && tokens[at].value === value;
|
|
96
|
+
const next = () => {
|
|
97
|
+
if (at >= tokens.length) throw new Unreadable("unexpected end of document");
|
|
98
|
+
return tokens[at++];
|
|
99
|
+
};
|
|
100
|
+
const expect = (value) => {
|
|
101
|
+
const token = next();
|
|
102
|
+
if (token.kind !== "punct" || token.value !== value) throw new Unreadable(`expected ${value}`);
|
|
103
|
+
};
|
|
104
|
+
const name = () => {
|
|
105
|
+
const token = next();
|
|
106
|
+
if (token.kind !== "name") throw new Unreadable("expected a name");
|
|
107
|
+
return token.value;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const value = () => {
|
|
111
|
+
const token = next();
|
|
112
|
+
if (token.kind === "punct" && token.value === "$") return { variable: name() };
|
|
113
|
+
if (token.kind === "punct" && token.value === "[") {
|
|
114
|
+
const items = [];
|
|
115
|
+
while (!peek("]")) items.push(value());
|
|
116
|
+
expect("]");
|
|
117
|
+
return { list: items };
|
|
118
|
+
}
|
|
119
|
+
if (token.kind === "punct" && token.value === "{") {
|
|
120
|
+
const fields = {};
|
|
121
|
+
while (!peek("}")) {
|
|
122
|
+
const key = name();
|
|
123
|
+
expect(":");
|
|
124
|
+
fields[key] = value();
|
|
125
|
+
}
|
|
126
|
+
expect("}");
|
|
127
|
+
return { object: fields };
|
|
128
|
+
}
|
|
129
|
+
if (token.kind === "name") return { literal: token.value === "null" ? null : token.value === "true" ? true : token.value === "false" ? false : token.value };
|
|
130
|
+
if (token.kind === "string" || token.kind === "number") return { literal: token.value };
|
|
131
|
+
throw new Unreadable("expected a value");
|
|
132
|
+
};
|
|
133
|
+
/** A field's arguments by name, and where the document writes them: `spans` (from the name to the value's end) and `close`, the `)`. */
|
|
134
|
+
const args = () => {
|
|
135
|
+
const out = {};
|
|
136
|
+
const spans = {};
|
|
137
|
+
if (!peek("(")) return { values: out, spans, close: null };
|
|
138
|
+
expect("(");
|
|
139
|
+
while (!peek(")")) {
|
|
140
|
+
const start = tokens[at].start;
|
|
141
|
+
const key = name();
|
|
142
|
+
expect(":");
|
|
143
|
+
out[key] = value();
|
|
144
|
+
spans[key] = { start, end: tokens[at - 1].end };
|
|
145
|
+
}
|
|
146
|
+
const close = tokens[at].start;
|
|
147
|
+
expect(")");
|
|
148
|
+
return { values: out, spans, close };
|
|
149
|
+
};
|
|
150
|
+
const directives = () => {
|
|
151
|
+
while (peek("@")) {
|
|
152
|
+
expect("@");
|
|
153
|
+
name();
|
|
154
|
+
args();
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
const type = () => {
|
|
158
|
+
if (peek("[")) {
|
|
159
|
+
expect("[");
|
|
160
|
+
type();
|
|
161
|
+
expect("]");
|
|
162
|
+
} else {
|
|
163
|
+
name();
|
|
164
|
+
}
|
|
165
|
+
if (peek("!")) expect("!");
|
|
166
|
+
};
|
|
167
|
+
const selectionSet = () => {
|
|
168
|
+
expect("{");
|
|
169
|
+
const selections = [];
|
|
170
|
+
while (!peek("}")) selections.push(selection());
|
|
171
|
+
expect("}");
|
|
172
|
+
return selections;
|
|
173
|
+
};
|
|
174
|
+
const selection = () => {
|
|
175
|
+
if (peek("...")) {
|
|
176
|
+
expect("...");
|
|
177
|
+
if (tokens[at]?.kind === "name" && tokens[at].value !== "on") {
|
|
178
|
+
const spread = name();
|
|
179
|
+
directives();
|
|
180
|
+
return { spread };
|
|
181
|
+
}
|
|
182
|
+
if (tokens[at]?.kind === "name") {
|
|
183
|
+
next();
|
|
184
|
+
name();
|
|
185
|
+
}
|
|
186
|
+
directives();
|
|
187
|
+
return { inline: selectionSet() };
|
|
188
|
+
}
|
|
189
|
+
const first = name();
|
|
190
|
+
let field = first;
|
|
191
|
+
let alias = first;
|
|
192
|
+
if (peek(":")) {
|
|
193
|
+
expect(":");
|
|
194
|
+
field = name();
|
|
195
|
+
}
|
|
196
|
+
const nameEnd = tokens[at - 1].end;
|
|
197
|
+
const fieldArgs = args();
|
|
198
|
+
directives();
|
|
199
|
+
return {
|
|
200
|
+
field,
|
|
201
|
+
key: alias,
|
|
202
|
+
args: fieldArgs.values,
|
|
203
|
+
written: { nameEnd, args: fieldArgs.spans, close: fieldArgs.close },
|
|
204
|
+
selections: peek("{") ? selectionSet() : [],
|
|
205
|
+
};
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const operations = [];
|
|
209
|
+
const fragments = new Map();
|
|
210
|
+
while (at < tokens.length) {
|
|
211
|
+
if (peek("{")) {
|
|
212
|
+
operations.push({ name: null, variables: {}, selections: selectionSet() });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const keyword = name();
|
|
216
|
+
if (keyword === "fragment") {
|
|
217
|
+
const fragment = name();
|
|
218
|
+
if (name() !== "on") throw new Unreadable("expected on");
|
|
219
|
+
name();
|
|
220
|
+
directives();
|
|
221
|
+
fragments.set(fragment, selectionSet());
|
|
222
|
+
} else if (keyword === "query" || keyword === "mutation" || keyword === "subscription") {
|
|
223
|
+
const operation = tokens[at]?.kind === "name" ? name() : null;
|
|
224
|
+
const variables = {};
|
|
225
|
+
if (peek("(")) {
|
|
226
|
+
expect("(");
|
|
227
|
+
while (!peek(")")) {
|
|
228
|
+
expect("$");
|
|
229
|
+
const variable = name();
|
|
230
|
+
expect(":");
|
|
231
|
+
type();
|
|
232
|
+
variables[variable] = peek("=") ? (expect("="), value()) : { literal: undefined };
|
|
233
|
+
directives();
|
|
234
|
+
}
|
|
235
|
+
expect(")");
|
|
236
|
+
}
|
|
237
|
+
directives();
|
|
238
|
+
operations.push({ name: operation, variables, selections: selectionSet() });
|
|
239
|
+
} else {
|
|
240
|
+
throw new Unreadable(`unexpected ${keyword}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return { operations, fragments };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** A value with every variable replaced by what was sent, else the variable's default. */
|
|
247
|
+
function resolve(node, variables, defaults) {
|
|
248
|
+
if (!node) return undefined;
|
|
249
|
+
if ("variable" in node) {
|
|
250
|
+
if (variables && Object.hasOwn(variables, node.variable)) return variables[node.variable];
|
|
251
|
+
return resolve(defaults[node.variable], variables, defaults);
|
|
252
|
+
}
|
|
253
|
+
if ("list" in node) return node.list.map((item) => resolve(item, variables, defaults));
|
|
254
|
+
if ("object" in node) return Object.fromEntries(Object.entries(node.object).map(([k, v]) => [k, resolve(v, variables, defaults)]));
|
|
255
|
+
return node.literal;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The fields a selection set reads, by response key, through fragments and
|
|
260
|
+
* inline fragments. Fields sharing a key are one field: their selections merge.
|
|
261
|
+
*/
|
|
262
|
+
function fieldsOf(selections, fragments, seen = new Set()) {
|
|
263
|
+
const byKey = new Map();
|
|
264
|
+
const add = (list) => {
|
|
265
|
+
for (const selection of list) {
|
|
266
|
+
if (selection.spread !== undefined) {
|
|
267
|
+
if (seen.has(selection.spread)) continue;
|
|
268
|
+
const fragment = fragments.get(selection.spread);
|
|
269
|
+
if (!fragment) throw new Unreadable(`unknown fragment ${selection.spread}`);
|
|
270
|
+
add(fieldsOf(fragment, fragments, new Set([...seen, selection.spread])).flatMap((f) => f.parts));
|
|
271
|
+
} else if (selection.inline) {
|
|
272
|
+
add(selection.inline);
|
|
273
|
+
} else {
|
|
274
|
+
const known = byKey.get(selection.key);
|
|
275
|
+
if (known) known.parts.push(selection);
|
|
276
|
+
else byKey.set(selection.key, { key: selection.key, field: selection.field, args: selection.args, parts: [selection] });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
};
|
|
280
|
+
add(selections);
|
|
281
|
+
return [...byKey.values()].map((f) => ({ ...f, selections: f.parts.flatMap((p) => p.selections) }));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** The response keys of the fields named `name` among `fields`. */
|
|
285
|
+
const keysNamed = (fields, name) => fields.filter((f) => f.field === name).map((f) => f.key);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Every connection `query` reads, by the path of response keys that leads to
|
|
289
|
+
* it (list positions left out): `{ "binItems": { nodes, edges, pageInfo,
|
|
290
|
+
* backward } }`, where `nodes` are the keys its entry lists answer under,
|
|
291
|
+
* `edges` each `{ key, cursor: [keys] }`, `pageInfo` each `{ key, fields: {
|
|
292
|
+
* hasNextPage: [keys], ... } }`, `backward` is true for a page read with
|
|
293
|
+
* `last` or `before` (a root field; a relation list pages forward only), and
|
|
294
|
+
* `first` is the page size the document asks for, or null. A
|
|
295
|
+
* connection is a field whose selection reads `nodes` or `edges`.
|
|
296
|
+
* `at` prefixes every path with where the answer holds the data (`data`).
|
|
297
|
+
* `null` when the document cannot be read or names no single operation.
|
|
298
|
+
*/
|
|
299
|
+
export function connectionsOf(query, variables, operationName, { at: prefix = "" } = {}) {
|
|
300
|
+
let parsed;
|
|
301
|
+
try {
|
|
302
|
+
parsed = parse(String(query));
|
|
303
|
+
} catch (error) {
|
|
304
|
+
if (error instanceof Unreadable || error instanceof SyntaxError) return null;
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
const { operations, fragments } = parsed;
|
|
308
|
+
const operation = operationName ? operations.find((o) => o.name === operationName) : operations.length === 1 ? operations[0] : undefined;
|
|
309
|
+
if (!operation) return null;
|
|
310
|
+
const found = {};
|
|
311
|
+
const visit = (selections, path, depth) => {
|
|
312
|
+
// `path` is the prefix at depth 0, and each field's response key below it.
|
|
313
|
+
for (const field of fieldsOf(selections, fragments)) {
|
|
314
|
+
if (!field.selections.length) continue;
|
|
315
|
+
const at = path ? `${path}.${field.key}` : field.key;
|
|
316
|
+
const inner = fieldsOf(field.selections, fragments);
|
|
317
|
+
const nodes = keysNamed(inner, "nodes");
|
|
318
|
+
const edges = inner.filter((f) => f.field === "edges").map((f) => ({ key: f.key, cursor: keysNamed(fieldsOf(f.selections, fragments), "cursor") }));
|
|
319
|
+
if (nodes.length || edges.length) {
|
|
320
|
+
const arg = (name) => resolve(field.args[name], variables, operation.variables);
|
|
321
|
+
found[at] = {
|
|
322
|
+
nodes,
|
|
323
|
+
edges,
|
|
324
|
+
pageInfo: inner
|
|
325
|
+
.filter((f) => f.field === "pageInfo")
|
|
326
|
+
.map((f) => {
|
|
327
|
+
const pageFields = fieldsOf(f.selections, fragments);
|
|
328
|
+
return { key: f.key, fields: Object.fromEntries(PAGE_FIELDS.map((name) => [name, keysNamed(pageFields, name)])) };
|
|
329
|
+
}),
|
|
330
|
+
backward: depth === 0 && (arg("last") != null || arg("before") != null),
|
|
331
|
+
first: typeof arg("first") === "number" ? arg("first") : null,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
visit(field.selections, at, depth + 1);
|
|
335
|
+
}
|
|
336
|
+
};
|
|
337
|
+
try {
|
|
338
|
+
visit(operation.selections, prefix, 0);
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error instanceof Unreadable) return null;
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
return found;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** The fields a selection set writes itself, through inline fragments; a named fragment is shared, so it is left out. */
|
|
347
|
+
function writtenFields(selections) {
|
|
348
|
+
return selections.flatMap((selection) => (selection.inline ? writtenFields(selection.inline) : selection.spread !== undefined ? [] : [selection]));
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* `query` reading on the relation list at `path` from `cursor`: that
|
|
353
|
+
* connection's `after` set, or replaced, where the document writes the field,
|
|
354
|
+
* or through its variable when the document passes `after` as one. `path` is
|
|
355
|
+
* response keys from the root field (`article.coauthors`). Returns `{ query,
|
|
356
|
+
* variables }`, or null when the document cannot be read or the list is not
|
|
357
|
+
* one field the operation writes once: a field inside a named fragment is
|
|
358
|
+
* shared by every place the fragment is spread, so it is never edited.
|
|
359
|
+
*/
|
|
360
|
+
export function withAfter(query, variables, operationName, path, cursor) {
|
|
361
|
+
let parsed;
|
|
362
|
+
try {
|
|
363
|
+
parsed = parse(String(query));
|
|
364
|
+
} catch (error) {
|
|
365
|
+
if (error instanceof Unreadable || error instanceof SyntaxError) return null;
|
|
366
|
+
throw error;
|
|
367
|
+
}
|
|
368
|
+
const { operations } = parsed;
|
|
369
|
+
const operation = operationName ? operations.find((o) => o.name === operationName) : operations.length === 1 ? operations[0] : undefined;
|
|
370
|
+
if (!operation) return null;
|
|
371
|
+
let found = [];
|
|
372
|
+
let level = operation.selections;
|
|
373
|
+
for (const key of path.split(".")) {
|
|
374
|
+
found = writtenFields(level).filter((field) => field.key === key);
|
|
375
|
+
if (!found.length) return null;
|
|
376
|
+
level = found.flatMap((field) => field.selections);
|
|
377
|
+
}
|
|
378
|
+
if (found.length !== 1) return null;
|
|
379
|
+
const { args, written } = found[0];
|
|
380
|
+
if (args.after && "variable" in args.after) return { query, variables: { ...(variables ?? {}), [args.after.variable]: cursor } };
|
|
381
|
+
const text = `after: ${JSON.stringify(cursor)}`;
|
|
382
|
+
const edited = args.after
|
|
383
|
+
? query.slice(0, written.args.after.start) + text + query.slice(written.args.after.end)
|
|
384
|
+
: written.close !== null
|
|
385
|
+
? `${query.slice(0, written.close)}, ${text}${query.slice(written.close)}`
|
|
386
|
+
: `${query.slice(0, written.nameEnd)}(${text})${query.slice(written.nameEnd)}`;
|
|
387
|
+
return { query: edited, variables };
|
|
388
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* filter-values.mjs — filter values as the scalar types the schema declares.
|
|
3
|
+
*
|
|
4
|
+
* GraphQL refuses a variable of the wrong type outright, and an agent writes
|
|
5
|
+
* a filter the way it reads: `{ "a_bool": { "has": "true" } }` on a list of
|
|
6
|
+
* true/false values, whose filter input the schema declares as
|
|
7
|
+
* `BooleanListFilter` (spec 17, amendment 59), is refused as written. So every
|
|
8
|
+
* filter `capa_graphql_build` sends is converted to the operand types its
|
|
9
|
+
* input declares: `"true"` goes as `true`, `"10"` on a number as `10`. A value
|
|
10
|
+
* that cannot be converted is left as written, and the API refuses it with its
|
|
11
|
+
* own message.
|
|
12
|
+
*
|
|
13
|
+
* THE SAME CONVERSION AS `@capacms/sdk`'s filter-values.ts, written again here
|
|
14
|
+
* because this package has no dependencies; the shared vector file pins both.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const LIST_OPERATORS = new Set(["in", "nin", "hasAny", "hasAll"]);
|
|
18
|
+
/** The operand types of a system filter (`IDFilter`, `DateTimeFilter`). */
|
|
19
|
+
const SYSTEM_OPERANDS = {
|
|
20
|
+
eq: "String", ne: "String", lt: "String", lte: "String", gt: "String", gte: "String",
|
|
21
|
+
in: "String", nin: "String", exists: "Boolean", null: "Boolean",
|
|
22
|
+
};
|
|
23
|
+
const LOGIC_KEYS = new Set(["and", "or", "not"]);
|
|
24
|
+
|
|
25
|
+
/** A JSON object: a filter, or one field's operators. */
|
|
26
|
+
export const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
27
|
+
|
|
28
|
+
/** One operand as the GraphQL scalar `type`, when it reads as one. */
|
|
29
|
+
function toScalar(type, value) {
|
|
30
|
+
switch (type) {
|
|
31
|
+
case "Float":
|
|
32
|
+
case "Int":
|
|
33
|
+
return typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) ? Number(value) : value;
|
|
34
|
+
case "Boolean":
|
|
35
|
+
return value === "true" ? true : value === "false" ? false : value;
|
|
36
|
+
case "String":
|
|
37
|
+
case "ID":
|
|
38
|
+
case "DateTime":
|
|
39
|
+
return typeof value === "number" || typeof value === "boolean" ? String(value) : value;
|
|
40
|
+
default:
|
|
41
|
+
return value;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One operand of `operator` as `type`; a list operand item by item. */
|
|
46
|
+
function typedOperand(operator, value, type) {
|
|
47
|
+
if (value === null || value === undefined || !LIST_OPERATORS.has(operator)) return toScalar(type, value);
|
|
48
|
+
return (Array.isArray(value) ? value : [value]).map((item) => toScalar(type, item));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function typedOperations(operations, operands) {
|
|
52
|
+
return Object.fromEntries(Object.entries(operations).map(([op, value]) => [op, typedOperand(op, value, operands[op])]));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A relation's own operators typed, and each hop into its target typed by the target field's operands. */
|
|
56
|
+
function typedRelation(operands, target, operations) {
|
|
57
|
+
const out = {};
|
|
58
|
+
for (const [name, value] of Object.entries(operations)) {
|
|
59
|
+
const hop = target?.fields.find((f) => f.name === name);
|
|
60
|
+
out[name] =
|
|
61
|
+
isObject(value) && (hop || name === "id")
|
|
62
|
+
? typedOperations(value, hop ? hop.filterInputs ?? {} : SYSTEM_OPERANDS)
|
|
63
|
+
: typedOperand(name, value, operands[name]);
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A `<Type>Filter` value with every operand converted to the type the schema
|
|
70
|
+
* declares for it: through `and`, `or` and `not`, on system keys and on every
|
|
71
|
+
* field, and one hop into a relation's target. Keys keep the order written.
|
|
72
|
+
*/
|
|
73
|
+
export function typedFilter(summary, model, filter) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
76
|
+
if (LOGIC_KEYS.has(key)) {
|
|
77
|
+
const typed = (part) => (isObject(part) ? typedFilter(summary, model, part) : part);
|
|
78
|
+
out[key] = Array.isArray(value) ? value.map(typed) : typed(value);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (!isObject(value)) {
|
|
82
|
+
out[key] = value;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const field = model.fields.find((f) => f.name === key);
|
|
86
|
+
if (!field) out[key] = typedOperations(value, model.systemFilters.find((system) => system.name === key)?.filterInputs ?? SYSTEM_OPERANDS);
|
|
87
|
+
else if (field.kind === "relation" || field.kind === "relationList") {
|
|
88
|
+
out[key] = typedRelation(field.filterInputs ?? {}, summary.models.find((m) => m.namespace === field.target), value);
|
|
89
|
+
} else out[key] = typedOperations(value, field.filterInputs ?? {});
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* more.mjs — the relation lists in an answer that hold more entries than
|
|
3
|
+
* they show, and how to read on.
|
|
4
|
+
*
|
|
5
|
+
* A relation list inside an entry is a connection of its own, read `first`
|
|
6
|
+
* entries at a time (100 when the document gives no first, the API's
|
|
7
|
+
* default). Its pageInfo says whether more come next, but it sits deep in
|
|
8
|
+
* the answer, and a list whose document selected no pageInfo says nothing at
|
|
9
|
+
* all: bin-250 answered 100 of its 250 items, and nothing told the agent so.
|
|
10
|
+
* So the tools name each such list at the top of the answer.
|
|
11
|
+
*
|
|
12
|
+
* Reading on takes a one-entry read: the API pages a relation list with
|
|
13
|
+
* `after` only when every entry above it is one entry (the single root field,
|
|
14
|
+
* then single relations), since a cursor belongs to one entry's list. The
|
|
15
|
+
* entry to read is the one that holds the list, or, past single relations,
|
|
16
|
+
* the nearest entry above it in a list.
|
|
17
|
+
*/
|
|
18
|
+
import { NESTED_DEFAULT } from "./build.mjs";
|
|
19
|
+
|
|
20
|
+
/** Lists named in full; the note counts the rest. */
|
|
21
|
+
const LISTED = 5;
|
|
22
|
+
|
|
23
|
+
const join = (path, key) => (typeof key === "number" ? `${path}[${key}]` : path ? `${path}.${key}` : key);
|
|
24
|
+
const firstKey = (holder, keys) => keys.find((key) => holder?.[key] !== undefined);
|
|
25
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Every relation list in `data` (the answer's data, found at `at`) that holds
|
|
29
|
+
* more than it shows, as `{ path, shown, endCursor?, pageInfo?, entry? }`:
|
|
30
|
+
* `hasNextPage` true, or, with no pageInfo selected (`pageInfo: false`), as
|
|
31
|
+
* many entries as its page. `connections` is connectionsOf(query, ..., { at
|
|
32
|
+
* }); a root connection is left out, since its pageInfo is at the top of the
|
|
33
|
+
* answer already. `entry` is the id of the entry to read on from, when the
|
|
34
|
+
* document selected it.
|
|
35
|
+
*/
|
|
36
|
+
export function relationsWithMore(data, connections, at) {
|
|
37
|
+
if (!connections || !isObject(data)) return [];
|
|
38
|
+
const found = [];
|
|
39
|
+
for (const [shape, connection] of Object.entries(connections)) {
|
|
40
|
+
const keys = shape.slice(at.length + 1).split(".");
|
|
41
|
+
if (keys.length < 2) continue;
|
|
42
|
+
// `holder` is the entry to read on from: the root single field's entry,
|
|
43
|
+
// then each element of a list (an edge's entry is under node).
|
|
44
|
+
const walk = (value, index, path, holder) => {
|
|
45
|
+
if (Array.isArray(value)) {
|
|
46
|
+
value.forEach((item, i) => walk(item, index, join(path, i), keys[index] === "node" ? holder : item));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (!isObject(value)) return;
|
|
50
|
+
if (index === keys.length) {
|
|
51
|
+
const more = moreOf(value, connection);
|
|
52
|
+
if (more) found.push({ path, ...more, ...(typeof holder?.id === "string" ? { entry: holder.id } : {}) });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const key = keys[index];
|
|
56
|
+
const next = value[key];
|
|
57
|
+
if (next === undefined || next === null) return;
|
|
58
|
+
walk(next, index + 1, join(path, key), index === 0 || key === "node" ? next : holder);
|
|
59
|
+
};
|
|
60
|
+
walk(data, 0, at, null);
|
|
61
|
+
}
|
|
62
|
+
return found;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `{ shown, endCursor? }` when this connection holds more than it shows, else null. */
|
|
66
|
+
function moreOf(holder, connection) {
|
|
67
|
+
const listKey = firstKey(holder, [...connection.nodes, ...connection.edges.map((edge) => edge.key)]);
|
|
68
|
+
const shown = Array.isArray(holder[listKey]) ? holder[listKey].length : 0;
|
|
69
|
+
const info = connection.pageInfo.map(({ key, fields }) => ({ value: holder[key], fields })).find(({ value }) => isObject(value));
|
|
70
|
+
const says = info && firstKey(info.value, info.fields.hasNextPage);
|
|
71
|
+
if (says !== undefined) {
|
|
72
|
+
if (info.value[says] !== true) return null;
|
|
73
|
+
const cursorKey = firstKey(info.value, info.fields.endCursor);
|
|
74
|
+
const endCursor = cursorKey !== undefined ? info.value[cursorKey] : undefined;
|
|
75
|
+
return { shown, ...(typeof endCursor === "string" ? { endCursor } : {}) };
|
|
76
|
+
}
|
|
77
|
+
// No pageInfo to ask: a full page may hide more.
|
|
78
|
+
const page = typeof connection.first === "number" ? connection.first : NESTED_DEFAULT;
|
|
79
|
+
return shown >= page ? { shown, pageInfo: false } : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The lists that fit `budget` characters, cursors included, in order; the
|
|
84
|
+
* first one without its cursor when even that does not fit.
|
|
85
|
+
*/
|
|
86
|
+
export function listedWithin(lists, budget) {
|
|
87
|
+
const kept = [];
|
|
88
|
+
for (const list of lists.slice(0, LISTED)) {
|
|
89
|
+
if (JSON.stringify([...kept, list]).length > budget) break;
|
|
90
|
+
kept.push(list);
|
|
91
|
+
}
|
|
92
|
+
if (!kept.length && lists.length) {
|
|
93
|
+
const { endCursor: _cursor, next: _next, ...bare } = lists[0];
|
|
94
|
+
kept.push(bare);
|
|
95
|
+
}
|
|
96
|
+
return kept;
|
|
97
|
+
}
|