@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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +492 -2
  3. package/STABILITY.md +24 -0
  4. package/package.json +126 -4
  5. package/src/adapters/apollo.d.ts +13 -0
  6. package/src/adapters/apollo.js +48 -0
  7. package/src/adapters/fetch.d.ts +15 -0
  8. package/src/adapters/fetch.js +150 -0
  9. package/src/adapters/shared.d.ts +14 -0
  10. package/src/adapters/shared.js +65 -0
  11. package/src/adapters/urql.d.ts +8 -0
  12. package/src/adapters/urql.js +53 -0
  13. package/src/anchor.js +13 -0
  14. package/src/args.js +41 -0
  15. package/src/bulk/csv.js +118 -0
  16. package/src/bulk/ndjson.js +10 -0
  17. package/src/bulk/plan.js +195 -0
  18. package/src/bulk/shape.js +67 -0
  19. package/src/bulk/source.js +24 -0
  20. package/src/bulk/sql.js +366 -0
  21. package/src/bulk/stream.js +55 -0
  22. package/src/cursor.js +25 -0
  23. package/src/dbg.d.ts +720 -0
  24. package/src/detmath.js +74 -0
  25. package/src/directives.js +113 -0
  26. package/src/dist.js +90 -0
  27. package/src/errors.js +17 -0
  28. package/src/execute.js +577 -0
  29. package/src/explain.js +473 -0
  30. package/src/fast-check.d.ts +31 -0
  31. package/src/fast-check.js +58 -0
  32. package/src/generate.js +84 -0
  33. package/src/generator-types.d.ts +90 -0
  34. package/src/generators.js +361 -0
  35. package/src/image.js +55 -0
  36. package/src/index.d.ts +507 -0
  37. package/src/index.js +62 -0
  38. package/src/infer.js +888 -0
  39. package/src/key.js +30 -0
  40. package/src/learn.js +397 -0
  41. package/src/naming.js +74 -0
  42. package/src/pack-registry.js +59 -0
  43. package/src/packs/PROVENANCE.md +26 -0
  44. package/src/packs/commerce.d.ts +4 -0
  45. package/src/packs/commerce.js +83 -0
  46. package/src/packs/finance.d.ts +4 -0
  47. package/src/packs/finance.js +180 -0
  48. package/src/packs/geo.d.ts +4 -0
  49. package/src/packs/geo.js +227 -0
  50. package/src/packs/health.d.ts +4 -0
  51. package/src/packs/health.js +76 -0
  52. package/src/packs/index.d.ts +7 -0
  53. package/src/packs/index.js +9 -0
  54. package/src/rng.js +89 -0
  55. package/src/rules.js +228 -0
  56. package/src/scenario.js +120 -0
  57. package/src/schema.js +148 -0
  58. package/src/spec.js +235 -0
  59. package/src/suggest.js +138 -0
  60. package/src/typed.d.ts +723 -0
  61. package/src/vocab/PROVENANCE.md +12 -0
  62. package/src/vocab/en.js +106 -0
  63. package/src/world.js +461 -0
  64. package/src/xxh64.js +87 -0
