@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.
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
@@ -0,0 +1,195 @@
1
+ // Bulk generation: a query's root collection as a lazy, windowed stream (docs/design.md section 19).
2
+ // Item k of the stream is item k of the equivalent windowed query `field(offset: k, first: 1)`.
3
+ import { Kind, buildSchema, visit } from "graphql";
4
+ import { GraceholderError } from "../errors.js";
5
+ import { prepare } from "../generate.js";
6
+ import { run } from "../execute.js";
7
+ import { selectOperation, toDocument } from "../infer.js";
8
+ import { PAGING_ARGS } from "../args.js";
9
+ import { encodeCursor } from "../cursor.js";
10
+
11
+ export const MAX_CHUNK = 10000;
12
+ const DEFAULT_CHUNK = 500;
13
+
14
+ const bad = (msg, extra) => new GraceholderError("bad-options", msg, extra);
15
+ const name = (value) => ({ kind: Kind.NAME, value });
16
+
17
+ /** Paging argument styles tried in order; schema mode may only declare some of them. */
18
+ const STYLES = [
19
+ ["offset", "first"],
20
+ ["skip", "take"],
21
+ ["offset", "limit"],
22
+ ["skip", "limit"],
23
+ ["skip", "first"],
24
+ ["after", "first"],
25
+ ];
26
+
27
+ function argument(argName, n) {
28
+ const value = argName === "after" ? { kind: Kind.STRING, value: encodeCursor(n) } : { kind: Kind.INT, value: String(n) };
29
+ return { kind: Kind.ARGUMENT, name: name(argName), value };
30
+ }
31
+
32
+ /** The window arguments of a chunk: `offset` items skipped, `size` items wanted. */
33
+ const windowArgs = (style, offset, size) => [argument(style[0], offset), argument(style[1], size)];
34
+
35
+ function usedVariables(nodes) {
36
+ const used = new Set();
37
+ for (const n of nodes) visit(n, { VariableDefinition: () => false, Variable: (v) => void used.add(v.name.value) });
38
+ return used;
39
+ }
40
+
41
+ /** The operation reduced to one root field, windowed by `style`, with the unused variables dropped. */
42
+ function singleFieldDocument(doc, op, field, style) {
43
+ const kept = (field.arguments ?? []).filter((a) => !PAGING_ARGS.has(a.name.value));
44
+ const windowed = { ...field, arguments: [...kept, ...windowArgs(style, 0, 1)] };
45
+ const fragments = doc.definitions.filter((d) => d.kind === Kind.FRAGMENT_DEFINITION);
46
+ const newOp = { ...op, selectionSet: { ...op.selectionSet, selections: [windowed] } };
47
+ const used = usedVariables([newOp, ...fragments]);
48
+ return {
49
+ ...doc,
50
+ definitions: [{ ...newOp, variableDefinitions: (op.variableDefinitions ?? []).filter((d) => used.has(d.variable.name.value)) }, ...fragments],
51
+ };
52
+ }
53
+
54
+ const rootFieldNodes = (op) => op.selectionSet.selections.filter((s) => s.kind === Kind.FIELD && !s.name.value.startsWith("__"));
55
+ const responseKey = (f) => f.alias?.value ?? f.name.value;
56
+
57
+ /** Add every scalar the model knows for a type to each selection of that type (SQL needs one column set per type). */
58
+ export function normalizeSelections(doc, model, rootField) {
59
+ const scalarsOf = (typeName) => {
60
+ const out = [];
61
+ for (const [n, f] of model.get(typeName) ?? []) {
62
+ if (f.kind === "scalar" && !f.special && Object.keys(f.args ?? {}).length === 0) out.push(n);
63
+ }
64
+ return out;
65
+ };
66
+ const rewrite = (selSet, typeName) => {
67
+ const selections = selSet.selections.map((s) => {
68
+ if (s.kind === Kind.FIELD && s.selectionSet) {
69
+ const f = model.get(typeName)?.get(s.name.value);
70
+ return f?.kind === "object" ? { ...s, selectionSet: rewrite(s.selectionSet, f.typeName) } : s;
71
+ }
72
+ if (s.kind === Kind.INLINE_FRAGMENT) return { ...s, selectionSet: rewrite(s.selectionSet, s.typeCondition?.name.value ?? typeName) };
73
+ return s;
74
+ });
75
+ const have = new Set(selections.filter((s) => s.kind === Kind.FIELD).map((s) => s.name.value));
76
+ for (const n of scalarsOf(typeName)) {
77
+ if (!have.has(n)) selections.push({ kind: Kind.FIELD, name: name(n), arguments: [], directives: [] });
78
+ }
79
+ return { ...selSet, selections };
80
+ };
81
+ const [opDef0, ...rest] = doc.definitions;
82
+ const opDef = /** @type {any} */ (opDef0);
83
+ const f = opDef.selectionSet.selections[0];
84
+ const rootModel = model.get(rootField.parentType)?.get(f.name.value);
85
+ const newField = f.selectionSet && rootModel?.kind === "object" ? { ...f, selectionSet: rewrite(f.selectionSet, rootModel.typeName) } : f;
86
+ const defs = rest.map((d) => (d.kind === Kind.FRAGMENT_DEFINITION ? { ...d, selectionSet: rewrite(d.selectionSet, d.typeCondition.name.value) } : d));
87
+ return { ...doc, definitions: [{ ...opDef, selectionSet: { ...opDef.selectionSet, selections: [newField] } }, ...defs] };
88
+ }
89
+
90
+ /**
91
+ * @param {any} query
92
+ * @param {any} options stream options (see docs/api.md)
93
+ * @param {any} [world]
94
+ * @param {{ normalize?: boolean }} [internal]
95
+ */
96
+ export function planStream(query, options = {}, world = undefined, internal = {}) {
97
+ const { count, offset = 0, chunkSize = DEFAULT_CHUNK, field } = options;
98
+ const isCount = (v) => Number.isInteger(v) && v >= 0;
99
+ if (count !== undefined && !isCount(count)) throw bad(`count must be a non-negative integer, got ${JSON.stringify(count)}`);
100
+ if (!isCount(offset)) throw bad(`offset must be a non-negative integer, got ${JSON.stringify(offset)}`);
101
+ if (!Number.isInteger(chunkSize) || chunkSize < 1 || chunkSize > MAX_CHUNK) throw bad(`chunkSize must be an integer from 1 to ${MAX_CHUNK}, got ${JSON.stringify(chunkSize)}`);
102
+
103
+ const doc = toDocument(query);
104
+ const op = selectOperation(doc, options.operationName);
105
+ const roots = rootFieldNodes(op);
106
+ if (roots.length === 0) throw bad("stream needs a query with a root collection field");
107
+
108
+ // infer the whole operation once to learn which root fields are collections
109
+ const whole = prepare(doc, options, world).inferred;
110
+ const rootType = whole.rootType ?? "Query";
111
+ const isCollection = (f) => {
112
+ const m = whole.model.get(rootType)?.get(f.name.value);
113
+ return Boolean(m && m.kind === "object" && (m.list || m.connection));
114
+ };
115
+ let node;
116
+ if (field !== undefined) {
117
+ node = roots.find((f) => responseKey(f) === field);
118
+ if (!node) throw bad(`the query has no root field ${JSON.stringify(field)}`, { hint: `root fields: ${roots.map(responseKey).join(", ")}` });
119
+ if (!isCollection(node)) throw bad(`the root field ${JSON.stringify(field)} is not a collection`);
120
+ } else {
121
+ node = roots.find(isCollection);
122
+ if (!node) throw bad("stream needs a root collection field (a list or a connection); none of the root fields is one", { hint: `root fields: ${roots.map(responseKey).join(", ")}` });
123
+ }
124
+ if (whole.abstracts.has(whole.model.get(rootType).get(node.name.value).typeName)) {
125
+ throw bad("stream does not support a root collection of a union or interface type");
126
+ }
127
+
128
+ // try the paging styles in order; without a user schema the first always fits
129
+ let built;
130
+ let lastError;
131
+ for (const style of STYLES) {
132
+ try {
133
+ let single = singleFieldDocument(doc, op, node, style);
134
+ let p = prepare(single, { ...options, strict: false, operationName: undefined }, world);
135
+ if (internal.normalize) {
136
+ single = normalizeSelections(single, p.inferred.model, { parentType: rootType });
137
+ p = prepare(single, { ...options, strict: false, operationName: undefined }, world);
138
+ }
139
+ built = { style, ...p, document: single };
140
+ break;
141
+ } catch (e) {
142
+ lastError = e;
143
+ if (!options.schema || !(e instanceof GraceholderError) || e.code !== "graphql") throw e;
144
+ }
145
+ }
146
+ if (!built) throw lastError;
147
+
148
+ const { inferred, runOptions, style } = built;
149
+ const rootInfo = inferred.model.get(inferred.rootType ?? "Query").get(node.name.value);
150
+ const key = responseKey(node);
151
+ const declared = rootInfo.count !== undefined;
152
+ if (count === undefined && !declared) {
153
+ throw bad(`stream needs a count: ${JSON.stringify(key)} has no declared size`, { hint: `pass { count: 1000 } or declare the size, for example ${key} @count(n: 1000)` });
154
+ }
155
+ inferred.schema ??= buildSchema(inferred.sdl);
156
+ const opDef = /** @type {any} */ (inferred.document.definitions[0]);
157
+ const field0 = opDef.selectionSet.selections[0];
158
+ const keep = (field0.arguments ?? []).filter((a) => !PAGING_ARGS.has(a.name.value));
159
+
160
+ /** Run one window and return its root items plus the trace of entity keys (when asked). */
161
+ const fetchWindow = (start, size, trace) => {
162
+ const win = { ...field0, arguments: [...keep, ...windowArgs(style, start, size)] };
163
+ const document = {
164
+ ...inferred.document,
165
+ definitions: [{ ...opDef, selectionSet: { ...opDef.selectionSet, selections: [win] } }, ...inferred.document.definitions.slice(1)],
166
+ };
167
+ const result = run({ ...inferred, document }, trace ? { ...runOptions, trace } : runOptions);
168
+ if (result.errors?.length) {
169
+ const own = result.errors.find((e) => e.originalError instanceof GraceholderError);
170
+ if (own) throw own.originalError;
171
+ throw new GraceholderError("graphql", result.errors[0].message, { cause: result.errors[0], errors: result.errors });
172
+ }
173
+ const value = JSON.parse(JSON.stringify(/** @type {any} */ (result.data)[key]));
174
+ if (!rootInfo.connection) return value;
175
+ if (Array.isArray(value?.nodes)) return value.nodes;
176
+ if (Array.isArray(value?.edges)) return value.edges.map((e) => e.node);
177
+ throw bad(`the connection ${JSON.stringify(key)} must select nodes or edges { node } to be streamed`);
178
+ };
179
+
180
+ return { key, rootInfo, count, offset, chunkSize, fetchWindow, inferred, runOptions, world };
181
+ }
182
+
183
+ /** The windows `[start, start + size)` covering the requested range, lazily. */
184
+ export function* windows(plan, from = plan.offset, count = plan.count, trace) {
185
+ let start = from;
186
+ let remaining = count ?? Infinity;
187
+ while (remaining > 0) {
188
+ const size = Math.min(plan.chunkSize, remaining);
189
+ const items = plan.fetchWindow(start, size, trace);
190
+ yield { start, items };
191
+ start += items.length;
192
+ remaining -= items.length;
193
+ if (items.length < size) return;
194
+ }
195
+ }
@@ -0,0 +1,67 @@
1
+ // The static shape of a stream's items: which fields a root item has, by response key.
2
+ // Built from the inferred document and model, or from a sample value when there is no model.
3
+ import { Kind } from "graphql";
4
+
5
+ /**
6
+ * @typedef {{ key: string, name: string, kind: "scalar"|"object", list: boolean, sdl?: string, gen?: string, typeName?: string, connection?: boolean, args?: boolean, node?: ShapeNode }} ShapeField
7
+ * @typedef {{ typeName: string, fields: Map<string, ShapeField> }} ShapeNode
8
+ */
9
+
10
+ /**
11
+ * Shape of the items of the single root field of `document` (the document a plan executes).
12
+ * @returns {ShapeNode|null} null for a connection root
13
+ */
14
+ export function shapeOfDocument(document, model, rootType) {
15
+ const fragments = new Map(document.definitions.filter((d) => d.kind === Kind.FRAGMENT_DEFINITION).map((d) => [d.name.value, d]));
16
+ const collect = (selSet, typeName, node) => {
17
+ for (const s of selSet.selections) {
18
+ if (s.kind === Kind.INLINE_FRAGMENT) {
19
+ collect(s.selectionSet, s.typeCondition?.name.value ?? typeName, node);
20
+ } else if (s.kind === Kind.FRAGMENT_SPREAD) {
21
+ const def = fragments.get(s.name.value);
22
+ collect(def.selectionSet, def.typeCondition.name.value, node);
23
+ } else if (s.kind === Kind.FIELD) {
24
+ const name = s.name.value;
25
+ if (name.startsWith("__")) continue;
26
+ const f = model.get(typeName)?.get(name);
27
+ if (!f) continue;
28
+ const key = s.alias?.value ?? name;
29
+ let entry = node.fields.get(key);
30
+ if (!entry) {
31
+ entry = {
32
+ key, name, kind: f.kind === "object" ? "object" : "scalar", list: Boolean(f.list), sdl: f.sdl, gen: f.gen, typeName: f.typeName,
33
+ connection: Boolean(f.connection), args: (s.arguments?.length ?? 0) > 0,
34
+ };
35
+ if (entry.kind === "object") entry.node = { typeName: f.typeName, fields: new Map() };
36
+ node.fields.set(key, entry);
37
+ }
38
+ if (s.selectionSet && entry.node) collect(s.selectionSet, f.typeName, entry.node);
39
+ }
40
+ }
41
+ };
42
+ const field = document.definitions[0].selectionSet.selections[0];
43
+ const root = model.get(rootType).get(field.name.value);
44
+ /** @type {ShapeNode} */
45
+ const node = { typeName: root.typeName, fields: new Map() };
46
+ if (root.connection) return null; // a connection root streams its nodes: the caller samples the first item
47
+ collect(field.selectionSet, root.typeName, node);
48
+ return node;
49
+ }
50
+
51
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
52
+
53
+ /** Shape guessed from one sample item (first-seen keys; nulls are scalars). */
54
+ export function shapeOfValue(value) {
55
+ /** @type {ShapeNode} */
56
+ const node = { typeName: "", fields: new Map() };
57
+ if (!isObject(value)) return node;
58
+ for (const [key, v] of Object.entries(value)) {
59
+ const first = Array.isArray(v) ? v.find((x) => x !== null && x !== undefined) : v;
60
+ if (isObject(first)) {
61
+ node.fields.set(key, { key, name: key, kind: "object", list: Array.isArray(v), node: shapeOfValue(first) });
62
+ } else {
63
+ node.fields.set(key, { key, name: key, kind: "scalar", list: Array.isArray(v) });
64
+ }
65
+ }
66
+ return node;
67
+ }
@@ -0,0 +1,24 @@
1
+ // Serialisers return a sync generator for a sync source and an async generator for an async-only one.
2
+ import { GraceholderError } from "../errors.js";
3
+
4
+ export const isSync = (source) => source != null && typeof source[Symbol.iterator] === "function";
5
+ export const isAsync = (source) => source != null && typeof source[Symbol.asyncIterator] === "function";
6
+
7
+ /** Map every item to a string (or to nothing), keeping the sync/async nature of the source. */
8
+ /**
9
+ * @param {any} source
10
+ * @param {(item: any) => string} fn
11
+ */
12
+ export function mapSource(source, fn) {
13
+ if (isSync(source)) {
14
+ return (function* () {
15
+ for (const item of source) yield fn(item);
16
+ })();
17
+ }
18
+ if (isAsync(source)) {
19
+ return (async function* () {
20
+ for await (const item of source) yield fn(item);
21
+ })();
22
+ }
23
+ throw new GraceholderError("bad-options", "the source must be an iterable or an async iterable of items, for example a stream()");
24
+ }
@@ -0,0 +1,366 @@
1
+ // SQL seeding: INSERT statements (and optional DDL) for Postgres and SQLite. See docs/design.md section 19
2
+ // and docs/api.md "toSQL" for the table, key and foreign-key strategy.
3
+ import { GraceholderError } from "../errors.js";
4
+ import { planStream } from "./plan.js";
5
+ import { POOL_SIZE } from "../execute.js";
6
+ import { shapeOfDocument } from "./shape.js";
7
+
8
+ const bad = (msg, extra) => new GraceholderError("bad-options", msg, extra);
9
+ const DIALECTS = new Set(["postgres", "sqlite"]);
10
+ const NUMERIC = /^(0|[1-9][0-9]*)$/;
11
+
12
+ export const snake = (s) =>
13
+ String(s)
14
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
15
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9_]+/g, "_");
18
+
19
+ const quoteIdent = (id) => `"${String(id).replaceAll('"', '""')}"`;
20
+ const quoteText = (s) => `'${String(s).replaceAll("'", "''")}'`;
21
+
22
+ /** Column SQL type for a GraphQL scalar. */
23
+ function sqlType(dialect, sdl, list) {
24
+ const pg = dialect === "postgres";
25
+ if (list) return pg ? "JSONB" : "TEXT";
26
+ switch (sdl) {
27
+ case "Int": return "INTEGER";
28
+ case "Float": return pg ? "DOUBLE PRECISION" : "REAL";
29
+ case "Boolean": return pg ? "BOOLEAN" : "INTEGER";
30
+ case "DateTime": return pg ? "TIMESTAMPTZ" : "TEXT";
31
+ case "Date": return pg ? "DATE" : "TEXT";
32
+ default: return "TEXT";
33
+ }
34
+ }
35
+
36
+ /** A SQL literal for a value in a column of kind `kind` ("int", "float", "bool", "json" or "text"). */
37
+ function literal(dialect, kind, value) {
38
+ if (value === null || value === undefined) return "NULL";
39
+ if (kind === "json" || (typeof value === "object")) return quoteText(JSON.stringify(value));
40
+ if (kind === "bool") return dialect === "postgres" ? (value ? "TRUE" : "FALSE") : value ? "1" : "0";
41
+ if (kind === "int" || kind === "float") {
42
+ if (typeof value === "number" && Number.isFinite(value)) return Object.is(value, -0) ? "0" : String(value);
43
+ if (typeof value === "boolean") return value ? "1" : "0";
44
+ return quoteText(value);
45
+ }
46
+ if (typeof value === "boolean") return value ? "TRUE" : "FALSE";
47
+ return quoteText(typeof value === "string" ? value : String(value));
48
+ }
49
+
50
+ const kindOf = (sdl, list) => (list ? "json" : sdl === "Int" ? "int" : sdl === "Float" ? "float" : sdl === "Boolean" ? "bool" : "text");
51
+
52
+ function pathString(path) {
53
+ const parts = [];
54
+ for (let p = path; p; p = p.prev) parts.push(p.key);
55
+ return parts.reverse().join("/");
56
+ }
57
+
58
+ /**
59
+ * Plan the tables of a shape: one per entity type, scalar columns, foreign keys and key kinds.
60
+ */
61
+ function planTables(plan, shape) {
62
+ const { inferred, runOptions } = plan;
63
+ const model = inferred.model;
64
+ const rootType = inferred.rootType ?? "Query";
65
+ const world = runOptions.world;
66
+ const packs = runOptions.packs ?? [];
67
+ /** @type {Map<string, any>} */
68
+ const tables = new Map();
69
+ const tableOf = (node) => {
70
+ let t = tables.get(node.typeName);
71
+ if (!t) {
72
+ t = { type: node.typeName, scalars: new Map(), singles: new Map(), lists: new Map(), text: false, dedupe: false, owners: [] };
73
+ tables.set(node.typeName, t);
74
+ }
75
+ return t;
76
+ };
77
+ const rootInfo = plan.rootInfo;
78
+ const rootField = inferred.document.definitions[0].selectionSet.selections[0];
79
+ const rootArgs = (rootField.arguments ?? []).some((a) => !["offset", "first", "skip", "take", "limit", "after"].includes(a.name.value));
80
+ const rootShared = rootInfo.own ? false : (rootInfo.shared ?? true);
81
+
82
+ const visit = (node, siteShared, siteText) => {
83
+ const t = tableOf(node);
84
+ if (siteText) t.text = true;
85
+ if (siteShared) t.dedupe = true;
86
+ if (world?.touches(node.typeName)) t.text = true;
87
+ for (const f of node.fields.values()) {
88
+ const info = model.get(node.typeName)?.get(f.name);
89
+ if (f.kind === "scalar") {
90
+ const prev = t.scalars.get(f.name);
91
+ if (!prev || (prev.key !== f.name && f.key === f.name)) t.scalars.set(f.name, { key: f.key, name: f.name, sdl: f.sdl, list: f.list, gen: f.gen });
92
+ continue;
93
+ }
94
+ if (f.connection) throw bad("toSQL does not support Relay connections: select a plain list instead", { hint: `field ${f.name}` });
95
+ if (inferred.abstracts.has(f.typeName)) throw bad("toSQL does not support union or interface fields", { hint: `field ${f.name}` });
96
+ const hasArgs = f.args;
97
+ if (f.list) {
98
+ // nested lists are owned by their parent (text keys "<parent key>.<field>.<i>") unless declared shared
99
+ const shared = info?.own ? false : (info?.shared ?? false);
100
+ t.lists.set(f.name, { key: f.key, name: f.name, target: f.typeName, shared });
101
+ visit(f.node, shared, !shared || hasArgs);
102
+ } else {
103
+ // a singular child is a reference into the type's shared pool unless it is owned (spec `own`, or a geo-style child)
104
+ const own = Boolean(info?.own) || packs.some((p) => p.inherit?.(f.typeName));
105
+ t.singles.set(f.name, { key: f.key, name: f.name, target: f.typeName, owned: own });
106
+ visit(f.node, !own, own || hasArgs || Boolean(info?.keyArgs));
107
+ }
108
+ }
109
+ };
110
+ const rootNode = shape;
111
+ visit(rootNode, rootShared, !rootShared || rootArgs);
112
+ return tables;
113
+ }
114
+
115
+ /**
116
+ * A resumable SQL writer: `header()`, then `rows(from, to)` in order, then `footer()`.
117
+ * @param {any} source a query, or a `stream()`
118
+ * @param {any} [options]
119
+ */
120
+ export function sqlWriter(source, options = {}) {
121
+ const dialect = options.dialect ?? "postgres";
122
+ if (!DIALECTS.has(dialect)) throw bad(`dialect must be "postgres" or "sqlite", got ${JSON.stringify(dialect)}`);
123
+ const names = options.names ?? "snake";
124
+ if (names !== "snake" && names !== "preserve") throw bad(`names must be "snake" or "preserve", got ${JSON.stringify(names)}`);
125
+ const ddl = options.ddl ?? true;
126
+ const dropExisting = options.dropExisting ?? false;
127
+ const transaction = options.transaction ?? true;
128
+ const prefix = options.tablePrefix ?? "";
129
+ const rename = names === "snake" ? snake : (s) => s;
130
+ const cfg = source?.config ?? { query: source, options: {}, world: undefined };
131
+ const plan = planStream(cfg.query, { ...cfg.options, ...streamOptions(options) }, cfg.world, { normalize: true });
132
+ const pg = dialect === "postgres";
133
+
134
+ const shape = shapeOfDocument(plan.inferred.document, plan.inferred.model, plan.inferred.rootType ?? "Query");
135
+ if (shape === null) throw bad("toSQL does not support a Relay connection as the root collection: use a plain list");
136
+ const tables = planTables(plan, shape);
137
+ const poolSize = plan.runOptions.poolSize ?? POOL_SIZE;
138
+
139
+ // names and columns
140
+ const used = new Set();
141
+ const colsOf = new Map();
142
+ for (const t of tables.values()) {
143
+ t.table = prefix + (options.tableNames?.[t.type] ?? rename(t.type));
144
+ if (used.has(t.table)) throw bad(`two entity types map to the table ${JSON.stringify(t.table)}`, { hint: "set tableNames or names: \"preserve\"" });
145
+ used.add(t.table);
146
+ const hasIdKey = t.scalars.get("id")?.gen === "id.key";
147
+ t.int = !t.text;
148
+ t.pk = t.int && hasIdKey ? "id" : "_key";
149
+ t.pkType = t.int ? "INTEGER" : "TEXT";
150
+ }
151
+ // child tables of owned lists get one foreign key per owner
152
+ const ownerLists = new Map();
153
+ for (const t of tables.values()) for (const l of t.lists.values()) {
154
+ const arr = ownerLists.get(l.target) ?? [];
155
+ arr.push({ owner: t, list: l });
156
+ ownerLists.set(l.target, arr);
157
+ }
158
+ for (const t of tables.values()) {
159
+ /** @type {any[]} */
160
+ const cols = [{ name: t.pk, type: t.pkType, kind: t.int ? "int" : "text", pk: true }];
161
+ for (const s of t.scalars.values()) {
162
+ if (s.name === t.pk) continue;
163
+ cols.push({ name: rename(s.name), type: sqlType(dialect, s.sdl, s.list), kind: kindOf(s.sdl, s.list), scalar: s });
164
+ }
165
+ for (const s of t.singles.values()) {
166
+ const target = tables.get(s.target);
167
+ cols.push({ name: `${rename(s.name)}_id`, type: target.pkType, kind: target.int ? "int" : "text", fk: target, single: s });
168
+ }
169
+ const owners = ownerLists.get(t.type) ?? [];
170
+ for (const { owner, list } of owners) {
171
+ const col = owners.length === 1 ? `${rename(owner.type)}_id` : `${rename(owner.type)}_${rename(list.name)}_id`;
172
+ cols.push({ name: col, type: owner.pkType, kind: owner.int ? "int" : "text", fk: owner, owner: { type: owner.type, list: list.name } });
173
+ }
174
+ const seen = new Set();
175
+ for (const c of cols) {
176
+ if (seen.has(c.name)) throw bad(`table ${JSON.stringify(t.table)} would have two columns named ${JSON.stringify(c.name)}`, { hint: "rename a field with an alias or set names: \"preserve\"" });
177
+ seen.add(c.name);
178
+ }
179
+ colsOf.set(t.type, cols);
180
+ t.cols = cols;
181
+ }
182
+
183
+ const q = quoteIdent;
184
+ const insertPrefix = new Map();
185
+ for (const t of tables.values()) insertPrefix.set(t.type, `INSERT INTO ${q(t.table)} (${t.cols.map((c) => q(c.name)).join(", ")}) VALUES (`);
186
+
187
+ const fkClause = (c) => `REFERENCES ${q(c.fk.table)} (${q(c.fk.pk)}) DEFERRABLE INITIALLY DEFERRED`;
188
+
189
+ function header() {
190
+ const out = [`-- graceholder: ${dialect} seed data, generated deterministically\n`];
191
+ if (transaction) out.push("BEGIN;\n");
192
+ if (ddl) {
193
+ if (dropExisting) for (const t of [...tables.values()].reverse()) out.push(`DROP TABLE IF EXISTS ${q(t.table)}${pg ? " CASCADE" : ""};\n`);
194
+ for (const t of tables.values()) {
195
+ const lines = t.cols.map((c) => {
196
+ const base = `${q(c.name)} ${c.type}${c.pk ? " PRIMARY KEY" : ""}`;
197
+ return !pg && c.fk ? `${base} ${fkClause(c)}` : base;
198
+ });
199
+ out.push(`CREATE TABLE ${q(t.table)} (\n ${lines.join(",\n ")}\n);\n`);
200
+ }
201
+ if (pg) {
202
+ for (const t of tables.values()) for (const c of t.cols) {
203
+ if (c.fk) out.push(`ALTER TABLE ${q(t.table)} ADD CONSTRAINT ${q(`${t.table}_${c.name}_fkey`)} FOREIGN KEY (${q(c.name)}) ${fkClause(c)};\n`);
204
+ }
205
+ }
206
+ }
207
+ return out.join("");
208
+ }
209
+
210
+ function footer() {
211
+ return transaction ? "COMMIT;\n" : "";
212
+ }
213
+
214
+ // ---- rows
215
+ const seen = new Map();
216
+ let pos = plan.offset;
217
+ const end = plan.count === undefined ? Infinity : plan.offset + plan.count;
218
+ let finished = false;
219
+
220
+ const keyLiteral = (t, key) => {
221
+ if (t.int) {
222
+ if (!NUMERIC.test(key)) throw new GraceholderError("internal", `entity key ${JSON.stringify(key)} of ${t.type} is not numeric but its primary key is an integer`);
223
+ return key;
224
+ }
225
+ return quoteText(key);
226
+ };
227
+
228
+ /**
229
+ * Write the rows of one entity and everything below it.
230
+ *
231
+ * An entity of a shared type can be met in several places with different selections (as a root
232
+ * item, as `post.author`, ...), and what hangs below it can differ per place. Its row is written
233
+ * once, the first time. Each later meeting completes what the earlier ones left out: singular
234
+ * references the row was written without (an UPDATE of the foreign key), and, because every owned
235
+ * descendant is tracked too, child rows and their own references that are not written yet.
236
+ *
237
+ * `seen` has one entry per tracked entity: shared entities with a pool key (at most `poolSize` per
238
+ * type) and everything they own, which is a function of those keys and not of the stream length, so
239
+ * memory stays bounded. Owned entities of untracked entities (rows of a long root collection) are
240
+ * unique by their path and are not remembered.
241
+ *
242
+ * @param {boolean} shared the place is a shared-pool reference (or a root item)
243
+ * @param {boolean} tracked the entity is owned by a tracked entity
244
+ */
245
+ function walk(node, obj, path, trace, shared, tracked, links, out, emit) {
246
+ const ref = trace.get(path);
247
+ if (!ref) throw new GraceholderError("internal", `no entity recorded at ${path}`);
248
+ const t = tables.get(ref.typeName);
249
+ const key = String(ref.entityKey);
250
+ /** @type {Set<string>|null} singular fields already written for this entity (null: not tracked) */
251
+ let done = null;
252
+ let known = false;
253
+ let stored = false;
254
+ if (tracked || (t.dedupe && shared)) {
255
+ const id = `${t.type}\u0000${key}`;
256
+ done = seen.get(id) ?? null;
257
+ known = done !== null;
258
+ if (!known) {
259
+ done = new Set();
260
+ if (tracked || !NUMERIC.test(key) || Number(key) <= poolSize) seen.set(id, done);
261
+ }
262
+ stored = seen.has(id);
263
+ }
264
+ const walkSingle = (single, child) =>
265
+ walk(node.fields.get(single.key).node, child, `${path}/${single.key}`, trace, !single.owned, stored && single.owned, null, out, emit);
266
+
267
+ if (!known) {
268
+ const values = [];
269
+ for (const c of t.cols) {
270
+ if (c.pk) values.push(keyLiteral(t, key));
271
+ else if (c.scalar) values.push(literal(dialect, c.kind, obj[c.scalar.key]));
272
+ else if (c.single) {
273
+ const child = obj[c.single.key];
274
+ if (child !== undefined) done?.add(c.single.name);
275
+ if (child === null || child === undefined) values.push("NULL");
276
+ else values.push(keyLiteral(c.fk, walkSingle(c.single, child)));
277
+ } else if (c.owner) {
278
+ const link = links?.get(`${c.owner.type}\u0000${c.owner.list}`);
279
+ values.push(link === undefined ? "NULL" : keyLiteral(c.fk, link));
280
+ }
281
+ }
282
+ if (emit) out.push(`${insertPrefix.get(t.type)}${values.join(", ")});\n`);
283
+ } else {
284
+ for (const c of t.cols) {
285
+ if (!c.single) continue;
286
+ const child = obj[c.single.key];
287
+ if (child === undefined || child === null) continue;
288
+ const childKey = walkSingle(c.single, child); // writes what the child still lacks
289
+ if (done && !done.has(c.single.name)) {
290
+ done.add(c.single.name);
291
+ if (emit) out.push(`UPDATE ${q(t.table)} SET ${q(c.name)} = ${keyLiteral(c.fk, childKey)} WHERE ${q(t.pk)} = ${keyLiteral(t, key)};\n`);
292
+ }
293
+ }
294
+ }
295
+ for (const l of t.lists.values()) {
296
+ const arr = obj[l.key];
297
+ if (!Array.isArray(arr)) continue;
298
+ const entry = node.fields.get(l.key);
299
+ const link = new Map([[`${t.type}\u0000${l.name}`, key]]);
300
+ arr.forEach((el, i) => {
301
+ if (el !== null && el !== undefined) walk(entry.node, el, `${path}/${l.key}/${i}`, trace, l.shared, stored && !l.shared, link, out, emit);
302
+ });
303
+ }
304
+ return key;
305
+ }
306
+
307
+ /** Statements for the items `[from, to)` (absolute indexes). Must be consumed in order; earlier items are replayed silently. */
308
+ function* rows(from = pos, to = end) {
309
+ if (from < pos) throw bad(`rows must be requested in order: the writer is at item ${pos}, asked for ${from}`);
310
+ if (finished) return;
311
+ const stop = Math.min(to, end);
312
+ while (pos < stop && !finished) {
313
+ const emit = pos >= from;
314
+ const target = emit ? stop : Math.min(from, stop);
315
+ const size = Math.min(plan.chunkSize, target - pos);
316
+ const trace = new Map();
317
+ const items = plan.fetchWindow(pos, size, (path, value) => {
318
+ const base = pathString(path);
319
+ if (Array.isArray(value)) value.forEach((v, i) => v !== null && typeof v === "object" && v.typeName && trace.set(`${base}/${i}`, v));
320
+ else if (value.typeName) trace.set(base, value);
321
+ });
322
+ const out = [];
323
+ items.forEach((item, i) => {
324
+ walk(shape, item, `${plan.key}/${i}`, trace, true, false, null, out, emit);
325
+ });
326
+ pos += items.length;
327
+ if (items.length < size) finished = true;
328
+ if (out.length) yield out.join("");
329
+ }
330
+ }
331
+
332
+ const describe = () =>
333
+ [...tables.values()].map((t) => ({ type: t.type, table: t.table, primaryKey: t.pk, columns: t.cols.map((c) => ({ name: c.name, type: c.type, references: c.fk ? c.fk.table : undefined })) }));
334
+
335
+ return { dialect, header, rows, footer, tables: describe };
336
+ }
337
+
338
+ /** The stream-level options inside a toSQL options object. */
339
+ function streamOptions(options) {
340
+ const out = {};
341
+ for (const k of ["count", "offset", "chunkSize", "field", "seed", "spec", "packs", "images", "distributions", "schema", "variables", "operationName", "locale", "anchorDate", "strict", "scenario", "scenarioOptions"]) {
342
+ if (options[k] !== undefined) out[k] = options[k];
343
+ }
344
+ return out;
345
+ }
346
+
347
+ /**
348
+ * SQL for the root collection of a query and everything below it: DDL, INSERTs, COMMIT.
349
+ * @param {any} source a query, or a `stream()` (including `world.stream()`)
350
+ * @param {any} [options] stream options plus `dialect`, `ddl`, `dropExisting`, `transaction`, `names`, `tablePrefix`, `tableNames`, `header`, `footer`
351
+ * @returns {Generator<string>} statements, each ending in a newline
352
+ */
353
+ export function toSQL(source, options = {}) {
354
+ const w = sqlWriter(source, options);
355
+ return (function* () {
356
+ if (options.header !== false) {
357
+ const h = w.header();
358
+ if (h) yield h;
359
+ }
360
+ yield* w.rows();
361
+ if (options.footer !== false) {
362
+ const f = w.footer();
363
+ if (f) yield f;
364
+ }
365
+ })();
366
+ }