@johnhenry/graceholder 0.0.0-stage → 0.1.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/rng.js
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// SplitMix64 and the frozen draw helpers (docs/design.md 2.3).
|
|
2
|
+
// A Stream is local to ONE key: it is consumed only by that value's generator.
|
|
3
|
+
|
|
4
|
+
const M = 0xffffffffffffffffn;
|
|
5
|
+
const GAMMA = 0x9e3779b97f4a7c15n;
|
|
6
|
+
const TWO64 = 1n << 64n;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One SplitMix64 step.
|
|
10
|
+
* @param {bigint} state
|
|
11
|
+
* @returns {[bigint, bigint]} [output, nextState]
|
|
12
|
+
*/
|
|
13
|
+
export function splitmix64Next(state) {
|
|
14
|
+
const s = (state + GAMMA) & M;
|
|
15
|
+
let z = s;
|
|
16
|
+
z = ((z ^ (z >> 30n)) * 0xbf58476d1ce4e5b9n) & M;
|
|
17
|
+
z = ((z ^ (z >> 27n)) * 0x94d049bb133111ebn) & M;
|
|
18
|
+
return [z ^ (z >> 31n), s];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class SplitMix64 {
|
|
22
|
+
/** @param {bigint} state */
|
|
23
|
+
constructor(state) {
|
|
24
|
+
this.state = state & M;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** @returns {bigint} next unsigned 64-bit output */
|
|
28
|
+
next() {
|
|
29
|
+
const [out, s] = splitmix64Next(this.state);
|
|
30
|
+
this.state = s;
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Unbiased integer in [lo, hi] using rejection sampling over u64 draws:
|
|
37
|
+
* a draw is accepted only when it is below the largest multiple of the range
|
|
38
|
+
* that fits in 2^64. At least one draw is always consumed.
|
|
39
|
+
* @param {() => bigint} next
|
|
40
|
+
*/
|
|
41
|
+
export function intFrom(next, lo, hi) {
|
|
42
|
+
if (!Number.isSafeInteger(lo) || !Number.isSafeInteger(hi) || hi < lo) {
|
|
43
|
+
throw new RangeError(`int(${lo}, ${hi}): need safe integers with lo <= hi`);
|
|
44
|
+
}
|
|
45
|
+
const range = BigInt(hi) - BigInt(lo) + 1n;
|
|
46
|
+
if (range > 1n << 53n) throw new RangeError(`int(${lo}, ${hi}): range exceeds 2^53`);
|
|
47
|
+
const limit = TWO64 - (TWO64 % range);
|
|
48
|
+
for (;;) {
|
|
49
|
+
const u = next();
|
|
50
|
+
if (u < limit) return Number(BigInt(lo) + (u % range));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class Stream extends SplitMix64 {
|
|
55
|
+
/** @returns {number} in [0, 1) with 53 bits of precision */
|
|
56
|
+
float() {
|
|
57
|
+
return Number(this.next() >> 11n) / 2 ** 53;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** @returns {number} integer in [lo, hi], inclusive, unbiased */
|
|
61
|
+
int(lo, hi) {
|
|
62
|
+
return intFrom(() => this.next(), lo, hi);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @template T @param {readonly T[]} arr @returns {T} */
|
|
66
|
+
pick(arr) {
|
|
67
|
+
if (arr.length === 0) throw new RangeError("pick() of an empty array");
|
|
68
|
+
return arr[this.int(0, arr.length - 1)];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** @template T @param {ReadonlyArray<readonly [T, number]>} pairs @returns {T} */
|
|
72
|
+
weighted(pairs) {
|
|
73
|
+
let total = 0;
|
|
74
|
+
for (const [, w] of pairs) total += w;
|
|
75
|
+
if (!(total > 0)) throw new RangeError("weighted() needs a positive total weight");
|
|
76
|
+
const x = this.float() * total;
|
|
77
|
+
let acc = 0;
|
|
78
|
+
for (const [v, w] of pairs) {
|
|
79
|
+
acc += w;
|
|
80
|
+
if (x < acc) return v;
|
|
81
|
+
}
|
|
82
|
+
return pairs[pairs.length - 1][0];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** @returns {boolean} true with probability p */
|
|
86
|
+
bool(p) {
|
|
87
|
+
return this.float() < p;
|
|
88
|
+
}
|
|
89
|
+
}
|
package/src/rules.js
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Inference rules: list-vs-one (L) and scalar type/generator by name (T). FROZEN at 1.0.0.
|
|
2
|
+
import { tokenize, singularize, personaKind } from "./naming.js";
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
const IRREGULAR_PLURALS = new Set(["people", "children", "men", "women", "mice"]);
|
|
6
|
+
/** Plural-looking words that are singular count-like scalars (T8) or mass nouns. */
|
|
7
|
+
const SINGULAR_PLURALS = new Set(["views", "likes", "votes", "dislikes", "news", "series", "species"]);
|
|
8
|
+
const L4_NAMES = new Set(["list"]);
|
|
9
|
+
|
|
10
|
+
function pluralLast(word) {
|
|
11
|
+
if (SINGULAR_PLURALS.has(word)) return false;
|
|
12
|
+
if (IRREGULAR_PLURALS.has(word)) return true;
|
|
13
|
+
if (word === "data") return false;
|
|
14
|
+
return word.endsWith("s") && !/(ss|us|is)$/.test(word);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* L2-L6. L1 (directive/spec) arrives with M4.
|
|
19
|
+
* @param {string} fieldName
|
|
20
|
+
* @param {{ paging: boolean, idsArg: boolean, listLiteralArg: boolean, realistic?: boolean, object?: boolean }} ctx
|
|
21
|
+
* @returns {{ list: boolean, rule: string }}
|
|
22
|
+
*/
|
|
23
|
+
export function listRule(fieldName, ctx) {
|
|
24
|
+
const tokens = tokenize(fieldName);
|
|
25
|
+
const last = tokens[tokens.length - 1] ?? "";
|
|
26
|
+
if (ctx.paging) return { list: true, rule: "L2" };
|
|
27
|
+
// realistic mode: `salesHistory { date value }` is a time series, which is a list
|
|
28
|
+
if (ctx.realistic && ctx.object && SERIES_TYPE_WORDS.has(last)) return { list: true, rule: "L7" };
|
|
29
|
+
// realistic mode: a scalar `followers` or `sales` is a number, not a list of words
|
|
30
|
+
if (ctx.realistic && !ctx.object && Object.hasOwn(POPULARITY, last)) return { list: false, rule: "L6" };
|
|
31
|
+
if (pluralLast(last)) return { list: true, rule: "L3" };
|
|
32
|
+
if (L4_NAMES.has(fieldName.toLowerCase()) || L4_NAMES.has(last) || (tokens[0] === "all" && tokens.length > 1)) {
|
|
33
|
+
return { list: true, rule: "L4" };
|
|
34
|
+
}
|
|
35
|
+
if (ctx.idsArg || ctx.listLiteralArg) return { list: true, rule: "L5" };
|
|
36
|
+
return { list: false, rule: "L6" };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- scalar type by name ------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const IMAGE_WORDS = new Set(["avatar", "image", "photo", "thumbnail", "picture", "logo"]);
|
|
42
|
+
const IMAGE_SUFFIX = new Set(["url", "uri", "link", "src"]);
|
|
43
|
+
const URL_WORDS = new Set(["url", "uri", "link", "website", "homepage"]);
|
|
44
|
+
const PHONE_WORDS = new Set(["phone", "mobile", "tel", "telephone"]);
|
|
45
|
+
const BOOL_PREFIX = new Set(["is", "has", "can", "should", "was", "did", "allow", "allows", "allowed"]);
|
|
46
|
+
const BOOL_SUFFIX = new Set(["enabled", "active", "verified", "public", "deleted", "archived", "flag"]);
|
|
47
|
+
|
|
48
|
+
/** T8: last word -> inclusive integer range. */
|
|
49
|
+
const INT_RANGES = {
|
|
50
|
+
count: [0, 500], total: [0, 1000], qty: [1, 10], quantity: [1, 10], num: [1, 100], age: [18, 90],
|
|
51
|
+
year: [1990, 2024], index: [0, 99], position: [1, 20], rank: [1, 100], level: [1, 50], size: [1, 100],
|
|
52
|
+
length: [1, 100], views: [0, 100000], likes: [0, 5000], votes: [0, 1000],
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** T9: last word -> [generator, min, max]. */
|
|
56
|
+
const FLOAT_RANGES = {
|
|
57
|
+
price: ["money", 1, 500], cost: ["money", 1, 500], amount: ["money", 1, 1000], total: ["money", 1, 1000],
|
|
58
|
+
subtotal: ["money", 1, 1000], tax: ["money", 0, 100], fee: ["money", 0, 100], balance: ["money", 0, 10000],
|
|
59
|
+
salary: ["money", 30000, 200000], revenue: ["money", 1000, 1000000], rate: ["float", 0, 100],
|
|
60
|
+
ratio: ["float", 0, 1], score: ["float", 0, 100], rating: ["float", 1, 5], weight: ["float", 1, 200],
|
|
61
|
+
height: ["float", 1, 250], lat: ["float", -90, 90], lng: ["float", -180, 180], latitude: ["float", -90, 90],
|
|
62
|
+
longitude: ["float", -180, 180], percent: ["float", 0, 100],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Realistic mode only (options.distributions: "realistic"): popularity-like counts are heavy-tailed.
|
|
67
|
+
* word -> [distribution, min, max]
|
|
68
|
+
*/
|
|
69
|
+
const POPULARITY = {
|
|
70
|
+
views: ["lognormal", 0, 1000000], view: ["lognormal", 0, 1000000], impressions: ["lognormal", 0, 5000000],
|
|
71
|
+
plays: ["lognormal", 0, 1000000], reads: ["lognormal", 0, 500000], clicks: ["lognormal", 0, 100000],
|
|
72
|
+
likes: ["lognormal", 0, 50000], like: ["lognormal", 0, 50000], shares: ["lognormal", 0, 10000],
|
|
73
|
+
followers: ["powerlaw", 0, 1000000], follower: ["powerlaw", 0, 1000000], subscribers: ["powerlaw", 0, 1000000],
|
|
74
|
+
stars: ["powerlaw", 0, 100000], downloads: ["powerlaw", 0, 10000000], sales: ["zipf", 0, 100000],
|
|
75
|
+
};
|
|
76
|
+
const SERIES_TYPE_WORDS = new Set(["history", "series", "timeline", "trend"]);
|
|
77
|
+
const SERIES_DATE_WORDS = new Set(["date", "day", "at", "time", "timestamp", "month", "week", "period", "ts", "x"]);
|
|
78
|
+
const SERIES_VALUE_WORDS = new Set([
|
|
79
|
+
"value", "amount", "count", "total", "price", "y", "balance", "close", "volume", "sales", "revenue", "views",
|
|
80
|
+
"visits", "users", "score", "rate", "temperature", "level", "reading",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** Realistic mode: is this the element type of a time series (`SalesHistory`, `PriceSeries`)? */
|
|
84
|
+
export const isSeriesType = (typeName) => SERIES_TYPE_WORDS.has(tokenize(typeName).at(-1) ?? "");
|
|
85
|
+
|
|
86
|
+
const COHERENT_DATES = { created: "time.created", updated: "time.updated", published: "time.published" };
|
|
87
|
+
const DATE_WORDS = new Set(["at", "date", "time", "timestamp"]);
|
|
88
|
+
const BIRTH_WORDS = new Set(["birthday", "dob"]);
|
|
89
|
+
|
|
90
|
+
const ORG_TYPE_KINDS = new Set(["org"]);
|
|
91
|
+
|
|
92
|
+
const r = (sdl, gen, rule, args = {}, extra = {}) => ({ sdl, gen, args, rule, ...extra });
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* `<x>Count`, `<x>sCount` and `total<X>s` count a sibling collection (docs/design.md 6.2).
|
|
96
|
+
* @returns {string[]|null} the words naming the counted collection, last word singular
|
|
97
|
+
*/
|
|
98
|
+
export function countOf(fieldName) {
|
|
99
|
+
const tokens = tokenize(fieldName);
|
|
100
|
+
let words = null;
|
|
101
|
+
if (tokens.length >= 2 && tokens[tokens.length - 1] === "count") words = tokens.slice(0, -1);
|
|
102
|
+
else if (tokens.length >= 2 && tokens[0] === "total" && /s$/.test(tokens[tokens.length - 1]) && !SINGULAR_PLURALS.has(tokens[tokens.length - 1])) words = tokens.slice(1);
|
|
103
|
+
if (!words || (words.length === 1 && words[0] === "total")) return null;
|
|
104
|
+
const out = [...words];
|
|
105
|
+
out[out.length - 1] = singularize(out[out.length - 1]);
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pick the SDL scalar type and generator for a scalar field. First matching row wins (T1-T13).
|
|
111
|
+
* @param {string} fieldName
|
|
112
|
+
* @param {{ typeName: string, list: boolean, packs?: any[], realistic?: boolean }} ctx
|
|
113
|
+
* @returns {{ sdl: string, gen: string, args: Record<string, any>, rule: string, nullable?: boolean, pack?: string }}
|
|
114
|
+
*/
|
|
115
|
+
export function classifyScalar(fieldName, ctx) {
|
|
116
|
+
if (fieldName === "__typename") return r("String", "typename", "T2");
|
|
117
|
+
const tokens = tokenize(fieldName);
|
|
118
|
+
if (tokens.length === 0) return r("String", "words", "T13", { n: 2 });
|
|
119
|
+
if (ctx.list) tokens[tokens.length - 1] = singularize(tokens[tokens.length - 1]);
|
|
120
|
+
const last = tokens[tokens.length - 1];
|
|
121
|
+
const first = tokens[0];
|
|
122
|
+
const has = (w) => tokens.includes(w);
|
|
123
|
+
const kind = personaKind(ctx.typeName);
|
|
124
|
+
|
|
125
|
+
// T1
|
|
126
|
+
if (last === "id") {
|
|
127
|
+
if (tokens.length === 1 && !ctx.list) return r("ID", "id.key", "T1");
|
|
128
|
+
return r("ID", "id.int", "T1", { min: 1, max: 100, string: true });
|
|
129
|
+
}
|
|
130
|
+
// realistic mode: fields of a time-series element type (`salesHistory { date value }`)
|
|
131
|
+
if (ctx.realistic && isSeriesType(ctx.typeName)) {
|
|
132
|
+
if (tokens.length === 1 && SERIES_DATE_WORDS.has(last)) return r("DateTime", "series.date", "T14");
|
|
133
|
+
if (tokens.length === 1 && SERIES_VALUE_WORDS.has(last)) return r("Float", "series.value", "T14");
|
|
134
|
+
}
|
|
135
|
+
// packs, in order: the first pack that recognises the name wins (rule P1)
|
|
136
|
+
for (const pack of ctx.packs ?? []) {
|
|
137
|
+
const hit = pack.rules?.({ tokens, first, last, kind, typeName: ctx.typeName, list: ctx.list });
|
|
138
|
+
if (hit) return r(hit.sdl, hit.gen, "P1", hit.args ?? {}, { pack: pack.name, ...(hit.nullable ? { nullable: true } : {}) });
|
|
139
|
+
}
|
|
140
|
+
// T3
|
|
141
|
+
if (last === "email" || last === "mail" || (last === "address" && has("email"))) return r("String", "person.email", "T3");
|
|
142
|
+
// T5 before T4: `avatarUrl` is an image, not a plain url
|
|
143
|
+
if (IMAGE_WORDS.has(last) || (tokens.some((t) => IMAGE_WORDS.has(t)) && IMAGE_SUFFIX.has(last))) {
|
|
144
|
+
return r("String", "imageUrl", "T5");
|
|
145
|
+
}
|
|
146
|
+
// T4
|
|
147
|
+
if (URL_WORDS.has(last) || has("url")) return r("String", "url", "T4");
|
|
148
|
+
// T6
|
|
149
|
+
if (PHONE_WORDS.has(last)) return r("String", "person.phone", "T6");
|
|
150
|
+
// T7
|
|
151
|
+
if (BOOL_PREFIX.has(first) || BOOL_SUFFIX.has(last)) return r("Boolean", "bool", "T7");
|
|
152
|
+
// T8, realistic mode: heavy-tailed popularity counts (`views`, `likeCount`, `followers`, `sales`)
|
|
153
|
+
if (ctx.realistic) {
|
|
154
|
+
const word = Object.hasOwn(POPULARITY, last) ? last : (last === "count" || last === "total") && tokens.length > 1 ? tokens[tokens.length - 2] : undefined;
|
|
155
|
+
const pop = word !== undefined && Object.hasOwn(POPULARITY, word) ? POPULARITY[word] : undefined;
|
|
156
|
+
if (pop) return r("Int", "int", "T8", { min: pop[1], max: pop[2], dist: pop[0] });
|
|
157
|
+
}
|
|
158
|
+
// T8 (a type context overrides `total`, see T9)
|
|
159
|
+
const numberOf = first === "number" && tokens[1] === "of";
|
|
160
|
+
const orderTotal = last === "total" && kind === "order";
|
|
161
|
+
if ((last in INT_RANGES && !orderTotal) || numberOf) {
|
|
162
|
+
const [min, max] = numberOf ? INT_RANGES.count : INT_RANGES[last];
|
|
163
|
+
if (last === "age" && kind === "person" && tokens.length === 1) return r("Int", "person.age", "T8", { min, max });
|
|
164
|
+
return r("Int", "int", "T8", { min, max });
|
|
165
|
+
}
|
|
166
|
+
// T9
|
|
167
|
+
if (kind === "order" && tokens.length === 1 && (last === "subtotal" || last === "total" || last === "tax")) {
|
|
168
|
+
const [, min, max] = FLOAT_RANGES[last];
|
|
169
|
+
return r("Float", `order.${last}`, "T9", { min, max });
|
|
170
|
+
}
|
|
171
|
+
if (last in FLOAT_RANGES) {
|
|
172
|
+
const [gen, min, max] = FLOAT_RANGES[last];
|
|
173
|
+
return r("Float", gen, "T9", { min, max });
|
|
174
|
+
}
|
|
175
|
+
// T10
|
|
176
|
+
if (DATE_WORDS.has(last) || BIRTH_WORDS.has(last)) {
|
|
177
|
+
if (BIRTH_WORDS.has(last)) return r("DateTime", "time.birthday", "T10");
|
|
178
|
+
if (tokens.length === 2 && last === "at" && first === "deleted") return r("DateTime", "time.deleted", "T10", {}, { nullable: true });
|
|
179
|
+
if (tokens.length === 2 && last === "at" && first in COHERENT_DATES) return r("DateTime", COHERENT_DATES[first], "T10");
|
|
180
|
+
return r("DateTime", "datetime", "T10", { minDaysAgo: 30, maxDaysAgo: 1500 });
|
|
181
|
+
}
|
|
182
|
+
// T11
|
|
183
|
+
const named = namedString(tokens, first, last, has, kind, ctx.typeName);
|
|
184
|
+
if (named) return r("String", named.gen, "T11", named.args ?? {});
|
|
185
|
+
// T13
|
|
186
|
+
return r("String", "words", "T13", { n: 2 });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function namedString(tokens, first, last, has, kind, typeName) {
|
|
190
|
+
const prev = tokens[tokens.length - 2];
|
|
191
|
+
if (last === "name") {
|
|
192
|
+
if (prev === "first" || prev === "given") return { gen: "person.first" };
|
|
193
|
+
if (prev === "last" || prev === "family" || prev === "sur") return { gen: "person.last" };
|
|
194
|
+
if (prev === "full") return { gen: "person.full" };
|
|
195
|
+
if (kind === "product") return { gen: "product.name" };
|
|
196
|
+
if (ORG_TYPE_KINDS.has(kind)) return { gen: "company.name" };
|
|
197
|
+
return { gen: "person.full" };
|
|
198
|
+
}
|
|
199
|
+
if (last === "username" || last === "handle" || last === "nickname") return { gen: "person.username" };
|
|
200
|
+
if (last === "title") {
|
|
201
|
+
if (first === "job" && tokens.length === 2) return { gen: "person.jobTitle" };
|
|
202
|
+
if (kind === "person") return { gen: "person.jobTitle" };
|
|
203
|
+
return { gen: "title" };
|
|
204
|
+
}
|
|
205
|
+
if (last === "company" || last === "organization") return { gen: "company.name" };
|
|
206
|
+
if (last === "city") return { gen: "place.city" };
|
|
207
|
+
if (last === "state") return { gen: "place.state" };
|
|
208
|
+
if (last === "country") return { gen: "place.country" };
|
|
209
|
+
if (has("zip") || has("zipcode") || first === "postal") return { gen: "place.zip" };
|
|
210
|
+
if (last === "street") return { gen: "place.street" };
|
|
211
|
+
if (last === "address") return { gen: "place.address" };
|
|
212
|
+
if (last === "bio") return { gen: "person.bio" };
|
|
213
|
+
if (last === "description" || last === "summary" || last === "text" || last === "comment" || last === "message") return { gen: "sentence" };
|
|
214
|
+
if (last === "body" || last === "content") return { gen: "paragraph" };
|
|
215
|
+
if (last === "slug") return { gen: "slug" };
|
|
216
|
+
if (last === "status") return { gen: "oneOf", args: { vocab: "status" } };
|
|
217
|
+
if (last === "role") return { gen: "oneOf", args: { vocab: "role" } };
|
|
218
|
+
if (last === "color" || last === "colour") return { gen: "color" };
|
|
219
|
+
if (last === "currency") return { gen: "oneOf", args: { vocab: "currency" } };
|
|
220
|
+
if (last === "locale") return { gen: "oneOf", args: { vocab: "locale" } };
|
|
221
|
+
if (last === "language") return { gen: "oneOf", args: { vocab: "language" } };
|
|
222
|
+
if (last === "uuid") return { gen: "id.uuid" };
|
|
223
|
+
if (last === "ip") return { gen: "ip" };
|
|
224
|
+
if (last === "sku") return { gen: "pattern", args: { p: "AAA-#####" } };
|
|
225
|
+
if (last === "token") return { gen: "hex", args: { length: 32 } };
|
|
226
|
+
void typeName;
|
|
227
|
+
return null;
|
|
228
|
+
}
|
package/src/scenario.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Scenarios: repeatable edge-case modes (docs/design.md section 16, Scenarios).
|
|
2
|
+
// A scenario never changes how a value is generated; it replaces what a field returns.
|
|
3
|
+
// Every choice is a pure function of a key, so scenario output is as repeatable as the rest.
|
|
4
|
+
import { GraceholderError } from "./errors.js";
|
|
5
|
+
|
|
6
|
+
/** Canonical order; a scenario set is always normalised to this order. */
|
|
7
|
+
export const SCENARIOS = Object.freeze(["empty", "huge", "nulls", "unicode", "boundary", "errors"]);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Normalise a scenario value: a name, an array of names, or "none" / [] (no scenario).
|
|
11
|
+
* Returns undefined when nothing was given, [] for an explicit "none".
|
|
12
|
+
* @param {unknown} value
|
|
13
|
+
* @param {(msg: string) => Error} fail
|
|
14
|
+
* @returns {string[]|undefined}
|
|
15
|
+
*/
|
|
16
|
+
export function normalizeScenarios(value, fail) {
|
|
17
|
+
if (value === undefined || value === null) return undefined;
|
|
18
|
+
const list = Array.isArray(value) ? value : [value];
|
|
19
|
+
const names = new Set();
|
|
20
|
+
for (const s of list) {
|
|
21
|
+
if (s === "none") continue;
|
|
22
|
+
if (typeof s !== "string" || !SCENARIOS.includes(s)) {
|
|
23
|
+
throw fail(`unknown scenario ${JSON.stringify(s)} (known: ${SCENARIOS.join(", ")}, or "none")`);
|
|
24
|
+
}
|
|
25
|
+
names.add(s);
|
|
26
|
+
}
|
|
27
|
+
return SCENARIOS.filter((s) => names.has(s));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const globalScenarios = (value) => normalizeScenarios(value, (m) => new GraceholderError("bad-options", `scenario: ${m}`)) ?? [];
|
|
31
|
+
|
|
32
|
+
/** Default share of eligible fields that fail under the errors scenario. */
|
|
33
|
+
export const DEFAULT_ERROR_RATE = 0.25;
|
|
34
|
+
|
|
35
|
+
export function errorRateOf(options) {
|
|
36
|
+
const r = options?.errorRate;
|
|
37
|
+
if (r === undefined) return DEFAULT_ERROR_RATE;
|
|
38
|
+
if (typeof r !== "number" || !(r >= 0 && r <= 1)) throw new GraceholderError("bad-options", `scenarioOptions.errorRate must be a number between 0 and 1, got ${JSON.stringify(r)}`);
|
|
39
|
+
return r;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** List sizes under `huge`, by how many lists enclose the field (a flat 500 would explode when nested). */
|
|
43
|
+
const HUGE_BY_DEPTH = [100, 15, 6, 3];
|
|
44
|
+
export const hugeSize = (depth) => HUGE_BY_DEPTH[Math.min(depth, HUGE_BY_DEPTH.length - 1)];
|
|
45
|
+
/** Total of an endless collection (connection, paged list) under `huge`. */
|
|
46
|
+
export const HUGE_TOTAL = 100000;
|
|
47
|
+
|
|
48
|
+
/** Generators whose output is free text (the only strings the unicode scenario replaces). */
|
|
49
|
+
const TEXT_GENS = new Set([
|
|
50
|
+
"words", "sentence", "paragraph", "title",
|
|
51
|
+
"person.first", "person.last", "person.full", "person.bio", "person.jobTitle",
|
|
52
|
+
"company.name", "product.name",
|
|
53
|
+
"place.address", "place.city", "place.country", "place.state", "place.street",
|
|
54
|
+
]);
|
|
55
|
+
export const isTextGen = (gen) => TEXT_GENS.has(gen);
|
|
56
|
+
|
|
57
|
+
const LONG_UNIT = "The quick brown fox jumps over the lazy dog \u{1F98A} ";
|
|
58
|
+
|
|
59
|
+
/** Strings that break layouts and encoders, kept as escapes so the file is plain ASCII. */
|
|
60
|
+
const UNICODE = Object.freeze([
|
|
61
|
+
// right-to-left
|
|
62
|
+
"مرحبا بالعالم",
|
|
63
|
+
"שלום עולם",
|
|
64
|
+
"abc مرحبا 123 שלום def",
|
|
65
|
+
"evil bidi override",
|
|
66
|
+
// emoji, ZWJ sequences, flags, skin tones
|
|
67
|
+
"\u{1F600}\u{1F680}\u{1F389}",
|
|
68
|
+
"\u{1F468}\u{1F469}\u{1F467}\u{1F466} family",
|
|
69
|
+
"\u{1F1EF}\u{1F1F5}\u{1F1FA}\u{1F1F8} flags \u{1F44D}\u{1F3FD}",
|
|
70
|
+
// combining marks
|
|
71
|
+
"Z͖͓͡à͔l͒͆g̕o҉",
|
|
72
|
+
"é vs é, ñ vs ñ",
|
|
73
|
+
// wide, CJK, zero-width, astral
|
|
74
|
+
"日本語のテキストと漢字",
|
|
75
|
+
"zerowidthjoinershere",
|
|
76
|
+
"\u{1D4D0}\u{1D4D1}\u{1D4D2} \u{20BB7}\u{20BB8}",
|
|
77
|
+
// whitespace and markup that UIs mishandle
|
|
78
|
+
" leading and trailing ",
|
|
79
|
+
"line one\nline two\ttabbed",
|
|
80
|
+
"<b>bold</b> & \"quoted\" 'text' </script>",
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** One string from the corpus; the last choice is a very long one. */
|
|
84
|
+
export function unicodeValue(stream) {
|
|
85
|
+
const i = stream.int(0, UNICODE.length); // UNICODE.length is the very long string
|
|
86
|
+
if (i < UNICODE.length) return UNICODE[i];
|
|
87
|
+
return LONG_UNIT.repeat(60); // about 2800 UTF-16 units, multi-code-unit emoji included
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const INT_EDGES = Object.freeze([0, 1, -1, 2147483647, -2147483648]);
|
|
91
|
+
const FLOAT_EDGES = Object.freeze([0, 1, -1, Number.MAX_VALUE, -Number.MAX_VALUE, Number.MIN_VALUE, Number.EPSILON, Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]);
|
|
92
|
+
const DATE_EDGES = Object.freeze(["1970-01-01T00:00:00.000Z", "9999-12-31T23:59:59.999Z", "0001-01-01T00:00:00.000Z", "2038-01-19T03:14:07.000Z"]);
|
|
93
|
+
|
|
94
|
+
/** A boundary value for an SDL scalar type, or undefined when the type has no boundary here. */
|
|
95
|
+
export function boundaryValue(sdl, stream) {
|
|
96
|
+
switch (sdl) {
|
|
97
|
+
case "Int": return stream.pick(INT_EDGES);
|
|
98
|
+
case "Float": return stream.pick(FLOAT_EDGES);
|
|
99
|
+
case "DateTime": return stream.pick(DATE_EDGES);
|
|
100
|
+
case "String": return stream.bool(0.5) ? "" : "x";
|
|
101
|
+
default: return undefined;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const FAILURES = Object.freeze([
|
|
106
|
+
["Simulated resolver failure", "INTERNAL_SERVER_ERROR"],
|
|
107
|
+
["Upstream service unavailable", "SERVICE_UNAVAILABLE"],
|
|
108
|
+
["Not authorized to read this field", "FORBIDDEN"],
|
|
109
|
+
["The request timed out", "TIMEOUT"],
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/** The error a field fails with; graphql-js adds the path and locations. */
|
|
113
|
+
export class ScenarioError extends Error {
|
|
114
|
+
constructor(stream) {
|
|
115
|
+
const [message, code] = stream.pick(FAILURES);
|
|
116
|
+
super(message);
|
|
117
|
+
this.name = "ScenarioError";
|
|
118
|
+
this.extensions = { code, scenario: "errors" };
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/schema.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Schema mode (docs/design.md section 17): load a schema given as SDL, an introspection
|
|
2
|
+
// result or a graphql-js schema, and map its scalars onto generators.
|
|
3
|
+
import {
|
|
4
|
+
buildSchema,
|
|
5
|
+
buildASTSchema,
|
|
6
|
+
buildClientSchema,
|
|
7
|
+
isSchema,
|
|
8
|
+
validateSchema,
|
|
9
|
+
Kind,
|
|
10
|
+
isNonNullType,
|
|
11
|
+
isListType,
|
|
12
|
+
isEnumType,
|
|
13
|
+
isScalarType,
|
|
14
|
+
isObjectType,
|
|
15
|
+
isInterfaceType,
|
|
16
|
+
isUnionType,
|
|
17
|
+
getNamedType,
|
|
18
|
+
} from "graphql";
|
|
19
|
+
import { GraceholderError } from "./errors.js";
|
|
20
|
+
|
|
21
|
+
const cache = new WeakMap();
|
|
22
|
+
const textCache = new Map();
|
|
23
|
+
const TEXT_CACHE_MAX = 16;
|
|
24
|
+
|
|
25
|
+
const bad = (msg, extra) => new GraceholderError("bad-schema", msg, extra);
|
|
26
|
+
|
|
27
|
+
function build(input) {
|
|
28
|
+
if (isSchema(input)) return input;
|
|
29
|
+
if (typeof input === "string") return buildSchema(input);
|
|
30
|
+
if (input && typeof input === "object") {
|
|
31
|
+
if (input.kind === Kind.DOCUMENT) return buildASTSchema(input);
|
|
32
|
+
const introspection = input.__schema ? input : input.data?.__schema ? input.data : null;
|
|
33
|
+
if (introspection) return buildClientSchema(introspection);
|
|
34
|
+
}
|
|
35
|
+
throw bad("schema must be SDL text, a parsed SDL document, an introspection result ({ __schema } or { data: { __schema } }) or a GraphQLSchema");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load and validate a schema. Results are cached by identity (objects) or text (strings).
|
|
40
|
+
* @param {unknown} input
|
|
41
|
+
* @returns {import("graphql").GraphQLSchema}
|
|
42
|
+
*/
|
|
43
|
+
export function loadSchema(input) {
|
|
44
|
+
const isText = typeof input === "string";
|
|
45
|
+
if (isText && textCache.has(input)) return textCache.get(input);
|
|
46
|
+
if (!isText && input && typeof input === "object" && cache.has(input)) return cache.get(input);
|
|
47
|
+
let schema;
|
|
48
|
+
try {
|
|
49
|
+
schema = build(input);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e instanceof GraceholderError) throw e;
|
|
52
|
+
throw bad(`could not read the schema: ${/** @type {Error} */ (e).message}`, { cause: e });
|
|
53
|
+
}
|
|
54
|
+
const problems = validateSchema(schema);
|
|
55
|
+
if (problems.length) throw bad(`the schema is not valid: ${problems[0].message}`, { cause: problems[0], hint: problems.slice(1, 4).map((p) => p.message).join("; ") || undefined });
|
|
56
|
+
if (!schema.getQueryType()) throw bad("the schema has no query root type");
|
|
57
|
+
if (isText) {
|
|
58
|
+
if (textCache.size >= TEXT_CACHE_MAX) textCache.delete(textCache.keys().next().value);
|
|
59
|
+
textCache.set(input, schema);
|
|
60
|
+
} else if (input && typeof input === "object") {
|
|
61
|
+
cache.set(input, schema);
|
|
62
|
+
}
|
|
63
|
+
return schema;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Decompose a field type.
|
|
68
|
+
* @param {import("graphql").GraphQLOutputType} type
|
|
69
|
+
* @returns {{ named: import("graphql").GraphQLNamedOutputType, list: boolean, nonNull: boolean, itemNonNull: boolean }}
|
|
70
|
+
*/
|
|
71
|
+
export function shapeOf(type) {
|
|
72
|
+
/** @type {any} */
|
|
73
|
+
let t = type;
|
|
74
|
+
const nonNull = isNonNullType(t);
|
|
75
|
+
if (nonNull) t = t.ofType;
|
|
76
|
+
let list = false;
|
|
77
|
+
let itemNonNull = false;
|
|
78
|
+
if (isListType(t)) {
|
|
79
|
+
list = true;
|
|
80
|
+
t = t.ofType;
|
|
81
|
+
if (isNonNullType(t)) {
|
|
82
|
+
itemNonNull = true;
|
|
83
|
+
t = t.ofType;
|
|
84
|
+
}
|
|
85
|
+
if (isListType(t)) {
|
|
86
|
+
throw new GraceholderError("unsupported", `nested list type ${String(type)} is not supported`, { hint: "use a list of objects, or override the field with a spec" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { named: getNamedType(type), list, nonNull, itemNonNull };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Concrete object types a (possibly abstract) type can be at run time, in schema order. */
|
|
93
|
+
export function concreteTypes(schema, type) {
|
|
94
|
+
if (isObjectType(type)) return [type];
|
|
95
|
+
if (isInterfaceType(type) || isUnionType(type)) return [...schema.getPossibleTypes(type)];
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- scalars -> generators ---------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
const DATE_TIME = { gen: "datetime", args: { minDaysAgo: 30, maxDaysAgo: 1500 } };
|
|
102
|
+
|
|
103
|
+
/** Custom scalar name (lower case, `_`/`-` removed) -> generator. First hit wins; otherwise the field name decides. */
|
|
104
|
+
/** @type {any[]} */
|
|
105
|
+
const CUSTOM_ENTRIES = [
|
|
106
|
+
...["datetime", "timestamp", "isodatetime", "instant", "utcdatetime", "localdatetime", "datetimeiso", "zoneddatetime", "offsetdatetime"].map((n) => [n, DATE_TIME]),
|
|
107
|
+
...["date", "isodate", "calendardate", "localdate", "plaindate"].map((n) => [n, { gen: "datetime", args: { minDaysAgo: 30, maxDaysAgo: 1500, format: "date" } }]),
|
|
108
|
+
...["url", "uri", "href", "weburl"].map((n) => [n, { gen: "url", args: {} }]),
|
|
109
|
+
...["email", "emailaddress"].map((n) => [n, { gen: "person.email", args: {} }]),
|
|
110
|
+
...["uuid", "guid"].map((n) => [n, { gen: "id.uuid", args: {} }]),
|
|
111
|
+
...["phone", "phonenumber", "tel"].map((n) => [n, { gen: "person.phone", args: {} }]),
|
|
112
|
+
...["ip", "ipv4", "ipaddress", "ipv4address"].map((n) => [n, { gen: "ip", args: {} }]),
|
|
113
|
+
...["color", "colour", "hexcolor", "hexcolorcode"].map((n) => [n, { gen: "color", args: {} }]),
|
|
114
|
+
...["slug"].map((n) => [n, { gen: "slug", args: {} }]),
|
|
115
|
+
...["bigint", "long", "int64", "safeint", "nonnegativeint", "positiveint", "unsignedint", "uint", "integer"].map((n) => [n, { gen: "int", args: { min: 0, max: 100000 } }]),
|
|
116
|
+
...["decimal", "bigdecimal", "float64", "double", "nonnegativefloat", "positivefloat", "money"].map((n) => [n, { gen: "float", args: { min: 0, max: 1000, decimals: 2 } }]),
|
|
117
|
+
...["json", "jsonobject", "any", "object", "unknown", "void"].map((n) => [n, { gen: "words", args: { n: 2 } }]),
|
|
118
|
+
];
|
|
119
|
+
const CUSTOM_SCALARS = new Map(CUSTOM_ENTRIES);
|
|
120
|
+
|
|
121
|
+
/** Look up the generator for a custom scalar by its own name. */
|
|
122
|
+
export function customScalarGen(scalarName) {
|
|
123
|
+
return CUSTOM_SCALARS.get(scalarName.toLowerCase().replace(/[_-]/g, "")) ?? null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** May a heuristic generator whose SDL type is `heur` fill a built-in scalar `declared`? */
|
|
127
|
+
export function compatible(heur, declared) {
|
|
128
|
+
switch (declared) {
|
|
129
|
+
case "Int": return heur === "Int" || heur === "ID";
|
|
130
|
+
case "Float": return heur === "Int" || heur === "Float";
|
|
131
|
+
case "Boolean": return heur === "Boolean";
|
|
132
|
+
case "String": return heur === "String" || heur === "DateTime" || heur === "ID";
|
|
133
|
+
case "ID": return heur === "ID" || heur === "String" || heur === "Int";
|
|
134
|
+
default: return false;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The generator used for a built-in scalar when no name heuristic fits its type. */
|
|
139
|
+
export const BUILTIN_FALLBACK = {
|
|
140
|
+
Int: { gen: "int", args: { min: 0, max: 100 } },
|
|
141
|
+
Float: { gen: "float", args: { min: 0, max: 100, decimals: 2 } },
|
|
142
|
+
Boolean: { gen: "bool", args: {} },
|
|
143
|
+
String: { gen: "words", args: { n: 2 } },
|
|
144
|
+
ID: { gen: "id.key", args: {} },
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/** A graphql type guard used by the walker. */
|
|
148
|
+
export const isLeaf = (type) => isScalarType(type) || isEnumType(type);
|