@johnhenry/graceholder 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +492 -2
- package/STABILITY.md +24 -0
- package/package.json +126 -4
- package/src/adapters/apollo.d.ts +13 -0
- package/src/adapters/apollo.js +48 -0
- package/src/adapters/fetch.d.ts +15 -0
- package/src/adapters/fetch.js +150 -0
- package/src/adapters/shared.d.ts +14 -0
- package/src/adapters/shared.js +65 -0
- package/src/adapters/urql.d.ts +8 -0
- package/src/adapters/urql.js +53 -0
- package/src/anchor.js +13 -0
- package/src/args.js +41 -0
- package/src/bulk/csv.js +118 -0
- package/src/bulk/ndjson.js +10 -0
- package/src/bulk/plan.js +195 -0
- package/src/bulk/shape.js +67 -0
- package/src/bulk/source.js +24 -0
- package/src/bulk/sql.js +366 -0
- package/src/bulk/stream.js +55 -0
- package/src/cursor.js +25 -0
- package/src/dbg.d.ts +720 -0
- package/src/detmath.js +74 -0
- package/src/directives.js +113 -0
- package/src/dist.js +90 -0
- package/src/errors.js +17 -0
- package/src/execute.js +577 -0
- package/src/explain.js +473 -0
- package/src/fast-check.d.ts +31 -0
- package/src/fast-check.js +58 -0
- package/src/generate.js +84 -0
- package/src/generator-types.d.ts +90 -0
- package/src/generators.js +361 -0
- package/src/image.js +55 -0
- package/src/index.d.ts +507 -0
- package/src/index.js +62 -0
- package/src/infer.js +888 -0
- package/src/key.js +30 -0
- package/src/learn.js +397 -0
- package/src/naming.js +74 -0
- package/src/pack-registry.js +59 -0
- package/src/packs/PROVENANCE.md +26 -0
- package/src/packs/commerce.d.ts +4 -0
- package/src/packs/commerce.js +83 -0
- package/src/packs/finance.d.ts +4 -0
- package/src/packs/finance.js +180 -0
- package/src/packs/geo.d.ts +4 -0
- package/src/packs/geo.js +227 -0
- package/src/packs/health.d.ts +4 -0
- package/src/packs/health.js +76 -0
- package/src/packs/index.d.ts +7 -0
- package/src/packs/index.js +9 -0
- package/src/rng.js +89 -0
- package/src/rules.js +228 -0
- package/src/scenario.js +120 -0
- package/src/schema.js +148 -0
- package/src/spec.js +235 -0
- package/src/suggest.js +138 -0
- package/src/typed.d.ts +723 -0
- package/src/vocab/PROVENANCE.md +12 -0
- package/src/vocab/en.js +106 -0
- package/src/world.js +461 -0
- package/src/xxh64.js +87 -0
package/src/explain.js
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// explain(query, path, options): why does the value at a path look the way it does? (docs/design.md section 20)
|
|
2
|
+
//
|
|
3
|
+
// The query is run once, exactly as graceholder() runs it, with a `probe` that records what each resolver
|
|
4
|
+
// on the way to the value did (src/execute.js). Nothing here draws a random number or changes an output:
|
|
5
|
+
// the probe only reads. The explanation is assembled from that trace and from the inference result.
|
|
6
|
+
import { ScenarioError } from "./scenario.js";
|
|
7
|
+
import { GraceholderError } from "./errors.js";
|
|
8
|
+
import { prepare } from "./generate.js";
|
|
9
|
+
import { run } from "./execute.js";
|
|
10
|
+
import { keyString, hashKey, KEY_SCHEME } from "./key.js";
|
|
11
|
+
import { specTrace } from "./spec.js";
|
|
12
|
+
import { classifyScalar, listRule } from "./rules.js";
|
|
13
|
+
import { tokenize } from "./naming.js";
|
|
14
|
+
import { PAGING_ARGS } from "./args.js";
|
|
15
|
+
import { toDocument, selectOperation } from "./infer.js";
|
|
16
|
+
|
|
17
|
+
const bad = (msg, extra) => new GraceholderError("bad-path", msg, extra);
|
|
18
|
+
|
|
19
|
+
/** One line per inference rule (docs/design.md section 4.2), so an explanation can say what a rule id means. */
|
|
20
|
+
export const RULE_TEXT = Object.freeze({
|
|
21
|
+
L1: "list or single value forced by @list/@one or a spec entry",
|
|
22
|
+
L2: "a paging argument (first, last, limit, offset, skip, take, page, after) makes the field a list",
|
|
23
|
+
L3: "the last word of the field name is plural",
|
|
24
|
+
L4: "the name is list-like (list, all...)",
|
|
25
|
+
L5: "an ids argument or a list-literal argument",
|
|
26
|
+
L6: "no list rule matched: a single value",
|
|
27
|
+
L7: "realistic mode: a history/series/timeline/trend object is a time series",
|
|
28
|
+
N1: "type name given by @type or a spec entry",
|
|
29
|
+
N2: "type name from a fragment type condition",
|
|
30
|
+
N3: "type name from the singularised, PascalCased field name",
|
|
31
|
+
S3: "a Relay connection (edges, pageInfo, nodes with totalCount, or a ...Connection type)",
|
|
32
|
+
S4: "several type conditions make a union or an interface",
|
|
33
|
+
T1: "field named id or ending in Id: an ID",
|
|
34
|
+
T2: "__typename",
|
|
35
|
+
T3: "email-like name: person.email",
|
|
36
|
+
T4: "url-like name",
|
|
37
|
+
T5: "image-like name (avatar, photo, thumbnail...)",
|
|
38
|
+
T6: "phone-like name",
|
|
39
|
+
T7: "boolean-like name (is, has, can... or enabled, active...)",
|
|
40
|
+
T8: "integer-like name (count, age, quantity, views...)",
|
|
41
|
+
T9: "decimal-like name (price, total, rating, lat...)",
|
|
42
|
+
T10: "date-like name (createdAt, date, birthday...)",
|
|
43
|
+
T11: "a named string kind (name, city, status, bio, slug...)",
|
|
44
|
+
T13: "no name rule matched: two words",
|
|
45
|
+
T14: "realistic mode: a time-series point",
|
|
46
|
+
P1: "a vocabulary pack recognised the name",
|
|
47
|
+
gen: "generator given by @gen, @pattern, @pick or a spec entry",
|
|
48
|
+
derive: "derived from another field of the same entity",
|
|
49
|
+
"C-count": "a count of a sibling collection (<x>Count, total<X>s)",
|
|
50
|
+
schema: "taken from the schema",
|
|
51
|
+
"schema-enum": "an enum of the schema: one of its values",
|
|
52
|
+
"schema-builtin": "a built-in scalar of the schema",
|
|
53
|
+
"schema-scalar": "a custom scalar of the schema",
|
|
54
|
+
"schema-abstract": "an interface or union of the schema",
|
|
55
|
+
"unused-spec-path": "a spec path that matched no field",
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const ruleText = (id) => (id ? RULE_TEXT[id] ?? (id.startsWith("schema:") ? `${RULE_TEXT.schema}; ${RULE_TEXT[id.slice(7)] ?? id.slice(7)}` : undefined) : undefined);
|
|
59
|
+
|
|
60
|
+
/** Parse `users.3.email` / `users[3].email` / `["users", 3, "email"]` into response keys and indexes. */
|
|
61
|
+
export function parsePath(path) {
|
|
62
|
+
let parts;
|
|
63
|
+
if (Array.isArray(path)) parts = path.map((p) => (typeof p === "number" ? p : String(p)));
|
|
64
|
+
else if (typeof path === "string") parts = path.replace(/\[(\d+)\]/g, ".$1").split(".").filter((p) => p !== "");
|
|
65
|
+
else throw bad("a path is a string such as users.3.email, or an array of response keys and indexes");
|
|
66
|
+
if (parts.length === 0) throw bad("the path is empty", { hint: "name a response key, for example users.0.email" });
|
|
67
|
+
return parts.map((p) => {
|
|
68
|
+
if (typeof p === "number") {
|
|
69
|
+
if (!Number.isInteger(p) || p < 0) throw bad(`list indexes are non-negative integers, got ${p}`);
|
|
70
|
+
return p;
|
|
71
|
+
}
|
|
72
|
+
return /^(0|[1-9][0-9]*)$/.test(p) ? Number(p) : p;
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const dotted = (target) => target.map(String).join(".");
|
|
77
|
+
|
|
78
|
+
function valueAt(data, target) {
|
|
79
|
+
let cur = data;
|
|
80
|
+
for (const seg of target) {
|
|
81
|
+
if (cur === null || cur === undefined || typeof cur !== "object") return { found: false };
|
|
82
|
+
if (typeof seg === "number") {
|
|
83
|
+
if (!Array.isArray(cur) || seg >= cur.length) return { found: false };
|
|
84
|
+
} else if (Array.isArray(cur) || !Object.hasOwn(cur, seg)) return { found: false };
|
|
85
|
+
cur = cur[seg];
|
|
86
|
+
}
|
|
87
|
+
return { found: true, value: cur };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const hex = (n) => `0x${n.toString(16).padStart(16, "0")}`;
|
|
91
|
+
|
|
92
|
+
/** A key as the library builds it: the exact string, its parts and its XXH64 hash. */
|
|
93
|
+
function describeKey(k) {
|
|
94
|
+
const parts = [KEY_SCHEME, k.seed, k.scope, k.typeName, k.entityKey, k.fieldName, ...(k.subKeys ?? [])].map(String);
|
|
95
|
+
return { string: keyString(k), parts, hash: hex(hashKey(k)) };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const compact = (o) => {
|
|
99
|
+
for (const k of Object.keys(o)) if (o[k] === undefined) delete o[k];
|
|
100
|
+
return o;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** The names path (`users.email`, no aliases or indexes) of the fields on the way to the target. */
|
|
104
|
+
const namesPath = (events) => events.map((e) => e.fieldName).join(".");
|
|
105
|
+
|
|
106
|
+
function describeScenario(f, info, notes, globalNames, collection) {
|
|
107
|
+
const names = f.scenario ?? [];
|
|
108
|
+
if (names.length === 0) return null;
|
|
109
|
+
const source = info?.ovSrc?.scenario === "spec" ? "spec" : globalNames.length ? "option" : "spec";
|
|
110
|
+
let effect = "none: the field is unaffected";
|
|
111
|
+
const err = notes.find((n) => n.step === "scenario-errors");
|
|
112
|
+
if (err) effect = err.hit ? `the field fails: draw ${err.draw} < errorRate ${err.rate}` : `the field is spared: draw ${err.draw} >= errorRate ${err.rate}`;
|
|
113
|
+
if (notes.some((n) => n.step === "scenario-nulls")) effect = "the field is null (nulls scenario)";
|
|
114
|
+
if (collection && names.includes("empty")) effect = "the collection is forced to be empty (empty scenario)";
|
|
115
|
+
else if (collection && names.includes("huge")) effect = `the collection is forced to a huge size: ${collection.size} items (huge scenario)`;
|
|
116
|
+
const edge = notes.find((n) => n.step === "scenario-edge");
|
|
117
|
+
if (edge) effect = `replaced by a ${edge.mode} edge case (was ${JSON.stringify(edge.replaced)})`;
|
|
118
|
+
return { names: [...names], source, effect };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Candidate sources for one overridable property, in precedence order (docs/design.md section 5.3). */
|
|
122
|
+
function precedenceFor(prop, info, trace, heuristic) {
|
|
123
|
+
const candidates = [];
|
|
124
|
+
const winnerSource = info.ovSrc?.[prop];
|
|
125
|
+
if (winnerSource === "directive") candidates.push({ source: "directive", value: info.ov[prop] });
|
|
126
|
+
(trace[prop] ?? []).forEach((c) => candidates.push({ source: "spec", entry: c.key, matches: c.klass, value: c.value }));
|
|
127
|
+
if (heuristic) candidates.push({ source: "heuristic", ...heuristic });
|
|
128
|
+
const winner = candidates.findIndex((c) => c.source === "directive") >= 0 ? candidates.findIndex((c) => c.source === "directive") : candidates.findIndex((c) => c.source === "spec") >= 0 ? candidates.findIndex((c) => c.source === "spec") : candidates.length - 1;
|
|
129
|
+
return { property: prop, winner: candidates[winner]?.source, candidates: candidates.map((c, i) => ({ ...c, wins: i === winner })) };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Build an explanation from one traced run.
|
|
134
|
+
* @param {any} query
|
|
135
|
+
* @param {Array<string|number>} target
|
|
136
|
+
* @param {any} options
|
|
137
|
+
* @param {any} [world] the internal world state (src/world.js)
|
|
138
|
+
*/
|
|
139
|
+
export function explainWith(query, target, options, world) {
|
|
140
|
+
const doc = toDocument(query);
|
|
141
|
+
const op = selectOperation(doc, options.operationName);
|
|
142
|
+
void op;
|
|
143
|
+
const { inferred, runOptions } = prepare(doc, options, world);
|
|
144
|
+
/** @type {{ target: Array<string|number>, events: any[], notes: any[] }} */
|
|
145
|
+
const probe = { target, events: [], notes: [] };
|
|
146
|
+
const result = run(inferred, { ...runOptions, probe });
|
|
147
|
+
const scenarioErrors = result.errors?.filter((e) => e.originalError instanceof ScenarioError) ?? [];
|
|
148
|
+
const other = result.errors?.filter((e) => !(e.originalError instanceof ScenarioError)) ?? [];
|
|
149
|
+
if (other.length) {
|
|
150
|
+
const own = other.find((e) => e.originalError instanceof GraceholderError);
|
|
151
|
+
if (own) throw own.originalError;
|
|
152
|
+
throw new GraceholderError("graphql", other[0].message, { cause: other[0], errors: other });
|
|
153
|
+
}
|
|
154
|
+
const data = result.data === undefined || result.data === null ? result.data : JSON.parse(JSON.stringify(result.data));
|
|
155
|
+
const found = valueAt(data, target);
|
|
156
|
+
if (!found.found) {
|
|
157
|
+
// a missing value is explained by the first null (or failed) value on the way to it
|
|
158
|
+
for (let i = 1; i < target.length; i++) {
|
|
159
|
+
const prefix = target.slice(0, i);
|
|
160
|
+
const at = valueAt(data, prefix);
|
|
161
|
+
if (!at.found) break;
|
|
162
|
+
if (at.value === null || at.value === undefined) return unavailable(target, prefix, probe, scenarioErrors, inferred, world);
|
|
163
|
+
}
|
|
164
|
+
const events = probe.events.filter((e) => e.type === "field");
|
|
165
|
+
const where = events.length ? `${events.length} of ${target.length} segments exist` : "the first segment is not a response key of the query";
|
|
166
|
+
throw bad(`there is no value at ${dotted(target)} (${where})`, { path: dotted(target), hint: "paths use response keys (aliases) and zero-based list indexes, for example users.0.email" });
|
|
167
|
+
}
|
|
168
|
+
const here = scenarioErrors.find((e) => e.path && e.path.length === target.length && e.path.every((p, i) => String(p) === String(target[i])));
|
|
169
|
+
return assemble({ target, options, inferred, probe, value: found.value, error: here, world, runOptions });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The target does not exist because a value on the way is null or failed. */
|
|
173
|
+
function unavailable(target, prefix, probe, scenarioErrors, inferred, world) {
|
|
174
|
+
const key = dotted(prefix);
|
|
175
|
+
const err = scenarioErrors.find((e) => e.path && e.path.length === prefix.length && e.path.every((p, i) => String(p) === String(prefix[i])));
|
|
176
|
+
const group = probe.events.find((e) => e.type === "field" && dotted(e.path) === key);
|
|
177
|
+
const notes = probe.events.filter((e) => e.type === "ancestor-note" && dotted(e.path) === key);
|
|
178
|
+
const entity = probe.events.find((e) => e.type === "entity" && e.deleted && probe.events.indexOf(e) > probe.events.indexOf(group));
|
|
179
|
+
let because = "the value is null";
|
|
180
|
+
let by;
|
|
181
|
+
const errNote = notes.find((n) => n.step === "scenario-errors");
|
|
182
|
+
const nullNote = notes.find((n) => n.step === "nullRate");
|
|
183
|
+
if (err) {
|
|
184
|
+
because = `the field failed (${err.message}): the errors scenario drew ${errNote?.draw} < errorRate ${errNote?.rate}`;
|
|
185
|
+
by = compact({ scenario: "errors", message: err.message, draw: errNote?.draw, rate: errNote?.rate, key: errNote && keyString(errNote.key) });
|
|
186
|
+
} else if (notes.some((n) => n.step === "scenario-nulls")) {
|
|
187
|
+
because = "the nulls scenario made the field null";
|
|
188
|
+
by = { scenario: "nulls" };
|
|
189
|
+
} else if (nullNote?.hit) {
|
|
190
|
+
because = `nullRate ${nullNote.rate} hit (draw ${nullNote.draw})`;
|
|
191
|
+
by = { nullRate: nullNote.rate, draw: nullNote.draw, key: keyString(nullNote.key) };
|
|
192
|
+
} else if (entity) {
|
|
193
|
+
because = `the entity ${group?.f?.typeName}:${entity.key} was deleted in the world`;
|
|
194
|
+
by = { world: "deleted", entity: `${group?.f?.typeName}:${entity.key}` };
|
|
195
|
+
}
|
|
196
|
+
void inferred;
|
|
197
|
+
void world;
|
|
198
|
+
return { path: dotted(target), value: undefined, kind: "unavailable", blockedAt: key, because, by, summary: `${dotted(target)} does not exist: ${key} is null because ${because}.` };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function assemble({ target, options, inferred, probe, value, error, world, runOptions }) {
|
|
202
|
+
const fieldEvents = probe.events.filter((e) => e.type === "field");
|
|
203
|
+
const self = fieldEvents.find((e) => e.rel === "self" || e.rel === "element");
|
|
204
|
+
const lastAncestor = [...fieldEvents].reverse().find((e) => e.rel === "ancestor");
|
|
205
|
+
// the group of events of each resolver on the way: its field event and what the resolver decided
|
|
206
|
+
/** @type {any[]} */
|
|
207
|
+
const groups = [];
|
|
208
|
+
for (const e of probe.events) {
|
|
209
|
+
if (e.type === "field") groups.push({ field: e, extras: [] });
|
|
210
|
+
else groups.at(-1).extras.push(e);
|
|
211
|
+
}
|
|
212
|
+
const leaf = self ?? lastAncestor; // the resolver that produced the value (or the collection it is an element of)
|
|
213
|
+
const leafGroup = groups.find((g) => g.field === leaf);
|
|
214
|
+
const ancestors = groups.filter((g) => g.field.rel === "ancestor");
|
|
215
|
+
const ROOT = inferred.rootType ?? "Query";
|
|
216
|
+
const names = namesPath(groups.map((g) => g.field));
|
|
217
|
+
const info = inferred.byPath.get(names);
|
|
218
|
+
const f = leaf.f;
|
|
219
|
+
const isElementOfObjectList = !self; // the target is an item of a list of objects: no resolver ran for it
|
|
220
|
+
const out = { path: dotted(target), value: error ? undefined : value };
|
|
221
|
+
|
|
222
|
+
// ---- what kind of thing is at the path
|
|
223
|
+
let kind;
|
|
224
|
+
if (self?.rel === "element") kind = "list-element";
|
|
225
|
+
else if (isElementOfObjectList) kind = f.connection ? "connection-member" : "item";
|
|
226
|
+
else if (f.connection) kind = "connection";
|
|
227
|
+
else if (f.kind === "scalar") kind = f.list ? "scalar-list" : "scalar";
|
|
228
|
+
else kind = f.list ? "list" : "object";
|
|
229
|
+
out.kind = kind;
|
|
230
|
+
if (error) out.error = { message: error.message, extensions: error.extensions };
|
|
231
|
+
|
|
232
|
+
// ---- the field
|
|
233
|
+
const fieldInfo = compact({
|
|
234
|
+
name: leaf.fieldName,
|
|
235
|
+
alias: String(leaf.path.at(-1)) !== leaf.fieldName ? String(leaf.path.at(-1)) : undefined,
|
|
236
|
+
parentType: leaf.parent,
|
|
237
|
+
path: names,
|
|
238
|
+
type: f.kind === "scalar" ? f.sdl : f.typeName,
|
|
239
|
+
list: f.list,
|
|
240
|
+
nullable: Boolean(f.nullable || f.nullRate > 0 || (info && info.nullable)),
|
|
241
|
+
});
|
|
242
|
+
out.field = fieldInfo;
|
|
243
|
+
|
|
244
|
+
// ---- entity identity and how the owner was reached
|
|
245
|
+
/** @type {any[]} */
|
|
246
|
+
const how = [];
|
|
247
|
+
for (const g of ancestors) {
|
|
248
|
+
const ent = g.extras.find((x) => x.type === "entity");
|
|
249
|
+
const col = g.extras.find((x) => x.type === "collection");
|
|
250
|
+
if (ent) how.push(compact({ path: dotted(g.field.path), field: g.field.fieldName, via: "entity", mode: ent.mode, key: ent.key, shared: ent.mode !== "owned" && ent.mode !== "inherited", note: entityNote(ent) }));
|
|
251
|
+
else if (col) {
|
|
252
|
+
how.push(compact({ path: dotted(g.field.path), field: g.field.fieldName, via: col.style, index: col.index, key: col.childKey, shared: col.shared, size: col.count, window: { start: col.start, total: col.total, endless: col.endless }, countKey: col.countKey && keyString(col.countKey), declaredCount: col.declared ?? undefined }));
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
let ownerType;
|
|
256
|
+
let ownerKey;
|
|
257
|
+
let ownerShared;
|
|
258
|
+
if (isElementOfObjectList) {
|
|
259
|
+
// the target itself is an item of the last collection
|
|
260
|
+
const col = leafGroup.extras.find((x) => x.type === "collection");
|
|
261
|
+
ownerType = f.connection ? inferred.model.get(f.typeName)?.get("nodes")?.typeName ?? f.typeName : f.typeName;
|
|
262
|
+
ownerKey = col?.childKey;
|
|
263
|
+
ownerShared = col?.shared;
|
|
264
|
+
const entity = leafGroup.extras.find((x) => x.type === "entity");
|
|
265
|
+
if (entity) {
|
|
266
|
+
ownerKey = entity.key;
|
|
267
|
+
ownerShared = entity.mode !== "owned" && entity.mode !== "inherited";
|
|
268
|
+
}
|
|
269
|
+
} else if (kind === "object") {
|
|
270
|
+
const ent = leafGroup.extras.find((x) => x.type === "entity");
|
|
271
|
+
ownerType = f.typeName;
|
|
272
|
+
ownerKey = ent?.key;
|
|
273
|
+
ownerShared = ent ? ent.mode !== "owned" && ent.mode !== "inherited" : undefined;
|
|
274
|
+
} else if (kind === "list" || kind === "connection") {
|
|
275
|
+
ownerType = leaf.parent;
|
|
276
|
+
ownerKey = leaf.entityKey;
|
|
277
|
+
ownerShared = how.at(-1)?.shared;
|
|
278
|
+
} else {
|
|
279
|
+
ownerType = leaf.parent;
|
|
280
|
+
ownerKey = leaf.entityKey;
|
|
281
|
+
ownerShared = how.at(-1)?.shared;
|
|
282
|
+
}
|
|
283
|
+
if (leaf.parent === ROOT && !isElementOfObjectList && kind !== "object") {
|
|
284
|
+
out.entity = { identity: ROOT, type: ROOT, key: ROOT, shared: true, how: [], note: "a field of the query root" };
|
|
285
|
+
} else if (ownerKey !== undefined) {
|
|
286
|
+
out.entity = compact({ identity: `${ownerType}:${ownerKey}`, type: ownerType, key: String(ownerKey), shared: ownerShared, how });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// ---- collection details (lists, connections, items)
|
|
290
|
+
const col = leafGroup.extras.find((x) => x.type === "collection");
|
|
291
|
+
if (col && !isElementOfObjectList) {
|
|
292
|
+
out.collection = compact({ size: col.count, window: { start: col.start, total: col.total, endless: col.endless }, declaredCount: col.declared ?? undefined, countKey: col.countKey && keyString(col.countKey), members: col.shared === undefined ? undefined : col.shared ? "shared (drawn from the pool of the type)" : "owned by the parent entity" });
|
|
293
|
+
}
|
|
294
|
+
if (leafGroup.extras.find((x) => x.type === "scalar-list")) {
|
|
295
|
+
const sl = leafGroup.extras.find((x) => x.type === "scalar-list");
|
|
296
|
+
out.collection = compact({ size: sl.count, declaredCount: sl.declared ?? undefined, countKey: keyString(sl.countKey) });
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ---- Relay machinery has no generator of its own
|
|
300
|
+
if (f.special && SPECIAL_TEXT[f.special]) {
|
|
301
|
+
out.special = { name: f.special === "pageInfoField" ? leaf.fieldName : f.special, description: SPECIAL_TEXT[f.special] };
|
|
302
|
+
const conn = [...groups].reverse().flatMap((g) => g.extras).find((x) => x.type === "collection" && x.style === "connection");
|
|
303
|
+
if (conn) out.collection = compact({ size: conn.count, window: { start: conn.start, total: conn.total, endless: conn.endless }, declaredCount: conn.declared ?? undefined, countKey: conn.countKey && keyString(conn.countKey) });
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---- the value's own recipe (scalars)
|
|
307
|
+
const gen = probe.notes.find((n) => n.step === "generate" && n.depth === 0);
|
|
308
|
+
const worldValue = probe.notes.find((n) => n.step === "world-value" && n.depth === 0);
|
|
309
|
+
if (gen) {
|
|
310
|
+
out.key = describeKey(gen.key);
|
|
311
|
+
out.generator = { name: gen.gen, args: gen.args ?? {}, outputType: info?.sdl ?? f.sdl };
|
|
312
|
+
}
|
|
313
|
+
const slots = probe.notes.filter((n) => n.step === "slot" && n.depth === undefined);
|
|
314
|
+
if (gen) {
|
|
315
|
+
const persona = compact({
|
|
316
|
+
type: gen.personaType,
|
|
317
|
+
key: gen.personaKey,
|
|
318
|
+
inherited: gen.personaKey !== leaf.entityKey ? true : undefined,
|
|
319
|
+
slots: slots.length ? slots.map((s) => ({ name: s.name, key: keyString(s.key) })) : undefined,
|
|
320
|
+
pinned: gen.pinned ?? undefined,
|
|
321
|
+
});
|
|
322
|
+
if (persona.slots || persona.pinned) out.persona = persona;
|
|
323
|
+
}
|
|
324
|
+
const derive = probe.notes.find((n) => n.step === "derive");
|
|
325
|
+
const countOf = probe.notes.find((n) => n.step === "count.of");
|
|
326
|
+
const order = probe.notes.find((n) => n.step === "order");
|
|
327
|
+
const orderDetail = probe.notes.find((n) => n.step === "order-detail");
|
|
328
|
+
if (derive) {
|
|
329
|
+
const inner = probe.notes.find((n) => n.step === "generate" && n.depth > 0);
|
|
330
|
+
out.derivation = compact({ kind: "derive", from: derive.from, as: derive.gen, sourceKey: inner && keyString(inner.key), sourceGenerator: inner?.gen });
|
|
331
|
+
if (inner) out.key = describeKey(inner.key);
|
|
332
|
+
out.generator = { name: derive.gen, args: f.genArgs ?? {}, outputType: info?.sdl ?? f.sdl };
|
|
333
|
+
} else if (countOf) {
|
|
334
|
+
out.derivation = { kind: "count.of", of: countOf.of, size: countOf.size };
|
|
335
|
+
out.generator = { name: "count.of", args: { of: countOf.of }, outputType: "Int" };
|
|
336
|
+
} else if (order) {
|
|
337
|
+
out.derivation = compact({ kind: "order", gen: order.gen, items: orderDetail?.items, itemsField: orderDetail?.itemsField, itemType: orderDetail?.itemType, taxPercent: orderDetail?.taxPercent, taxKey: orderDetail && keyString(orderDetail.taxKey), subtotalCents: orderDetail?.subtotalCents });
|
|
338
|
+
out.generator = { name: order.gen, args: {}, outputType: "Float" };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- the seed in effect
|
|
342
|
+
out.seed = describeSeed(leaf.seed, info, inferred, options, runOptions, ancestors, inferred.byPath);
|
|
343
|
+
|
|
344
|
+
// ---- why this rule, spec entry or directive
|
|
345
|
+
if (info) {
|
|
346
|
+
const decisions = inferred.decisions.filter((d) => d.path === names);
|
|
347
|
+
const scalarD = decisions.find((d) => d.kind === "scalar");
|
|
348
|
+
const typeD = decisions.find((d) => d.kind === "type");
|
|
349
|
+
const listD = decisions.find((d) => d.kind === "list");
|
|
350
|
+
const main = f.kind === "scalar" ? scalarD : typeD ?? listD;
|
|
351
|
+
if (main) out.rule = compact({ id: main.rule, source: main.source, description: ruleText(main.rule), pack: packOf(info, inferred, runOptions) });
|
|
352
|
+
if (listD && f.kind === "scalar") out.listRule = compact({ id: listD.rule, source: listD.source, description: ruleText(listD.rule), isList: listD.value });
|
|
353
|
+
else if (listD) out.listRule = compact({ id: listD.rule, source: listD.source, description: ruleText(listD.rule), isList: listD.value });
|
|
354
|
+
out.decisions = decisions.map((d) => compact({ kind: d.kind, value: d.value, source: d.source, rule: d.rule }));
|
|
355
|
+
out.precedence = precedenceList(info, inferred, leaf, runOptions);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- scenario, nullRate, pins
|
|
359
|
+
const sc = describeScenario(f, info, probe.notes, options.scenario === undefined ? [] : [].concat(options.scenario).filter((s) => s !== "none"), out.collection);
|
|
360
|
+
if (sc) out.scenario = sc;
|
|
361
|
+
const nr = probe.notes.find((n) => n.step === "nullRate");
|
|
362
|
+
if (nr) out.nullRate = { rate: nr.rate, draw: nr.draw, key: keyString(nr.key), hit: nr.hit };
|
|
363
|
+
if (worldValue) out.pin = { entity: `${worldValue.type}:${worldValue.key}`, field: worldValue.field, kind: worldValue.kind, value: worldValue.value };
|
|
364
|
+
const deleted = leafGroup.extras.find((x) => x.type === "entity" && x.deleted);
|
|
365
|
+
if (deleted) out.pin = { entity: `${f.typeName}:${deleted.key}`, kind: "deleted", value: null };
|
|
366
|
+
|
|
367
|
+
// ---- the story in words
|
|
368
|
+
out.summary = summarize(out, f);
|
|
369
|
+
return out;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const SPECIAL_TEXT = {
|
|
373
|
+
totalCount: "the size of the whole collection: the declared count, or the endless total (1000 unless $options.endlessTotal)",
|
|
374
|
+
cursor: "an opaque cursor that encodes the position of the edge",
|
|
375
|
+
pageInfoField: "paging information computed from the window of the connection",
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const entityNote = (ent) =>
|
|
379
|
+
({
|
|
380
|
+
args: `selected by the argument${ent.keyArgs ? "s " + ent.keyArgs.join(", ") : " (id, key, slug, ...)"}: the same entity wherever it is selected`,
|
|
381
|
+
root: "a root field is entity 1",
|
|
382
|
+
pool: `a stable reference into the pool of the type (1 to ${ent.poolSize}), drawn from the key above`,
|
|
383
|
+
owned: "owned by its parent: not shared with other fields",
|
|
384
|
+
inherited: "a geo-style child that shares its parent's persona",
|
|
385
|
+
"connection-member": "the node of a connection edge",
|
|
386
|
+
})[ent.mode];
|
|
387
|
+
|
|
388
|
+
function packOf(info, inferred, runOptions) {
|
|
389
|
+
const rule = inferred.decisions.find((d) => d.path === info.path && d.kind === "scalar")?.rule;
|
|
390
|
+
if (rule !== "P1") return undefined;
|
|
391
|
+
const c = classifyScalar(info.name, { typeName: info.parentType, list: info.list, packs: runOptions.packs, realistic: runOptions.realistic });
|
|
392
|
+
return c.pack;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function describeSeed(seed, info, inferred, options, runOptions, ancestors, byPath) {
|
|
396
|
+
const value = String(seed);
|
|
397
|
+
if (info?.ov?.seed !== undefined && String(info.ov.seed) === value) return { value, source: info.ovSrc.seed === "spec" ? "spec" : "directive" };
|
|
398
|
+
if (inferred.opSeed !== undefined && String(inferred.opSeed) === value) return { value, source: "operation directive" };
|
|
399
|
+
if (value === String(runOptions.seed)) return { value, source: options.seed === undefined ? "default" : "option" };
|
|
400
|
+
// inherited: a @seed or a spec seed on an enclosing field applies to its whole subtree
|
|
401
|
+
for (const g of [...ancestors].reverse()) {
|
|
402
|
+
const names = ancestors.slice(0, ancestors.indexOf(g) + 1).map((a) => a.field.fieldName).join(".");
|
|
403
|
+
const a = byPath.get(names);
|
|
404
|
+
if (a?.ov?.seed !== undefined && String(a.ov.seed) === value) return { value, source: a.ovSrc.seed === "spec" ? "spec on an enclosing field" : "directive on an enclosing field", at: dotted(g.field.path) };
|
|
405
|
+
}
|
|
406
|
+
return { value, source: "enclosing field" };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const PROPS = ["gen", "list", "count", "nullRate", "derive", "type", "key", "own", "shared", "seed", "scenario"];
|
|
410
|
+
|
|
411
|
+
function precedenceList(info, inferred, leaf, runOptions) {
|
|
412
|
+
const trace = specTrace(inferred.spec, info.path, info.parentType, info.name);
|
|
413
|
+
const out = [];
|
|
414
|
+
for (const prop of PROPS) {
|
|
415
|
+
const directive = info.ovSrc?.[prop] === "directive";
|
|
416
|
+
const always = (prop === "gen" && info.kind === "scalar" && !info.schemaSourced) || (prop === "list" && !info.connection && !info.schemaSourced);
|
|
417
|
+
if (!directive && !trace[prop] && !always) continue;
|
|
418
|
+
let heuristic;
|
|
419
|
+
if (prop === "gen" && info.kind === "scalar") {
|
|
420
|
+
const c = classifyScalar(info.name, { typeName: info.parentType, list: Boolean(info.list), packs: runOptions.packs, realistic: runOptions.realistic });
|
|
421
|
+
heuristic = { rule: c.rule, value: c.gen, description: ruleText(c.rule) };
|
|
422
|
+
} else if (prop === "list") {
|
|
423
|
+
const argNames = [...(info.args?.keys() ?? [])];
|
|
424
|
+
const r = listRule(info.name, { paging: argNames.some((n) => PAGING_ARGS.has(n)), idsArg: argNames.includes("ids"), listLiteralArg: false, realistic: runOptions.realistic, object: info.kind === "object" });
|
|
425
|
+
heuristic = { rule: r.rule, value: r.list, description: ruleText(r.rule) };
|
|
426
|
+
}
|
|
427
|
+
out.push(precedenceFor(prop, info, trace, heuristic));
|
|
428
|
+
}
|
|
429
|
+
return out;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function summarize(x, f) {
|
|
433
|
+
const bits = [];
|
|
434
|
+
const at = x.path;
|
|
435
|
+
if (x.error) return `${at} fails: ${x.error.message}${x.scenario ? ` (${x.scenario.effect})` : ""}.`;
|
|
436
|
+
if (x.pin?.kind === "pin") return `${at} is ${JSON.stringify(x.value)} because ${x.pin.entity}.${x.pin.field} is pinned in the world.`;
|
|
437
|
+
if (x.pin?.kind === "deleted") return `${at} is null because ${x.pin.entity} was deleted in the world.`;
|
|
438
|
+
if (x.scenario?.effect && /null \(|replaced|forced/.test(x.scenario.effect) && (x.value === null || x.kind === "scalar" || x.kind === "list" || x.kind === "scalar-list")) {
|
|
439
|
+
if (x.value === null && !x.generator) return `${at} is null: ${x.scenario.effect}.`;
|
|
440
|
+
bits.push(`the ${x.scenario.names.join("+")} scenario: ${x.scenario.effect}`);
|
|
441
|
+
}
|
|
442
|
+
if (x.nullRate?.hit) bits.push(`nullRate ${x.nullRate.rate} hit (draw ${x.nullRate.draw})`);
|
|
443
|
+
if (x.kind === "scalar" || x.kind === "list-element") {
|
|
444
|
+
const g = x.generator;
|
|
445
|
+
const via = x.derivation ? `derived (${x.derivation.kind})` : g ? `${g.name}(${Object.keys(g.args ?? {}).length ? JSON.stringify(g.args) : ""})` : "a generator";
|
|
446
|
+
bits.unshift(`${at} = ${JSON.stringify(x.value)}: ${via}${x.rule ? `, chosen by rule ${x.rule.id} (${x.rule.source}${x.rule.pack ? `, pack ${x.rule.pack}` : ""})` : ""}${x.entity ? `, on entity ${x.entity.identity}${x.entity.shared ? " (shared)" : " (owned)"}` : ""}`);
|
|
447
|
+
if (x.persona?.pinned) bits[0] += `; persona pinned: ${Object.entries(x.persona.pinned).map(([k, v]) => `${k}=${v}`).join(", ")}`;
|
|
448
|
+
if (x.special) bits[0] = `${at} = ${JSON.stringify(x.value)}: ${x.special.description}`;
|
|
449
|
+
} else if (x.kind === "object" || x.kind === "item" || x.kind === "connection-member") {
|
|
450
|
+
bits.unshift(`${at} is entity ${x.entity?.identity ?? "?"}${x.entity?.shared ? " (shared)" : " (owned)"}`);
|
|
451
|
+
} else {
|
|
452
|
+
bits.unshift(`${at} is a ${x.kind} of ${f.typeName ?? f.sdl}${x.collection ? `, ${x.collection.size} items` : ""}`);
|
|
453
|
+
}
|
|
454
|
+
return bits.join("; ") + ".";
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Explain the value at `path` of a query: its key, generator, rule, persona, entity and scenario effect.
|
|
459
|
+
* Runs the query once with a read-only trace; output never changes.
|
|
460
|
+
* @param {string|import("graphql").DocumentNode} query
|
|
461
|
+
* @param {string|Array<string|number>} path response keys and zero-based indexes, for example `users.3.email`
|
|
462
|
+
* @param {import("./index.js").Options & { world?: any }} [options]
|
|
463
|
+
*/
|
|
464
|
+
export function explain(query, path, options = {}) {
|
|
465
|
+
const { world, ...rest } = options;
|
|
466
|
+
const target = parsePath(path);
|
|
467
|
+
if (world !== undefined) {
|
|
468
|
+
if (typeof world?.explain !== "function") throw new GraceholderError("bad-options", "options.world must be a world made by createWorld");
|
|
469
|
+
return world.explain(query, target, rest);
|
|
470
|
+
}
|
|
471
|
+
return explainWith(query, target, rest, undefined);
|
|
472
|
+
}
|
|
473
|
+
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { Arbitrary } from "fast-check";
|
|
2
|
+
import type { DocumentNode } from "graphql";
|
|
3
|
+
import type { Options, Scenario, ResultError } from "./index.js";
|
|
4
|
+
|
|
5
|
+
/** One generated sample. Printed by fast-check as `graceholder({"seed":...,"scenarios":[...]})`. */
|
|
6
|
+
export interface GraceholderSample {
|
|
7
|
+
seed: string | number;
|
|
8
|
+
scenarios: Scenario[];
|
|
9
|
+
/** The (possibly partial) data; null when an error reached the root. */
|
|
10
|
+
data: Record<string, unknown> | null;
|
|
11
|
+
/** GraphQL errors from the `errors` scenario, in the usual JSON shape; empty otherwise. */
|
|
12
|
+
errors: ResultError[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface GraceholderArbitraryOptions extends Omit<Options, "seed" | "scenario" | "errors"> {
|
|
16
|
+
/** Arbitrary of seeds. Default: integers from 0 to 2^31-1, shrinking toward 0. */
|
|
17
|
+
seeds?: Arbitrary<string | number>;
|
|
18
|
+
/** Scenarios that may be combined (default: all). `false` or `[]` for none. The chosen set shrinks toward empty. */
|
|
19
|
+
scenarios?: Scenario[] | false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** An arbitrary over seeds and scenario sets that maps each to graceholder output. */
|
|
23
|
+
export declare function graceholderArbitrary(query: string | DocumentNode, options?: GraceholderArbitraryOptions): Arbitrary<GraceholderSample>;
|
|
24
|
+
/** Regenerate a sample from the `{ seed, scenarios }` a failing run printed. */
|
|
25
|
+
export declare function replay(
|
|
26
|
+
query: string | DocumentNode,
|
|
27
|
+
sample: { seed: string | number; scenarios?: Scenario[] },
|
|
28
|
+
options?: Omit<GraceholderArbitraryOptions, "seeds" | "scenarios">,
|
|
29
|
+
): GraceholderSample;
|
|
30
|
+
export declare const SCENARIOS: readonly Scenario[];
|
|
31
|
+
export default graceholderArbitrary;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// @johnhenry/graceholder/fast-check: a fast-check arbitrary over seeds and scenario sets.
|
|
2
|
+
// fast-check is an OPTIONAL peer dependency; this subpath is the only module that imports it.
|
|
3
|
+
import * as fc from "fast-check";
|
|
4
|
+
import { graceholder } from "./index.js";
|
|
5
|
+
import { SCENARIOS, normalizeScenarios } from "./scenario.js";
|
|
6
|
+
import { GraceholderError } from "./errors.js";
|
|
7
|
+
|
|
8
|
+
const MAX_SEED = 0x7fffffff;
|
|
9
|
+
|
|
10
|
+
/** The options that decide what is generated; the arbitrary owns `seed`, `scenario` and `errors`. */
|
|
11
|
+
function baseOptions(options) {
|
|
12
|
+
const { seeds, scenarios, seed, scenario, errors, ...rest } = options;
|
|
13
|
+
if (seed !== undefined || scenario !== undefined || errors !== undefined) {
|
|
14
|
+
throw new GraceholderError("bad-options", "graceholderArbitrary chooses seed, scenario and errors itself; use `seeds` and `scenarios` instead");
|
|
15
|
+
}
|
|
16
|
+
return rest;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Generate for one sample: the same call the arbitrary makes, so a printed `{ seed, scenarios }` reproduces. */
|
|
20
|
+
export function replay(query, sample, options = {}) {
|
|
21
|
+
const { seed, scenarios = [] } = sample;
|
|
22
|
+
const out = graceholder(query, { ...baseOptions(options), seed, scenario: scenarios, errors: "result" });
|
|
23
|
+
return { seed, scenarios: [...scenarios], data: out.data ?? null, errors: out.errors ?? [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An arbitrary of `{ seed, scenarios, data, errors }`.
|
|
28
|
+
*
|
|
29
|
+
* - `seeds`: an arbitrary of seeds (default: integers from 0, shrinking toward 0).
|
|
30
|
+
* - `scenarios`: the scenarios that may be combined (default: all), or `false` / `[]` for none.
|
|
31
|
+
* The chosen set shrinks toward empty.
|
|
32
|
+
* - every other option is passed to `graceholder()` (spec, packs, variables, ...).
|
|
33
|
+
*
|
|
34
|
+
* A counterexample is printed as `graceholder({ seed, scenarios })`, which is everything needed to
|
|
35
|
+
* reproduce it with {@link replay} or `graceholder(query, { seed, scenario })`.
|
|
36
|
+
*
|
|
37
|
+
* @param {string|import("graphql").DocumentNode} query
|
|
38
|
+
* @param {Record<string, any>} [options]
|
|
39
|
+
*/
|
|
40
|
+
export function graceholderArbitrary(query, options = {}) {
|
|
41
|
+
const allowed = options.scenarios === false ? [] : normalizeScenarios(options.scenarios ?? SCENARIOS, (m) => new GraceholderError("bad-options", `scenarios: ${m}`)) ?? [];
|
|
42
|
+
const seeds = options.seeds ?? fc.integer({ min: 0, max: MAX_SEED });
|
|
43
|
+
const base = baseOptions(options);
|
|
44
|
+
const sets = allowed.length ? fc.subarray(allowed) : fc.constant([]);
|
|
45
|
+
// fail early (bad query, bad spec) instead of inside the first run
|
|
46
|
+
graceholder(query, { ...base, seed: 0, scenario: [] });
|
|
47
|
+
return fc.record({ seed: seeds, scenarios: sets }).map(({ seed, scenarios }) => {
|
|
48
|
+
const sample = replay(query, { seed, scenarios }, base);
|
|
49
|
+
Object.defineProperty(sample, fc.toStringMethod, {
|
|
50
|
+
value: () => `graceholder(${JSON.stringify({ seed, scenarios })})`,
|
|
51
|
+
enumerable: false,
|
|
52
|
+
});
|
|
53
|
+
return sample;
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export { SCENARIOS };
|
|
58
|
+
export default graceholderArbitrary;
|
package/src/generate.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The single-operation pipeline shared by graceholder() and the world (docs/design.md section 8).
|
|
2
|
+
import { infer } from "./infer.js";
|
|
3
|
+
import { run } from "./execute.js";
|
|
4
|
+
import { GraceholderError } from "./errors.js";
|
|
5
|
+
import { LOCALES } from "./vocab/en.js";
|
|
6
|
+
import { resolvePacks } from "./pack-registry.js";
|
|
7
|
+
import { anchorMs, DEFAULT_ANCHOR } from "./anchor.js";
|
|
8
|
+
import { globalScenarios, errorRateOf } from "./scenario.js";
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_SEED = "graceholder";
|
|
11
|
+
export { DEFAULT_ANCHOR, anchorMs };
|
|
12
|
+
|
|
13
|
+
export function checkLocale(locale) {
|
|
14
|
+
if (!Object.hasOwn(LOCALES, locale)) {
|
|
15
|
+
throw new GraceholderError("unknown-locale", `unknown locale ${JSON.stringify(locale)}`, { hint: `available: ${Object.keys(LOCALES).join(", ")}` });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Validate the realism options (images, distributions, packs) and resolve the packs (docs/design.md section 16, Realism). */
|
|
20
|
+
export function realismOptions(options) {
|
|
21
|
+
const images = options.images ?? "url";
|
|
22
|
+
if (images !== "url" && images !== "svg") throw new GraceholderError("bad-options", `images must be "url" or "svg", got ${JSON.stringify(images)}`);
|
|
23
|
+
const distributions = options.distributions ?? "uniform";
|
|
24
|
+
if (distributions !== "uniform" && distributions !== "realistic") {
|
|
25
|
+
throw new GraceholderError("bad-options", `distributions must be "uniform" or "realistic", got ${JSON.stringify(distributions)}`);
|
|
26
|
+
}
|
|
27
|
+
return { images, realistic: distributions === "realistic", packs: resolvePacks(options.packs) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Infer one query operation and resolve the execution options (shared by single runs and bulk streams).
|
|
32
|
+
* @param {any} query
|
|
33
|
+
* @param {any} [options]
|
|
34
|
+
* @param {any} [world]
|
|
35
|
+
*/
|
|
36
|
+
export function prepare(query, options = {}, world = undefined) {
|
|
37
|
+
const locale = options.locale ?? "en";
|
|
38
|
+
checkLocale(locale);
|
|
39
|
+
const ms = anchorMs(options.anchorDate ?? DEFAULT_ANCHOR);
|
|
40
|
+
const realism = realismOptions(options);
|
|
41
|
+
const scenario = globalScenarios(options.scenario);
|
|
42
|
+
const errorRate = errorRateOf(options.scenarioOptions);
|
|
43
|
+
const inferred = infer(query, {
|
|
44
|
+
packs: realism.packs,
|
|
45
|
+
realistic: realism.realistic,
|
|
46
|
+
operationName: options.operationName,
|
|
47
|
+
spec: options.spec,
|
|
48
|
+
strict: options.strict,
|
|
49
|
+
variables: options.variables,
|
|
50
|
+
scenario,
|
|
51
|
+
nullableTypes: world?.deletedTypes(),
|
|
52
|
+
nullableFields: world?.nullFields(),
|
|
53
|
+
schema: options.schema,
|
|
54
|
+
});
|
|
55
|
+
const runOptions = {
|
|
56
|
+
seed: options.seed ?? DEFAULT_SEED,
|
|
57
|
+
anchorMs: ms,
|
|
58
|
+
locale,
|
|
59
|
+
images: realism.images,
|
|
60
|
+
packs: realism.packs,
|
|
61
|
+
realistic: realism.realistic,
|
|
62
|
+
variables: options.variables,
|
|
63
|
+
poolSize: inferred.spec.options.poolSize,
|
|
64
|
+
endlessTotal: inferred.spec.options.endlessTotal,
|
|
65
|
+
errorRate,
|
|
66
|
+
world,
|
|
67
|
+
};
|
|
68
|
+
return { inferred, runOptions };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Infer and execute one query operation. Returns the raw graphql-js result (errors included) with
|
|
73
|
+
* plain JSON-compatible `data`. `world` is the optional world view (src/world.js).
|
|
74
|
+
* @param {any} query
|
|
75
|
+
* @param {any} [options]
|
|
76
|
+
* @param {any} [world]
|
|
77
|
+
*/
|
|
78
|
+
export function generateResult(query, options = {}, world = undefined) {
|
|
79
|
+
const { inferred, runOptions } = prepare(query, options, world);
|
|
80
|
+
const result = run(inferred, runOptions);
|
|
81
|
+
// graphql-js builds null-prototype objects; hand back plain JSON-compatible data
|
|
82
|
+
if (result.data !== undefined && result.data !== null) result.data = JSON.parse(JSON.stringify(result.data));
|
|
83
|
+
return result;
|
|
84
|
+
}
|