@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
package/lib/explore.mjs
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* explore.mjs — what a model's content actually looks like, measured.
|
|
3
|
+
*
|
|
4
|
+
* An agent asked to filter by category needs the categories that exist, not
|
|
5
|
+
* the field's type; one asked to fix a page needs to know that `views` is null
|
|
6
|
+
* on half the entries. This module reads up to a few hundred entries through
|
|
7
|
+
* GraphQL and reduces them to per-field statistics small enough for a context
|
|
8
|
+
* window: counts, ranges, the distinct values of an enum-like field, and how
|
|
9
|
+
* many related entries each entry points at.
|
|
10
|
+
*
|
|
11
|
+
* TWO READS OF EACH PAGE, BECAUSE A FILTER AND A QUERY SEE DIFFERENT THINGS.
|
|
12
|
+
* A filter (`{ null: true }`, `{ exists: true }`) tests what is stored. A
|
|
13
|
+
* query returns what resolves: a reference to a deleted or draft-only entry is
|
|
14
|
+
* null, and so is a value that does not fit its field's type (`"42"` in a
|
|
15
|
+
* number field). Counted from the query alone, both read as missing and the
|
|
16
|
+
* agent's next filter disagrees with what it was told. So each page GraphQL
|
|
17
|
+
* returned is read again over REST, relations unexpanded, for the same ids
|
|
18
|
+
* (`storedRequest`): every count comes from what is stored, and the two reads
|
|
19
|
+
* side by side say which references dangle and which values do not fit.
|
|
20
|
+
*
|
|
21
|
+
* REST passes a scalar through as stored, but it renders a media value and a
|
|
22
|
+
* reference, and shows as null what it cannot render: text in an image field,
|
|
23
|
+
* a media list that is not a list, a reference that is not an id. Only the
|
|
24
|
+
* API's filters still see such a value. So for those fields, the entries of a
|
|
25
|
+
* page that REST shows as null are read once more with `{ exists: true }`
|
|
26
|
+
* (`hiddenValueRequests`), and each one it matches stores a value of the
|
|
27
|
+
* wrong type.
|
|
28
|
+
*
|
|
29
|
+
* A relation list is rendered too, and REST renders one stored as null, as
|
|
30
|
+
* `[]`, or as anything that is not a list, all as an empty list. Two filters
|
|
31
|
+
* tell them apart, so the entries of a page whose list REST shows empty are
|
|
32
|
+
* read twice more: with `{ null: true }` (`nullListRequests`), which matches
|
|
33
|
+
* the missing ones, and with `not` of `has` an id no entry has
|
|
34
|
+
* (`listRequests`), which matches the ones stored as a list: a filter is
|
|
35
|
+
* three-valued, and `not` keeps a value that is not a list unknown
|
|
36
|
+
* (docs/api/entries.md). Those are empty, and the rest store something that
|
|
37
|
+
* is not a list, present and mistyped.
|
|
38
|
+
*
|
|
39
|
+
* The query is planned inside the API's limits. Its cost (5,000 per request)
|
|
40
|
+
* counts each entry once, each single relation once more, and each array
|
|
41
|
+
* relation FAN_OUT_CAP times, since it is read as at most that many ids, plus
|
|
42
|
+
* COUNT_COST for its totalCount; the page size shrinks to fit. At most
|
|
43
|
+
* MAX_EXPANSIONS relation fields can be expanded per query, so a model with
|
|
44
|
+
* more is measured on the first ones and the rest are reported as skipped.
|
|
45
|
+
*/
|
|
46
|
+
import { isNameable, writeName } from "./graphql/names.mjs";
|
|
47
|
+
|
|
48
|
+
export const FAN_OUT_CAP = 20;
|
|
49
|
+
/** REST's relation items per request, which GraphQL applies per root field. */
|
|
50
|
+
export const MAX_EXPANSIONS = 12;
|
|
51
|
+
const NODE_BUDGET = 5000;
|
|
52
|
+
/** A totalCount's cost: it reads entries the answer does not hold (spec 17, amendment 43). */
|
|
53
|
+
const COUNT_COST = 500;
|
|
54
|
+
const PAGE_MAX = 200;
|
|
55
|
+
const TOP_VALUES = 10;
|
|
56
|
+
const VALUE_CHARS = 60;
|
|
57
|
+
const SAMPLE_CHARS = 120;
|
|
58
|
+
|
|
59
|
+
const clip = (value, chars) => (typeof value === "string" && value.length > chars ? `${value.slice(0, chars)}...` : value);
|
|
60
|
+
|
|
61
|
+
const isExpansion = (field) => field.kind === "relation" || field.kind === "relationList";
|
|
62
|
+
|
|
63
|
+
/** Nodes one entry can cost: itself, one per single relation, FAN_OUT_CAP per array relation. */
|
|
64
|
+
export function nodesPerEntry(fields) {
|
|
65
|
+
const singles = fields.filter((f) => f.kind === "relation").length;
|
|
66
|
+
const lists = fields.filter((f) => f.kind === "relationList").length;
|
|
67
|
+
return 1 + singles + lists * FAN_OUT_CAP;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Entries per request that keeps `fields`, and the query's totalCount, inside the API's budget. */
|
|
71
|
+
export function pageSizeFor(fields) {
|
|
72
|
+
return Math.max(1, Math.min(PAGE_MAX, Math.floor((NODE_BUDGET - COUNT_COST) / nodesPerEntry(fields))));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `fields` split into what one query can measure and what it cannot: every
|
|
77
|
+
* field up to MAX_EXPANSIONS relations, in schema order, and the relations
|
|
78
|
+
* past that.
|
|
79
|
+
*/
|
|
80
|
+
export function measurableFields(fields) {
|
|
81
|
+
const measured = [];
|
|
82
|
+
const skipped = [];
|
|
83
|
+
let expansions = 0;
|
|
84
|
+
for (const field of fields) {
|
|
85
|
+
if (isExpansion(field) && expansions++ >= MAX_EXPANSIONS) skipped.push(field);
|
|
86
|
+
else measured.push(field);
|
|
87
|
+
}
|
|
88
|
+
return { measured, skipped };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function selectionFor(field) {
|
|
92
|
+
switch (field.kind) {
|
|
93
|
+
case "relation":
|
|
94
|
+
return `${field.name} { id }`;
|
|
95
|
+
case "relationList":
|
|
96
|
+
return `${field.name}(first: ${FAN_OUT_CAP}) { nodes { id } pageInfo { hasNextPage } }`;
|
|
97
|
+
case "media":
|
|
98
|
+
return `${field.name} { id type }`;
|
|
99
|
+
default:
|
|
100
|
+
return field.name;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* A namespace REST's grammars can name: every one but a namespace starting
|
|
106
|
+
* with `$`, which only `select=*` returns (names.mjs). One holding a
|
|
107
|
+
* character the grammars use is written quoted (`"price.usd"`). A field REST
|
|
108
|
+
* cannot name is measured on what GraphQL returns.
|
|
109
|
+
*/
|
|
110
|
+
export const restNameable = isNameable;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* The REST read of one page's stored values: the fields by namespace,
|
|
114
|
+
* relations unexpanded so a reference comes back whether or not its entry
|
|
115
|
+
* still resolves, for exactly the ids GraphQL returned (`$id` is the system
|
|
116
|
+
* id even beside a field called `id`). Null when there is nothing to read.
|
|
117
|
+
*/
|
|
118
|
+
export function storedRequest(model, fields, ids) {
|
|
119
|
+
const names = [...new Set(fields.map((f) => f.namespace).filter(restNameable))];
|
|
120
|
+
if (!names.length || !ids.length) return null;
|
|
121
|
+
return {
|
|
122
|
+
path: `/api/entries/${encodeURIComponent(model.namespace)}`,
|
|
123
|
+
query: { select: names.map(writeName).join(","), where: JSON.stringify({ $id: { in: ids } }), limit: ids.length },
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Field kinds REST renders rather than passes through, so a stored value of
|
|
129
|
+
* the wrong type reads as null over REST (this file's header). A relation
|
|
130
|
+
* list is not one: REST reads a list it cannot render as empty, as it reads a
|
|
131
|
+
* list stored empty or not at all (`nullListRequests`).
|
|
132
|
+
*/
|
|
133
|
+
const RENDERED_KINDS = new Set(["media", "relation", "id"]);
|
|
134
|
+
const LIST_KINDS = new Set(["relationList", "idList"]);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The REST reads that find stored values REST shows as null: for each field
|
|
138
|
+
* REST renders, the entries in `ids` whose stored row reads as null there,
|
|
139
|
+
* filtered with `{ exists: true }`, which tests what is stored. One read per
|
|
140
|
+
* field with such entries; `namespace` names the field each one is for.
|
|
141
|
+
*/
|
|
142
|
+
export function hiddenValueRequests(model, fields, storedById, ids) {
|
|
143
|
+
const requests = [];
|
|
144
|
+
for (const field of fields) {
|
|
145
|
+
if (!RENDERED_KINDS.has(field.kind) || !restNameable(field.namespace)) continue;
|
|
146
|
+
const shownNull = ids.filter((id) => {
|
|
147
|
+
const row = storedById.get(id);
|
|
148
|
+
return row !== undefined && Object.hasOwn(row, field.namespace) && isNull(row[field.namespace]);
|
|
149
|
+
});
|
|
150
|
+
if (!shownNull.length) continue;
|
|
151
|
+
requests.push({
|
|
152
|
+
namespace: field.namespace,
|
|
153
|
+
path: `/api/entries/${encodeURIComponent(model.namespace)}`,
|
|
154
|
+
query: {
|
|
155
|
+
select: writeName(field.namespace),
|
|
156
|
+
where: JSON.stringify({ $id: { in: shownNull }, [writeName(field.namespace)]: { exists: true } }),
|
|
157
|
+
limit: shownNull.length,
|
|
158
|
+
},
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return requests;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Whether REST shows an entry's relation list as holding no id. */
|
|
165
|
+
function shownEmpty(storedById, id, namespace) {
|
|
166
|
+
const row = storedById.get(id);
|
|
167
|
+
return row !== undefined && Object.hasOwn(row, namespace) && (idsOf(row[namespace]) ?? []).length === 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* For each relation list, one REST read of the entries in `ids` whose stored
|
|
172
|
+
* row holds no id, filtered by `condition(name)` beside `$id`, where `name`
|
|
173
|
+
* is the field's namespace as REST writes it. Each request's `namespace`
|
|
174
|
+
* names the field it is for.
|
|
175
|
+
*/
|
|
176
|
+
function emptyListRequests(model, fields, storedById, ids, condition) {
|
|
177
|
+
const requests = [];
|
|
178
|
+
for (const field of fields) {
|
|
179
|
+
if (!LIST_KINDS.has(field.kind) || !restNameable(field.namespace)) continue;
|
|
180
|
+
const shown = ids.filter((id) => shownEmpty(storedById, id, field.namespace));
|
|
181
|
+
if (!shown.length) continue;
|
|
182
|
+
requests.push({
|
|
183
|
+
namespace: field.namespace,
|
|
184
|
+
path: `/api/entries/${encodeURIComponent(model.namespace)}`,
|
|
185
|
+
query: { select: writeName(field.namespace), where: JSON.stringify({ $id: { in: shown }, ...condition(writeName(field.namespace)) }), limit: shown.length },
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return requests;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The REST reads that find relation lists stored as null among those REST
|
|
193
|
+
* shows empty, with `{ null: true }`, which tests what is stored.
|
|
194
|
+
*/
|
|
195
|
+
export function nullListRequests(model, fields, storedById, ids) {
|
|
196
|
+
return emptyListRequests(model, fields, storedById, ids, (namespace) => ({ [namespace]: { null: true } }));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The id `listRequests` asks a list not to hold. Any UUID would do, as a
|
|
201
|
+
* relation list filter wants one: a list REST shows empty holds no id in
|
|
202
|
+
* either form `has` reads.
|
|
203
|
+
*/
|
|
204
|
+
export const NO_ENTRY_ID = "00000000-0000-4000-8000-000000000000";
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* The REST reads that find relation lists stored as a list among those REST
|
|
208
|
+
* shows empty, with `not` of `has` NO_ENTRY_ID: every stored list passes it,
|
|
209
|
+
* and `not` keeps a value that is not a list, or none, unknown, so neither
|
|
210
|
+
* does (this file's header).
|
|
211
|
+
*/
|
|
212
|
+
export function listRequests(model, fields, storedById, ids) {
|
|
213
|
+
return emptyListRequests(model, fields, storedById, ids, (namespace) => ({ not: { [namespace]: { has: NO_ENTRY_ID } } }));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The exploration query for `fields` of `model`, paged by `$after`. `status` is always the system field (N5 renames a field called status). */
|
|
217
|
+
export function exploreQuery(model, fields, first) {
|
|
218
|
+
const selections = ["id", "status", ...fields.map(selectionFor)].join(" ");
|
|
219
|
+
return (
|
|
220
|
+
`query CapaExplore($after: String) { ${model.listField}(first: ${first}, after: $after) ` +
|
|
221
|
+
`{ nodes { ${selections} } pageInfo { hasNextPage endCursor } totalCount } }`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function scalarKind(field) {
|
|
226
|
+
const base = field.graphqlType.replace(/[[\]!]/g, "");
|
|
227
|
+
if (field.graphqlType.startsWith("[")) return base === "Float" || base === "Int" ? "numberList" : "list";
|
|
228
|
+
if (base === "Float" || base === "Int") return "number";
|
|
229
|
+
if (base === "Boolean") return "boolean";
|
|
230
|
+
if (base === "DateTime") return "date";
|
|
231
|
+
if (base === "String") return "string";
|
|
232
|
+
return "other";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function countTop(values, limit) {
|
|
236
|
+
const counts = new Map();
|
|
237
|
+
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
238
|
+
const sorted = [...counts].sort((a, b) => b[1] - a[1] || String(a[0]).localeCompare(String(b[0])));
|
|
239
|
+
return { distinct: counts.size, top: sorted.slice(0, limit).map(([value, count]) => [clip(value, VALUE_CHARS), count]) };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const round = (n) => Math.round(n * 100) / 100;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* ISO 8601 as a stored date can be written: a four-digit or signed six-digit
|
|
246
|
+
* year, an optional time with any number of fraction digits, and `Z` or an
|
|
247
|
+
* offset (`+14:00`, `-0800`, `+05`). A date alone is midnight UTC.
|
|
248
|
+
*/
|
|
249
|
+
const ISO_INSTANT =
|
|
250
|
+
/^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?(Z|[+-]\d{2}(?::?\d{2})?)?)?$/i;
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* A date string as the instant it names, comparable across offsets: whole
|
|
254
|
+
* seconds since the epoch, then the fraction as nine digits. Text order gets
|
|
255
|
+
* both wrong: `9999-12-31T23:59:59.999-14:00` is in year 10000 UTC, after
|
|
256
|
+
* `9999-12-31T23:59:59.999Z`, and `2024-01-01T00:00:00+14:00` is before
|
|
257
|
+
* `2023-12-31T12:00:00Z`.
|
|
258
|
+
* Null for text that is not such a date.
|
|
259
|
+
*/
|
|
260
|
+
export function instantOf(text) {
|
|
261
|
+
const match = typeof text === "string" ? ISO_INSTANT.exec(text.trim()) : null;
|
|
262
|
+
if (!match) return null;
|
|
263
|
+
const [, year, month, day, hour = "0", minute = "0", second = "0", fraction = "", zone = "Z"] = match;
|
|
264
|
+
const date = new Date(0);
|
|
265
|
+
date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
|
|
266
|
+
if (date.getUTCMonth() !== Number(month) - 1 || date.getUTCDate() !== Number(day)) return null;
|
|
267
|
+
date.setUTCHours(Number(hour), Number(minute), Number(second), 0);
|
|
268
|
+
let offsetMinutes = 0;
|
|
269
|
+
if (zone.toUpperCase() !== "Z") {
|
|
270
|
+
const digits = zone.slice(1).replace(":", "");
|
|
271
|
+
offsetMinutes = (zone[0] === "-" ? -1 : 1) * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2) || 0));
|
|
272
|
+
}
|
|
273
|
+
const seconds = date.getTime() / 1000 - offsetMinutes * 60;
|
|
274
|
+
return Number.isFinite(seconds) ? { seconds, fraction: fraction.padEnd(9, "0").slice(0, 9) } : null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function compareInstants(a, b) {
|
|
278
|
+
return a.seconds - b.seconds || (a.fraction < b.fraction ? -1 : a.fraction > b.fraction ? 1 : 0);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The earliest and latest of `dates` by instant, each as written; text that is no date is left out. */
|
|
282
|
+
export function dateRange(dates) {
|
|
283
|
+
const instants = dates.map((text) => ({ text, at: instantOf(text) })).filter((d) => d.at !== null);
|
|
284
|
+
if (!instants.length) return {};
|
|
285
|
+
instants.sort((a, b) => compareInstants(a.at, b.at) || (a.text < b.text ? -1 : a.text > b.text ? 1 : 0));
|
|
286
|
+
return { earliest: instants[0].text, latest: instants[instants.length - 1].text };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function numberStats(numbers) {
|
|
290
|
+
if (!numbers.length) return {};
|
|
291
|
+
const sorted = [...numbers].sort((a, b) => a - b);
|
|
292
|
+
const mid = sorted.length >> 1;
|
|
293
|
+
return {
|
|
294
|
+
min: sorted[0],
|
|
295
|
+
max: sorted[sorted.length - 1],
|
|
296
|
+
mean: round(numbers.reduce((a, b) => a + b, 0) / numbers.length),
|
|
297
|
+
median: sorted.length % 2 ? sorted[mid] : round((sorted[mid - 1] + sorted[mid]) / 2),
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** What the counts of every field mean, once per answer. */
|
|
302
|
+
export const COUNTS_LEGEND =
|
|
303
|
+
"Counts are of what is stored, so they agree with filters. present: has a value. empty: blank text or an empty list. " +
|
|
304
|
+
"missing: null or absent, what { null: true } matches; { exists: true } matches present and empty. The three add up to scanned. " +
|
|
305
|
+
"A relation list stored as something other than a list is present and mistyped. dangling: stored references a query returns nothing for " +
|
|
306
|
+
"(deleted, draft only, another model). mistyped: stored values that do not fit the field's type, which a query returns as null. " +
|
|
307
|
+
"items and fanOut count the ids stored.";
|
|
308
|
+
|
|
309
|
+
const isNull = (value) => value === null || value === undefined;
|
|
310
|
+
|
|
311
|
+
/** The ids of a relation list or id list, as stored (REST `{ items }`) or as served (a connection's `nodes`, or ids). */
|
|
312
|
+
function idsOf(value) {
|
|
313
|
+
if (Array.isArray(value)) return value.map((item) => (typeof item === "string" ? item : item?.id));
|
|
314
|
+
if (Array.isArray(value?.items)) return value.items.map((item) => item.id);
|
|
315
|
+
if (Array.isArray(value?.nodes)) return value.nodes.map((node) => node.id);
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** Blank text, an empty list, or a relation list with no ids: a value that holds nothing. */
|
|
320
|
+
function isEmpty(value) {
|
|
321
|
+
if (typeof value === "string") return value.trim() === "";
|
|
322
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
323
|
+
if (Array.isArray(value?.items)) return value.items.length === 0;
|
|
324
|
+
if (value && typeof value === "object" && Array.isArray(value.nodes)) return value.nodes.length === 0 && value.pageInfo?.hasNextPage !== true;
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The id a single reference holds: REST's `{ id, model }`, a served entry, or a bare id. */
|
|
329
|
+
const idOf = (value) => (typeof value === "string" ? value : value?.id);
|
|
330
|
+
|
|
331
|
+
/** A stored value a query returns as null, or a list some of whose stored items it returns as null. */
|
|
332
|
+
function mistyped({ stored, served }) {
|
|
333
|
+
if (isNull(served)) return true;
|
|
334
|
+
return Array.isArray(stored) && Array.isArray(served) && served.some((item, i) => isNull(item) && !isNull(stored[i]));
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** Text as a query returns it: a number or true/false stored in a text field is still text. */
|
|
338
|
+
const asText = (value) => (typeof value === "string" ? value : typeof value === "number" || typeof value === "boolean" ? String(value) : null);
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Statistics for one field over the scanned entries. Pure.
|
|
342
|
+
*
|
|
343
|
+
* `nodes` are the entries as GraphQL returned them, and `storedById` each
|
|
344
|
+
* one's fields as REST says they are stored, by namespace. Every count comes
|
|
345
|
+
* from the stored value, so it agrees with a filter; the served value beside
|
|
346
|
+
* it says which references resolve (`dangling`) and which values fit their
|
|
347
|
+
* type (`mistyped`). `hiddenIds` are the entries whose value REST shows as
|
|
348
|
+
* null although `{ exists: true }` matches it (`hiddenValueRequests`): each
|
|
349
|
+
* stores a value of the wrong type, counted present and mistyped, since what
|
|
350
|
+
* it holds cannot be read. `nullIds` are the entries whose relation list REST
|
|
351
|
+
* shows empty and `{ null: true }` matches (`nullListRequests`): missing. An
|
|
352
|
+
* entry or field with no stored value read is measured on what GraphQL
|
|
353
|
+
* returned. `listIds`, when given, are the entries whose relation list REST
|
|
354
|
+
* shows empty that store a list (`listRequests`): any other entry REST shows
|
|
355
|
+
* empty and `{ null: true }` does not match stores something that is not a
|
|
356
|
+
* list, which is hidden like a wrong-typed media value. `present`, `empty`
|
|
357
|
+
* and `missing` are COUNTS_LEGEND's.
|
|
358
|
+
*/
|
|
359
|
+
export function fieldStats(field, nodes, storedById = new Map(), hiddenIds = new Set(), nullIds = new Set(), listIds = null) {
|
|
360
|
+
const notAList = (id) => listIds !== null && LIST_KINDS.has(field.kind) && shownEmpty(storedById, id, field.namespace) && !nullIds.has(id) && !listIds.has(id);
|
|
361
|
+
const entries = nodes.map((node) => {
|
|
362
|
+
const served = node[field.name] ?? null;
|
|
363
|
+
const row = storedById.get(node.id);
|
|
364
|
+
const read = row && Object.hasOwn(row, field.namespace) ? row[field.namespace] ?? null : served;
|
|
365
|
+
const stored = nullIds.has(node.id) ? null : read;
|
|
366
|
+
return { stored, served, hidden: (isNull(stored) && hiddenIds.has(node.id)) || notAList(node.id) };
|
|
367
|
+
});
|
|
368
|
+
const given = entries.filter((e) => e.hidden || !isNull(e.stored));
|
|
369
|
+
const present = given.filter((e) => e.hidden || !isEmpty(e.stored));
|
|
370
|
+
// The values REST can show; a hidden one is only known to be there.
|
|
371
|
+
const readable = present.filter((e) => !e.hidden);
|
|
372
|
+
const withHidden = (out) => (present.length > readable.length ? { ...out, mistyped: present.length - readable.length } : out);
|
|
373
|
+
const stats = {
|
|
374
|
+
field: field.name,
|
|
375
|
+
type: field.arrayType ? `${field.capaType} of ${field.arrayType}` : field.capaType,
|
|
376
|
+
present: present.length,
|
|
377
|
+
empty: given.length - present.length,
|
|
378
|
+
missing: entries.length - given.length,
|
|
379
|
+
};
|
|
380
|
+
if (field.namespace !== field.name) stats.namespace = field.namespace;
|
|
381
|
+
|
|
382
|
+
switch (field.kind) {
|
|
383
|
+
case "relation":
|
|
384
|
+
return withHidden({ ...stats, target: field.target, dangling: readable.filter((e) => isNull(e.served)).length, ...countTop(readable.map((e) => idOf(e.stored)), 5) });
|
|
385
|
+
case "id":
|
|
386
|
+
// A target GraphQL cannot expand (unreadable, or left out of GraphQL) is still named, as for a list of ids.
|
|
387
|
+
return withHidden({ ...stats, target: field.target ?? undefined, ...countTop(readable.map((e) => idOf(e.stored)), 5) });
|
|
388
|
+
case "relationList":
|
|
389
|
+
case "idList": {
|
|
390
|
+
// A list stored as something else holds no id, so it points at nothing and dangles nowhere.
|
|
391
|
+
const lists = given.map((e) => ({ ids: e.hidden ? [] : idsOf(e.stored) ?? [], served: e.served }));
|
|
392
|
+
// fanOut is how many entries each scanned entry points at, so a list stored as null (missing) counts as 0.
|
|
393
|
+
const fanOut = numberStats(entries.map((e) => (e.hidden ? [] : idsOf(e.stored) ?? []).length));
|
|
394
|
+
const out = { ...stats, target: field.target ?? undefined, items: lists.reduce((sum, l) => sum + l.ids.length, 0), fanOut };
|
|
395
|
+
if (field.kind === "idList") return withHidden(out);
|
|
396
|
+
// A query reads the first FAN_OUT_CAP stored ids of each entry and leaves out those that do not resolve.
|
|
397
|
+
out.dangling = lists.reduce((sum, l) => sum + Math.min(l.ids.length, FAN_OUT_CAP) - (l.served?.nodes?.length ?? 0), 0);
|
|
398
|
+
const longer = lists.filter((l) => l.ids.length > FAN_OUT_CAP).length;
|
|
399
|
+
if (longer) out.danglingChecked = `the first ${FAN_OUT_CAP} ids of each entry; ${longer} store more`;
|
|
400
|
+
return withHidden(out);
|
|
401
|
+
}
|
|
402
|
+
case "media": {
|
|
403
|
+
// The kinds of file across every item: a list of media is an array of { id, type }.
|
|
404
|
+
const items = readable.flatMap((e) => (Array.isArray(e.stored) ? e.stored : [e.stored])).filter((v) => !isNull(v));
|
|
405
|
+
const out = { ...stats, mediaTypes: countTop(items.map((v) => v.type ?? "unknown"), TOP_VALUES).top };
|
|
406
|
+
if (!field.arrayType) return withHidden(out);
|
|
407
|
+
return withHidden({ ...out, items: items.length, perEntry: numberStats(given.map((e) => e.stored).filter(Array.isArray).map((l) => l.length)) });
|
|
408
|
+
}
|
|
409
|
+
case "json":
|
|
410
|
+
return stats;
|
|
411
|
+
default:
|
|
412
|
+
break;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const wrong = present.filter(mistyped).length;
|
|
416
|
+
const withMistyped = (out) => (wrong ? { ...out, mistyped: wrong } : out);
|
|
417
|
+
const values = present.map((e) => e.stored);
|
|
418
|
+
switch (scalarKind(field)) {
|
|
419
|
+
case "number":
|
|
420
|
+
return withMistyped({ ...stats, ...numberStats(values.filter((v) => typeof v === "number" && Number.isFinite(v))) });
|
|
421
|
+
case "boolean":
|
|
422
|
+
return withMistyped({ ...stats, true: values.filter((v) => v === true).length, false: values.filter((v) => v === false).length });
|
|
423
|
+
case "date":
|
|
424
|
+
return withMistyped({ ...stats, ...dateRange(values) });
|
|
425
|
+
case "string": {
|
|
426
|
+
const strings = values.map(asText).filter((v) => v !== null);
|
|
427
|
+
const { distinct, top } = countTop(strings, TOP_VALUES);
|
|
428
|
+
const lengths = strings.map((s) => s.length);
|
|
429
|
+
const out = { ...stats, distinct };
|
|
430
|
+
const enumLike = field.capaType === "enum" || (distinct <= TOP_VALUES * 2 && distinct <= Math.max(1, strings.length / 2));
|
|
431
|
+
if (enumLike) out.values = top;
|
|
432
|
+
else out.examples = top.slice(0, 3).map(([value]) => value);
|
|
433
|
+
if (lengths.length) out.length = { min: Math.min(...lengths), max: Math.max(...lengths) };
|
|
434
|
+
return withMistyped(out);
|
|
435
|
+
}
|
|
436
|
+
case "list":
|
|
437
|
+
case "numberList": {
|
|
438
|
+
const arrays = given.map((e) => e.stored).filter(Array.isArray);
|
|
439
|
+
const items = arrays.flat().filter((v) => !isNull(v));
|
|
440
|
+
const { distinct, top } = countTop(items, TOP_VALUES);
|
|
441
|
+
return withMistyped({ ...stats, items: items.length, perEntry: numberStats(arrays.map((l) => l.length)), distinct, values: top });
|
|
442
|
+
}
|
|
443
|
+
default:
|
|
444
|
+
return withMistyped(stats);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/** How many of the scanned entries are in each status, in the order an entry moves through them. */
|
|
449
|
+
export function statusCounts(nodes) {
|
|
450
|
+
const counts = {};
|
|
451
|
+
for (const status of ["published", "changed", "draft"]) {
|
|
452
|
+
const count = nodes.filter((node) => node.status === status).length;
|
|
453
|
+
if (count) counts[status] = count;
|
|
454
|
+
}
|
|
455
|
+
return counts;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** The first `count` entries, long strings clipped, relations as ids. */
|
|
459
|
+
export function samples(nodes, fields, count) {
|
|
460
|
+
return nodes.slice(0, count).map((node) => {
|
|
461
|
+
const out = { id: node.id };
|
|
462
|
+
for (const field of fields) {
|
|
463
|
+
const value = node[field.name];
|
|
464
|
+
if (field.kind === "relation") out[field.name] = value?.id ?? null;
|
|
465
|
+
else if (field.kind === "relationList") out[field.name] = value?.nodes?.map((n) => n.id) ?? [];
|
|
466
|
+
else if (Array.isArray(value)) out[field.name] = value.map((v) => clip(v, SAMPLE_CHARS));
|
|
467
|
+
else out[field.name] = clip(value, SAMPLE_CHARS);
|
|
468
|
+
}
|
|
469
|
+
return out;
|
|
470
|
+
});
|
|
471
|
+
}
|