package/src/key.js ADDED
@@ -0,0 +1,30 @@
1
+ // Key string (scheme gh1) -> hash -> local stream. FROZEN at 1.0.0 (docs/design.md 2.2).
2
+ import { xxh64 } from "./xxh64.js";
3
+ import { Stream } from "./rng.js";
4
+
5
+ export const KEY_SCHEME = "gh1";
6
+ const SEP = "\x1f";
7
+ const SCOPES = new Set(["e", "l", "p", "a"]);
8
+ const encoder = new TextEncoder();
9
+
10
+ const clean = (v) => String(v).replaceAll(SEP, "�");
11
+
12
+ /**
13
+ * @param {{ seed: string|number, scope: "e"|"l"|"p"|"a", typeName: string, entityKey: string,
14
+ * fieldName: string, subKeys?: Array<string|number> }} p
15
+ */
16
+ export function keyString(p) {
17
+ if (!SCOPES.has(p.scope)) throw new RangeError(`unknown key scope ${JSON.stringify(p.scope)}`);
18
+ const parts = [KEY_SCHEME, p.seed, p.scope, p.typeName, p.entityKey, p.fieldName, ...(p.subKeys ?? [])];
19
+ return parts.map(clean).join(SEP);
20
+ }
21
+
22
+ /** @returns {bigint} XXH64 (seed 0) of the UTF-8 key string */
23
+ export function hashKey(p) {
24
+ return xxh64(encoder.encode(keyString(p)), 0n);
25
+ }
26
+
27
+ /** @returns {Stream} the SplitMix64 stream for this key */
28
+ export function keyStream(p) {
29
+ return new Stream(hashKey(p));
30
+ }
package/src/learn.js ADDED
@@ -0,0 +1,397 @@
1
+ // specFromSamples: learn a side-car spec from real responses (docs/design.md section 18).
2
+ // Pure and deterministic: the same query, samples and options give the same spec. It reads formats
3
+ // and statistics, never free text: see the privacy rules in section 18.2.
4
+ import { Kind, isEnumType } from "graphql";
5
+ import { GraceholderError } from "./errors.js";
6
+ import { infer } from "./infer.js";
7
+ import { anchorMs as parseAnchor, DEFAULT_ANCHOR } from "./anchor.js";
8
+ import { compileSpec } from "./spec.js";
9
+ import { PAGING_ARGS } from "./args.js";
10
+
11
+ const DAY_MS = 86400000;
12
+ const YEAR_DAYS = 365.25;
13
+ const bad = (msg, path) => new GraceholderError("bad-samples", msg, path === undefined ? undefined : { path });
14
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
15
+ const r3 = (x) => Math.round(x * 1000) / 1000;
16
+ const byText = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
17
+
18
+ /** Generators whose value is the identity of the entity or computed from the graph: never learned. */
19
+ const KEEP = new Set(["id.key", "id.int", "count.of", "order.subtotal", "order.tax", "order.total"]);
20
+ /** Generators that stand for personal data: enum-like or not, their values are never copied. */
21
+ const PERSONAL = new Set(["person.first", "person.last", "person.full", "person.username", "person.phone", "person.bio"]);
22
+ /** Generators that stand for free text: a learned length range replaces them. */
23
+ const TEXTISH = new Set(["word", "words", "sentence", "sentences", "paragraph", "lorem", "title", "person.bio", "text"]);
24
+ /** Coherent date generators and the window of days before the anchor they can produce. */
25
+ const COHERENT_DAYS = {
26
+ "time.created": [30, 1500], "time.updated": [0, 1500], "time.published": [0, 1500],
27
+ "time.birthday": [18 * YEAR_DAYS, 90 * YEAR_DAYS + 364],
28
+ };
29
+
30
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
31
+ const EMAIL = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
32
+ const URL_RE = /^https?:\/\/[^\s]+$/i;
33
+ const IMAGE_URL = /\.(?:png|jpe?g|gif|webp|svg|avif)(?:\?[^\s]*)?$/i;
34
+ const IPV4 = /^(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}$/;
35
+ const ISO_DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
36
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
37
+
38
+ /** Response key -> merged field nodes of one selection list, looking through fragments. */
39
+ function collectFields(selections, fragments, out = new Map()) {
40
+ for (const sel of selections) {
41
+ if (sel.kind === Kind.FIELD) {
42
+ const name = sel.name.value;
43
+ if (name.startsWith("__")) continue;
44
+ const key = sel.alias?.value ?? name;
45
+ const entry = out.get(key) ?? { key, name, selections: [], hasSelection: false };
46
+ if (sel.selectionSet) {
47
+ entry.hasSelection = true;
48
+ entry.selections.push(...sel.selectionSet.selections);
49
+ }
50
+ out.set(key, entry);
51
+ } else if (sel.kind === Kind.INLINE_FRAGMENT) {
52
+ collectFields(sel.selectionSet.selections, fragments, out);
53
+ } else if (sel.kind === Kind.FRAGMENT_SPREAD) {
54
+ collectFields(fragments.get(sel.name.value).selectionSet.selections, fragments, out);
55
+ }
56
+ }
57
+ return out;
58
+ }
59
+
60
+ function normalizeSamples(samples, rootKeys) {
61
+ const list = Array.isArray(samples) ? samples : [samples];
62
+ if (list.length === 0) throw bad("specFromSamples needs at least one sample response");
63
+ return list.map((s, i) => {
64
+ if (!isObject(s)) throw bad(`sample ${i} must be an object (a response's data, or { data })`);
65
+ if (Object.hasOwn(s, "data") && !rootKeys.has("data")) {
66
+ if (!isObject(s.data)) throw bad(`sample ${i} has no data${s.errors ? " (it has errors)" : ""}`);
67
+ return s.data;
68
+ }
69
+ return s;
70
+ });
71
+ }
72
+
73
+ class Acc {
74
+ constructor(path, info) {
75
+ this.path = path;
76
+ this.info = info;
77
+ this.present = 0; // parent instances that had the key
78
+ this.nulls = 0;
79
+ this.sizes = []; // list lengths, one per instance holding a list
80
+ this.values = []; // non-null scalar values, flattened across instances and list elements
81
+ this.sawList = false;
82
+ this.sawSingle = false;
83
+ this.opaque = false; // an object where a scalar was selected (a JSON scalar)
84
+ }
85
+ }
86
+
87
+ function walkSamples(ctx, selections, parentPath, instances) {
88
+ for (const f of collectFields(selections, ctx.fragments).values()) {
89
+ const path = parentPath ? `${parentPath}.${f.name}` : f.name;
90
+ let acc = ctx.accs.get(path);
91
+ if (!acc) {
92
+ acc = new Acc(path, ctx.byPath.get(path));
93
+ ctx.accs.set(path, acc);
94
+ }
95
+ const children = [];
96
+ for (const inst of instances) {
97
+ if (!Object.hasOwn(inst, f.key)) continue;
98
+ const v = inst[f.key];
99
+ acc.present++;
100
+ if (v === null) {
101
+ acc.nulls++;
102
+ continue;
103
+ }
104
+ if (Array.isArray(v)) {
105
+ acc.sawList = true;
106
+ acc.sizes.push(v.length);
107
+ for (const e of v) {
108
+ if (e === null) continue;
109
+ if (f.hasSelection) {
110
+ if (!isObject(e)) throw bad(`${path}: the query selects sub-fields, but a list element is ${typeof e}`, path);
111
+ children.push(e);
112
+ } else if (isObject(e) || Array.isArray(e)) acc.opaque = true;
113
+ else acc.values.push(e);
114
+ }
115
+ } else {
116
+ acc.sawSingle = true;
117
+ if (f.hasSelection) {
118
+ if (!isObject(v)) throw bad(`${path}: the query selects sub-fields, but the sample has ${typeof v}`, path);
119
+ children.push(v);
120
+ } else if (isObject(v)) acc.opaque = true;
121
+ else acc.values.push(v);
122
+ }
123
+ }
124
+ if (f.hasSelection && children.length) walkSamples(ctx, f.selections, path, children);
125
+ }
126
+ }
127
+
128
+ // --- value learning ----------------------------------------------------------------------------
129
+
130
+ const decimalsOf = (v) => {
131
+ const t = String(v);
132
+ if (/e/i.test(t)) return 6;
133
+ const i = t.indexOf(".");
134
+ return Math.min(8, i < 0 ? 0 : t.length - i - 1);
135
+ };
136
+
137
+ /** Pattern (docs/design.md 18.3) for same-length ASCII strings, or null when they do not share a format. */
138
+ export function shapePattern(values) {
139
+ const len = values[0].length;
140
+ if (len === 0 || len > 64 || values.some((v) => v.length !== len)) return null;
141
+ if (values.some((v) => !/^[\x21-\x7e]+$/.test(v))) return null;
142
+ const distinct = new Set(values).size;
143
+ const cls = (c) => (/[0-9]/.test(c) ? "d" : /[a-z]/.test(c) ? "l" : /[A-Z]/.test(c) ? "u" : "o");
144
+ const slots = [];
145
+ for (let i = 0; i < len; i++) {
146
+ const chars = new Set(values.map((v) => v[i]));
147
+ const classes = new Set([...chars].map(cls));
148
+ if (chars.size === 1) {
149
+ const c = [...chars][0];
150
+ slots.push({ kind: cls(c) === "o" ? "lit" : "const", c, cls: cls(c) });
151
+ } else if (classes.size === 1) {
152
+ const k = [...classes][0];
153
+ if (k === "o") return null; // different punctuation in one place: not a format
154
+ slots.push({ kind: "class", cls: k });
155
+ } else if (classes.has("o")) {
156
+ return null;
157
+ } else {
158
+ slots.push({ kind: "class", cls: "x" }); // letters and digits mixed
159
+ }
160
+ }
161
+ // a constant alphabetic prefix of up to 5 letters, followed by a digit or a separator, is part of the format
162
+ let prefix = 0;
163
+ if (distinct >= 2) {
164
+ while (prefix < len && slots[prefix].kind === "const" && /[A-Za-z]/.test(slots[prefix].c)) prefix++;
165
+ const next = slots[prefix];
166
+ if (prefix > 5 || prefix === len || !(next.cls === "d" || next.cls === "o")) prefix = 0;
167
+ }
168
+ const hasDigit = values.some((v) => /[0-9]/.test(v));
169
+ const hasLiteral = slots.some((s) => s.kind === "lit");
170
+ const allUpper = len >= 2 && values.every((v) => /^[A-Z]+$/.test(v));
171
+ if (!(hasDigit || hasLiteral || allUpper)) return null;
172
+ if (distinct === 1 && !hasDigit) return null;
173
+ const escape = (c) => (/[#?A*\\]/.test(c) ? `\\${c}` : c);
174
+ return slots
175
+ .map((s, i) => {
176
+ if (s.kind === "lit") return escape(s.c);
177
+ if (s.kind === "const" && i < prefix) return escape(s.c);
178
+ const k = s.cls;
179
+ return k === "d" ? "#" : k === "l" ? "?" : k === "u" ? "A" : "*";
180
+ })
181
+ .join("");
182
+ }
183
+
184
+ function learnValues(ctx, acc) {
185
+ const { path, info } = acc;
186
+ const values = acc.values;
187
+ const note = (kind, message) => ctx.notes.push({ path, kind, message });
188
+ if (info.ovSrc?.gen === "directive") return note("skipped", "a directive on the field already sets its generator"), null;
189
+ if (info.derive !== undefined) return note("kept-heuristic", "derived fields follow their source"), null;
190
+ const heur = info.gen;
191
+ if (KEEP.has(heur)) return note("kept-heuristic", `${heur} supplies identifiers and aggregates; the entity graph keeps them consistent`), null;
192
+ const named = info.schema?.named;
193
+ if (values.every((v) => typeof v === "boolean")) {
194
+ return { gen: { name: "bool", args: { p: r3(values.filter(Boolean).length / values.length) } } };
195
+ }
196
+ if (values.every((v) => typeof v === "number" && Number.isFinite(v))) return learnNumbers(values, named?.name, note);
197
+ if (values.every((v) => typeof v === "string")) return learnStrings(ctx, acc, values, heur, note);
198
+ return note("skipped", "values of mixed types"), null;
199
+ }
200
+
201
+ function learnNumbers(values, declared, note) {
202
+ let min = values[0];
203
+ let max = values[0];
204
+ for (const v of values) {
205
+ if (v < min) min = v;
206
+ if (v > max) max = v;
207
+ }
208
+ const integral = declared === "Int" ? true : declared === "Float" ? false : values.every(Number.isInteger);
209
+ if (integral) {
210
+ if (!Number.isSafeInteger(min) || !Number.isSafeInteger(max)) return note("skipped", "integers outside the safe range"), null;
211
+ return { gen: { name: "int", args: { min, max } } };
212
+ }
213
+ return { gen: { name: "float", args: { min, max, decimals: Math.max(...values.map(decimalsOf)) } } };
214
+ }
215
+
216
+ function learnStrings(ctx, acc, values, heur, note) {
217
+ const named = acc.info.schema?.named;
218
+ const counts = new Map();
219
+ for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
220
+ const n = values.length;
221
+ const d = counts.size;
222
+
223
+ // a schema enum: the values are public, only their frequencies come from the data
224
+ if (named && isEnumType(named)) {
225
+ const allowed = new Set(named.getValues().map((v) => v.name));
226
+ const stray = values.find((v) => !allowed.has(v));
227
+ if (stray !== undefined) throw bad(`${acc.path}: ${JSON.stringify(stray)} is not a value of the enum ${named.name}`, acc.path);
228
+ return { pick: weighted(counts) };
229
+ }
230
+
231
+ if (values.every((v) => UUID.test(v))) return { gen: "id.uuid" };
232
+ if (values.every((v) => EMAIL.test(v))) return { gen: "person.email" };
233
+ if (values.every((v) => URL_RE.test(v))) return { gen: values.every((v) => IMAGE_URL.test(v)) ? "imageUrl" : "url" };
234
+ if (values.every((v) => IPV4.test(v))) return { gen: "ip" };
235
+ const dates = learnDates(ctx, values, heur, note);
236
+ if (dates !== undefined) return dates;
237
+
238
+ const constantCase = values.every((v) => /^[A-Z][A-Z_]+$/.test(v));
239
+ const enumLike = values.every((v) => v.length <= 40) && ((d <= ctx.maxEnum && n >= 2 * d) || (constantCase && d <= 24));
240
+ if (enumLike && ctx.allows(acc.path)) {
241
+ if (PERSONAL.has(heur)) return note("withheld", `${heur} stands for personal data; its values are never copied`), null;
242
+ return { pick: weighted(counts) };
243
+ }
244
+ const hasDigit = values.some((v) => /[0-9]/.test(v));
245
+ if (enumLike && !hasDigit) {
246
+ return note("withheld", `${d} distinct value(s) look like an enumeration; pass allowValues to copy them`), null;
247
+ }
248
+
249
+ const len = values[0].length;
250
+ if (len >= 8 && values.every((v) => v.length === len && /^[0-9a-f]+$/.test(v)) && values.some((v) => /[a-f]/.test(v)) && values.some((v) => /[0-9]/.test(v))) {
251
+ return { gen: { name: "hex", args: { length: len } } };
252
+ }
253
+ if (values.every((v) => /^[0-9]+$/.test(v))) {
254
+ if (values.every((v) => v.length === len)) return { pattern: "#".repeat(len) };
255
+ const nums = values.map(Number);
256
+ if (nums.every(Number.isSafeInteger)) return { gen: { name: "id.int", args: { min: Math.min(...nums), max: Math.max(...nums), string: true } } };
257
+ }
258
+ const shape = shapePattern(values);
259
+ if (shape !== null) return { pattern: shape };
260
+
261
+ if (TEXTISH.has(heur)) {
262
+ let min = Infinity;
263
+ let max = 0;
264
+ for (const v of values) {
265
+ if (v.length < min) min = v.length;
266
+ if (v.length > max) max = v.length;
267
+ }
268
+ return { gen: { name: "text", args: { min, max } } };
269
+ }
270
+ return note("kept-heuristic", `free text is never copied; the ${heur} heuristic stays`), null;
271
+ }
272
+
273
+ /** Counts as `{ value, weight }` objects (most common first), or plain values when all are equally common. */
274
+ function weighted(counts) {
275
+ const entries = [...counts].sort((a, b) => b[1] - a[1] || byText(a[0], b[0]));
276
+ if (entries.every(([, c]) => c === entries[0][1])) return entries.map(([v]) => v).sort(byText);
277
+ return entries.map(([value, weight]) => ({ value, weight }));
278
+ }
279
+
280
+ /** ISO date-times and dates as a datetime range relative to the anchor; undefined when the values are not dates. */
281
+ function learnDates(ctx, values, heur, note) {
282
+ let dateOnly = true;
283
+ const times = [];
284
+ for (const v of values) {
285
+ const dt = ISO_DATETIME.exec(v);
286
+ if (dt) {
287
+ dateOnly = false;
288
+ times.push(Date.parse(dt[1] ? v : `${v}Z`));
289
+ } else if (ISO_DATE.test(v)) {
290
+ times.push(Date.parse(v));
291
+ } else return undefined;
292
+ }
293
+ if (times.some((t) => !Number.isFinite(t))) return undefined;
294
+ let lo = times[0];
295
+ let hi = times[0];
296
+ for (const t of times) {
297
+ if (t < lo) lo = t;
298
+ if (t > hi) hi = t;
299
+ }
300
+ const minDaysAgo = Math.floor((ctx.anchorMs - hi) / DAY_MS);
301
+ const maxDaysAgo = Math.ceil((ctx.anchorMs - lo) / DAY_MS);
302
+ const window = COHERENT_DAYS[heur];
303
+ if (window && minDaysAgo >= window[0] && maxDaysAgo <= window[1]) {
304
+ return note("kept-heuristic", `${heur} keeps dates coherent with the entity's other dates and already covers this range`), null;
305
+ }
306
+ const args = { minDaysAgo, maxDaysAgo };
307
+ if (dateOnly) args.format = "date";
308
+ return { gen: { name: "datetime", args } };
309
+ }
310
+
311
+ // --- emission ----------------------------------------------------------------------------------
312
+
313
+ /**
314
+ * Like {@link specFromSamples}, but also returns the notes explaining what was withheld or kept.
315
+ * @param {string|import("graphql").DocumentNode} query
316
+ * @param {any} samples one response or an array of responses (`data` objects or `{ data }`)
317
+ * @param {{ schema?: any, allowValues?: boolean|string[], maxEnum?: number, anchorDate?: string|Date, operationName?: string, variables?: Record<string, unknown> }} [opts]
318
+ * @returns {{ spec: Record<string, any>, notes: { path: string, kind: string, message: string }[] }}
319
+ */
320
+ export function analyzeSamples(query, samples, opts = {}) {
321
+ const { allowValues = false, maxEnum = 12 } = opts;
322
+ if (typeof allowValues !== "boolean" && !(Array.isArray(allowValues) && allowValues.every((p) => typeof p === "string"))) {
323
+ throw new GraceholderError("bad-options", "allowValues must be a boolean or an array of field paths");
324
+ }
325
+ if (!Number.isInteger(maxEnum) || maxEnum < 1) throw new GraceholderError("bad-options", "maxEnum must be a positive integer");
326
+ const anchorMs = parseAnchor(opts.anchorDate ?? DEFAULT_ANCHOR);
327
+ const inferred = infer(query, { operationName: opts.operationName, variables: opts.variables, schema: opts.schema });
328
+ const fragments = new Map(inferred.document.definitions.filter((d) => d.kind === Kind.FRAGMENT_DEFINITION).map((d) => [d.name.value, d]));
329
+ const op = /** @type {import("graphql").OperationDefinitionNode} */ (inferred.document.definitions.find((d) => d.kind === Kind.OPERATION_DEFINITION));
330
+ const rootKeys = new Set(collectFields(op.selectionSet.selections, fragments).keys());
331
+ const instances = normalizeSamples(samples, rootKeys);
332
+
333
+ const ctx = {
334
+ fragments, byPath: inferred.byPath, accs: new Map(), notes: /** @type {{ path: string, kind: string, message: string }[]} */ ([]), anchorMs, maxEnum,
335
+ allows: (path) => allowValues === true || (Array.isArray(allowValues) && allowValues.includes(path)),
336
+ };
337
+ walkSamples(ctx, op.selectionSet.selections, "", instances);
338
+
339
+ const spec = {};
340
+ for (const acc of ctx.accs.values()) {
341
+ const { info, path } = acc;
342
+ if (!info || info.synthetic) continue;
343
+ const entry = {};
344
+ const fromDirective = (prop) => info.ovSrc?.[prop] === "directive";
345
+ const sch = info.schemaSourced ? info.schema : null;
346
+ if (acc.sawList && acc.sawSingle) throw bad(`${path}: the samples have a list in some responses and a single value in others`, path);
347
+ if (sch) {
348
+ if (acc.sawList && !sch.list && !info.connection) throw bad(`${path}: a sample has a list, but the schema declares ${String(sch.def.type)}`, path);
349
+ if (acc.sawSingle && sch.list) throw bad(`${path}: a sample has a single value, but the schema declares ${String(sch.def.type)}`, path);
350
+ if (acc.nulls > 0 && sch.nonNull) throw bad(`${path}: a sample has null, but the schema declares ${String(sch.def.type)}`, path);
351
+ } else if (!info.connection && (acc.sawList || acc.sawSingle) && acc.sawList !== info.list && !fromDirective("list")) {
352
+ entry[acc.sawList ? "list" : "one"] = true;
353
+ }
354
+ if (acc.sawList && !info.connection) {
355
+ const paged = [...info.args.keys()].some((a) => PAGING_ARGS.has(a) || a === "skip" || a === "take");
356
+ if (paged) ctx.notes.push({ path, kind: "skipped", message: "a paged list: the page size comes from the query, not the collection" });
357
+ else if (!fromDirective("count")) {
358
+ const min = Math.min(...acc.sizes);
359
+ const max = Math.max(...acc.sizes);
360
+ entry.count = min === max ? min : [min, max];
361
+ }
362
+ }
363
+ if (info.connection) {
364
+ const totals = ctx.accs.get(`${path}.totalCount`)?.values.filter((v) => Number.isInteger(v) && v >= 0) ?? [];
365
+ if (totals.length && !fromDirective("count")) {
366
+ const min = Math.min(...totals);
367
+ const max = Math.max(...totals);
368
+ entry.count = min === max ? min : [min, max];
369
+ } else if (!totals.length) ctx.notes.push({ path, kind: "skipped", message: "a connection without totalCount: its size is unknown" });
370
+ }
371
+ if (acc.nulls > 0 && acc.present > 0 && !fromDirective("nullRate")) {
372
+ entry.nullRate = acc.nulls === acc.present ? 1 : Math.max(0.001, r3(acc.nulls / acc.present));
373
+ }
374
+ if (info.kind === "scalar" && acc.values.length > 0 && !acc.opaque) {
375
+ const learned = learnValues(ctx, acc);
376
+ if (learned) Object.assign(entry, learned);
377
+ }
378
+ if (Object.keys(entry).length) spec[path] = entry;
379
+ }
380
+ compileSpec(spec); // what we hand back must be a valid spec
381
+ return { spec, notes: ctx.notes };
382
+ }
383
+
384
+ /**
385
+ * Infer a side-car spec from real responses to a query.
386
+ *
387
+ * Learns list sizes (`count`), null rates (`nullRate`), integer and float ranges, boolean ratios,
388
+ * date ranges, string lengths and ID/code formats (`pattern`). Free text is never copied: enum-like
389
+ * value sets only appear in the spec when `allowValues` permits (or when the schema declares the enum).
390
+ * @param {string|import("graphql").DocumentNode} query
391
+ * @param {any} samples one response or an array of responses (`data` objects or `{ data }`)
392
+ * @param {{ schema?: any, allowValues?: boolean|string[], maxEnum?: number, anchorDate?: string|Date, operationName?: string, variables?: Record<string, unknown> }} [opts]
393
+ * @returns {Record<string, any>} a plain spec object, ready to commit
394
+ */
395
+ export function specFromSamples(query, samples, opts = {}) {
396
+ return analyzeSamples(query, samples, opts).spec;
397
+ }
package/src/naming.js ADDED
@@ -0,0 +1,74 @@
1
+ // Name tokenising, singularising and type-name derivation (docs/design.md 4.2). FROZEN at 1.0.0.
2
+
3
+ /** Split a field or type name into lowercase words. */
4
+ export function tokenize(name) {
5
+ return name
6
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
7
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
8
+ .split(/[^A-Za-z0-9]+/)
9
+ .filter(Boolean)
10
+ .map((t) => t.toLowerCase());
11
+ }
12
+
13
+ const IRREGULAR = new Map([
14
+ ["people", "person"], ["men", "man"], ["women", "woman"], ["children", "child"], ["mice", "mouse"],
15
+ ["feet", "foot"], ["teeth", "tooth"], ["geese", "goose"], ["analyses", "analysis"], ["indices", "index"],
16
+ ["criteria", "criterion"], ["knives", "knife"], ["wives", "wife"], ["leaves", "leaf"], ["lives", "life"],
17
+ ["shelves", "shelf"], ["wolves", "wolf"], ["data", "data"],
18
+ // -ie words that the generic ies->y rule would turn into -y
19
+ ["movies", "movie"], ["cookies", "cookie"], ["pies", "pie"], ["ties", "tie"], ["lies", "lie"],
20
+ ["zombies", "zombie"], ["selfies", "selfie"], ["rookies", "rookie"], ["calories", "calorie"],
21
+ ["species", "species"], ["series", "series"],
22
+ ]);
23
+
24
+ /** Singularise one lowercase word. Rules in order, first match wins. */
25
+ export function singularize(word) {
26
+ const irregular = IRREGULAR.get(word);
27
+ if (irregular !== undefined) return irregular;
28
+ if (/[^aeiou]ies$/.test(word)) return word.slice(0, -3) + "y";
29
+ if (/(ss|x|ch|sh)es$/.test(word)) return word.slice(0, -2);
30
+ if (/(ss|us|is)$/.test(word)) return word;
31
+ if (word.endsWith("s")) return word.slice(0, -1);
32
+ return word;
33
+ }
34
+
35
+ export const pascal = (tokens) => tokens.map((t) => t[0].toUpperCase() + t.slice(1)).join("");
36
+
37
+ /** Names a derived object type may not take (N5). */
38
+ const RESERVED = new Set(["Int", "Float", "String", "Boolean", "ID", "DateTime", "Query", "Mutation", "Subscription"]);
39
+
40
+ /** N3 + N5: singularise the last word of the field name, PascalCase, avoid collisions. */
41
+ export function typeNameFor(fieldName) {
42
+ const tokens = tokenize(fieldName);
43
+ if (tokens.length === 0) return "Object";
44
+ tokens[tokens.length - 1] = singularize(tokens[tokens.length - 1]);
45
+ let name = pascal(tokens);
46
+ if (/^[0-9]/.test(name)) name = "T" + name;
47
+ return RESERVED.has(name) ? name + "Type" : name;
48
+ }
49
+
50
+ const KINDS = [
51
+ ["person", ["user", "person", "customer", "author", "member", "employee", "contact", "account", "profile", "owner", "student", "patient", "driver", "guest", "reviewer", "commenter", "friend", "follower", "manager", "admin"]],
52
+ ["org", ["company", "organization", "org", "team", "brand", "vendor", "store", "shop", "employer"]],
53
+ ["product", ["product", "item", "sku", "listing"]],
54
+ ["post", ["post", "article", "comment", "message", "review", "note", "story"]],
55
+ ["order", ["order", "invoice", "cart", "payment", "receipt"]],
56
+ ];
57
+
58
+ /** Persona kind of a type name (docs/design.md 6.1): first kind with a matching token wins. */
59
+ export function personaKind(typeName) {
60
+ const tokens = tokenize(typeName);
61
+ for (const [kind, words] of KINDS) if (tokens.some((t) => words.includes(t))) return kind;
62
+ return "generic";
63
+ }
64
+
65
+ const PLURALS = new Map([["person", "people"], ["man", "men"], ["woman", "women"], ["child", "children"], ["mouse", "mice"], ["foot", "feet"], ["tooth", "teeth"], ["goose", "geese"]]);
66
+
67
+ /** Plural of one lowercase word (used to name a collection that is counted but not selected). */
68
+ export function pluralize(word) {
69
+ const irregular = PLURALS.get(word);
70
+ if (irregular) return irregular;
71
+ if (/[^aeiou]y$/.test(word)) return word.slice(0, -1) + "ies";
72
+ if (/(s|x|z|ch|sh)$/.test(word)) return word + "es";
73
+ return word + "s";
74
+ }
@@ -0,0 +1,59 @@
1
+ // Vocabulary packs (docs/design.md section 17). A pack is plain data plus functions:
2
+ // { name, version, vocab, types?, generators(V, base), rules?(ctx), inherit?(typeName) }
3
+ // Packs live in separate modules (`@johnhenry/graceholder/packs/<name>`) so an application only
4
+ // ships the ones it imports. `registerPack` makes a pack addressable by name in `options.packs`.
5
+ import { GraceholderError } from "./errors.js";
6
+
7
+ const NAME = /^[a-z][a-z0-9-]*$/;
8
+ /** name -> pack object, for packs registered with registerPack */
9
+ const REGISTERED = new Map();
10
+ /** name -> the one pack object that has used this name (registered or passed directly) */
11
+ const SEEN = new Map();
12
+
13
+ const bad = (msg, hint) => new GraceholderError("bad-pack", msg, hint ? { hint } : undefined);
14
+
15
+ function validate(pack) {
16
+ if (pack === null || typeof pack !== "object") throw bad("a pack must be an object");
17
+ if (typeof pack.name !== "string" || !NAME.test(pack.name)) throw bad(`pack name must match ${NAME}, got ${JSON.stringify(pack.name)}`);
18
+ if (typeof pack.generators !== "function") throw bad(`pack ${pack.name}: generators must be a function (V, base) => ({ name: (ctx) => value })`);
19
+ if (pack.rules !== undefined && typeof pack.rules !== "function") throw bad(`pack ${pack.name}: rules must be a function`);
20
+ if (pack.inherit !== undefined && typeof pack.inherit !== "function") throw bad(`pack ${pack.name}: inherit must be a function`);
21
+ const seen = SEEN.get(pack.name);
22
+ if (seen && seen !== pack) throw bad(`a different pack named ${JSON.stringify(pack.name)} is already in use`, "pack names are global; pick another name");
23
+ SEEN.set(pack.name, pack);
24
+ }
25
+
26
+ /** Register a pack so `options.packs` can name it. Registering the same pack twice is fine. */
27
+ export function registerPack(pack) {
28
+ validate(pack);
29
+ REGISTERED.set(pack.name, pack);
30
+ return pack;
31
+ }
32
+
33
+ /** Names of the registered packs. */
34
+ export function registeredPacks() {
35
+ return [...REGISTERED.keys()];
36
+ }
37
+
38
+ /**
39
+ * Resolve `options.packs` (names and/or pack objects) to pack objects, in order, without duplicates.
40
+ * @returns {any[]}
41
+ */
42
+ export function resolvePacks(list) {
43
+ if (list === undefined) return [];
44
+ if (!Array.isArray(list)) throw new GraceholderError("bad-options", "packs must be an array of pack names or pack objects");
45
+ const out = [];
46
+ for (const item of list) {
47
+ let pack = item;
48
+ if (typeof item === "string") {
49
+ pack = REGISTERED.get(item);
50
+ if (!pack) {
51
+ throw new GraceholderError("unknown-pack", `unknown pack ${JSON.stringify(item)}`, {
52
+ hint: `registered: ${registeredPacks().join(", ") || "(none)"}. Import it from "@johnhenry/graceholder/packs/${item}" and pass the object, or call registerPack()`,
53
+ });
54
+ }
55
+ } else validate(item);
56
+ if (!out.includes(pack)) out.push(pack);
57
+ }
58
+ return out;
59
+ }
@@ -0,0 +1,26 @@
1
+ # Pack vocabulary provenance
2
+
3
+ Everything in `geo.js`, `commerce.js`, `finance.js` and `health.js` was written by hand for this
4
+ project and is contributed under the repository's MIT licence. No file was copied from, or generated
5
+ from, a third-party dataset (in particular not from faker, whose data files are not used).
6
+
7
+ - **geo.** City, region, country, time zone, postal-code and area-code facts are public, uncopyrightable
8
+ facts typed in by hand; coordinates are approximate city centres rounded to 0.01 degree and are
9
+ placeholders, not for navigation. Time zones are IANA names. Phone templates use each country's calling
10
+ code; the US and Canada use the fictional `555-01xx` block, the United Kingdom the Ofcom drama ranges
11
+ (`020 7946 0xxx`, `0161 496 0xxx`, ...) and Australia the ACMA `5550 xxxx` block. Paris uses `01 99 00 xx xx`, which is believed to be France's reserved fiction range but was not checked against a source.
12
+ Other countries' templates are well-formed but not guaranteed unassigned. Cities are real, so use them
13
+ as place names only.
14
+ - **commerce.** Categories, product nouns and adjectives are ordinary English words. Brand names are
15
+ invented; any resemblance to a trademark is coincidental.
16
+ - **finance.** Currency codes, names, symbols and minor units follow ISO 4217. IBAN lengths and layouts
17
+ follow ISO 13616 / the SWIFT IBAN registry; the check digits are computed (ISO 7064 mod 97-10) and
18
+ verified in tests against published example IBANs. Card number prefixes (4, 51-55, 34/37) and the Luhn
19
+ check are the public conventions; the ABA 3-7-1 weights are the published routing-number checksum.
20
+ Merchants are invented.
21
+ - **health.** Every condition and medication name is invented and obviously whimsical; none is a real
22
+ diagnosis or drug. Specialty names are ordinary category words. Nothing is medical information. A test
23
+ rejects a deny list of real diagnoses and drug-name endings.
24
+
25
+ All tables are order-frozen (values are chosen by index), so any edit, reorder or append is an output
26
+ change. `test/packs.test.js` asserts a SHA-256 checksum of every table.
@@ -0,0 +1,4 @@
1
+ import type { Pack } from "../index.js";
2
+ /** The commerce pack. Import from "@johnhenry/graceholder/packs/commerce". */
3
+ export declare const commerce: Pack;
4
+ export default commerce;