@gleanql/codegen 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Liljengard
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @gleanql/codegen
2
+
3
+ The schema generator for [GleanQL](https://github.com/gleanql/gleanql) — GraphQL
4
+ without writing GraphQL. From a GraphQL schema (SDL or introspection) it
5
+ generates:
6
+
7
+ - the **schema model** the compiler and runtime share,
8
+ - **branded TypeScript types** for every schema type — so API drift (a
9
+ removed field, a tightened nullability) becomes a type error in your
10
+ components,
11
+ - the typed **`glean` accessor** your components read from.
12
+
13
+ You normally don't install this directly — the
14
+ [`@gleanql/vite`](https://github.com/gleanql/gleanql/tree/main/packages/vite)
15
+ plugin runs it on every build from your `schema.graphql`.
16
+
17
+ ## Docs
18
+
19
+ Full documentation lives in the [GleanQL repo](https://github.com/gleanql/gleanql).
20
+
21
+ MIT
@@ -0,0 +1,119 @@
1
+ //#region src/introspection.d.ts
2
+ /**
3
+ * GraphQL introspection model + type-ref helpers.
4
+ *
5
+ * We consume the standard introspection result (`data.__schema`) directly rather
6
+ * than depend on graphql-js at runtime — the shape is stable and small. These
7
+ * structural types match what `introspectionFromSchema()` produces, so a real
8
+ * introspection drops straight in.
9
+ */
10
+ type IntrospectionTypeKind = "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT" | "LIST" | "NON_NULL";
11
+ type IntrospectionTypeRef = {
12
+ readonly kind: "NON_NULL";
13
+ readonly ofType: IntrospectionTypeRef;
14
+ } | {
15
+ readonly kind: "LIST";
16
+ readonly ofType: IntrospectionTypeRef;
17
+ } | {
18
+ readonly kind: "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT";
19
+ readonly name: string;
20
+ readonly ofType?: null;
21
+ };
22
+ interface IntrospectionInputValue {
23
+ readonly name: string;
24
+ readonly type: IntrospectionTypeRef;
25
+ readonly defaultValue?: string | null;
26
+ }
27
+ interface IntrospectionField {
28
+ readonly name: string;
29
+ readonly args: readonly IntrospectionInputValue[];
30
+ readonly type: IntrospectionTypeRef;
31
+ }
32
+ interface IntrospectionType {
33
+ readonly kind: IntrospectionTypeKind;
34
+ readonly name: string;
35
+ readonly description?: string | null;
36
+ readonly fields?: readonly IntrospectionField[] | null;
37
+ readonly inputFields?: readonly IntrospectionInputValue[] | null;
38
+ readonly interfaces?: readonly IntrospectionTypeRef[] | null;
39
+ readonly enumValues?: ReadonlyArray<{
40
+ readonly name: string;
41
+ }> | null;
42
+ readonly possibleTypes?: readonly IntrospectionTypeRef[] | null;
43
+ }
44
+ interface IntrospectionSchema {
45
+ readonly queryType: {
46
+ readonly name: string;
47
+ };
48
+ readonly mutationType?: {
49
+ readonly name: string;
50
+ } | null;
51
+ readonly subscriptionType?: {
52
+ readonly name: string;
53
+ } | null;
54
+ readonly types: readonly IntrospectionType[];
55
+ }
56
+ /** The named type at the bottom of a (possibly list/non-null) type ref. */
57
+ declare function namedTypeName(ref: IntrospectionTypeRef): string;
58
+ /** True if a LIST appears anywhere in the type ref. */
59
+ declare function isListType(ref: IntrospectionTypeRef): boolean;
60
+ /** True if the outermost wrapper is NON_NULL. */
61
+ declare function isNonNull(ref: IntrospectionTypeRef): boolean;
62
+ /** Render a GraphQL SDL type ref string, e.g. `[Product!]!`. */
63
+ declare function renderGraphQLType(ref: IntrospectionTypeRef): string;
64
+ /** Introspection meta types (`__Schema`, `__Type`, …) we never generate. */
65
+ declare function isInternalType(name: string): boolean;
66
+ //#endregion
67
+ //#region src/schema-model.d.ts
68
+ interface GenerateSchemaModelOptions {
69
+ /** Exported const name for the SchemaModel. Default: `schema`. */
70
+ readonly exportName?: string;
71
+ }
72
+ declare function generateSchemaModel(schema: IntrospectionSchema, options?: GenerateSchemaModelOptions): string;
73
+ //#endregion
74
+ //#region src/ts-render.d.ts
75
+ /** Shared TypeScript-type rendering for branded types and the graph accessors. */
76
+ declare const DEFAULT_SCALAR_TYPES: Record<string, string>;
77
+ //#endregion
78
+ //#region src/types.d.ts
79
+ /**
80
+ * Generate branded TypeScript types from introspection.
81
+ *
82
+ * To app code these read as ordinary schema types (`Product`, `Image`, …); the
83
+ * compiler recognizes them via the `__typename` brand. Nullability, lists,
84
+ * callable fields (field arguments), enums, interfaces, unions and input objects
85
+ * all surface — so TypeScript catches API drift (a removed/renamed field, a
86
+ * tightened nullability) at compile time.
87
+ */
88
+ interface GenerateTypesOptions {
89
+ /** TS type for each GraphQL scalar. Unlisted custom scalars default to `string`. */
90
+ readonly scalarTypes?: Record<string, string>;
91
+ }
92
+ declare function generateTypes(schema: IntrospectionSchema, options?: GenerateTypesOptions): string;
93
+ //#endregion
94
+ //#region src/graph.d.ts
95
+ /**
96
+ * Generate the `graph` accessor object: one method per Query root field plus the
97
+ * `components(...)` registry helper. At build time the compiler reads these to
98
+ * learn the root fields and their types; at runtime they are backed by the
99
+ * runtime's bound graph (see `@gleanql/react`'s `bindGraph`). The generated bodies
100
+ * are typed stubs — the real values are proxies.
101
+ */
102
+ interface GenerateGraphOptions {
103
+ readonly scalarTypes?: Record<string, string>;
104
+ /** Import path for the generated branded types. Default: `./schema.js`. */
105
+ readonly schemaImportPath?: string;
106
+ }
107
+ declare function generateGraph(schema: IntrospectionSchema, options?: GenerateGraphOptions): string;
108
+ //#endregion
109
+ //#region src/index.d.ts
110
+ interface GeneratedSchemaPackage {
111
+ readonly schemaModel: string;
112
+ readonly types: string;
113
+ readonly graph: string;
114
+ }
115
+ interface GenerateSchemaPackageOptions extends GenerateSchemaModelOptions, GenerateTypesOptions, GenerateGraphOptions {}
116
+ /** Generate all three source files from an introspection schema. */
117
+ declare function generateSchemaPackage(schema: IntrospectionSchema, options?: GenerateSchemaPackageOptions): GeneratedSchemaPackage;
118
+ //#endregion
119
+ export { DEFAULT_SCALAR_TYPES, GenerateGraphOptions, GenerateSchemaModelOptions, GenerateSchemaPackageOptions, GenerateTypesOptions, GeneratedSchemaPackage, IntrospectionField, IntrospectionInputValue, IntrospectionSchema, IntrospectionType, IntrospectionTypeKind, IntrospectionTypeRef, generateGraph, generateSchemaModel, generateSchemaPackage, generateTypes, isInternalType, isListType, isNonNull, namedTypeName, renderGraphQLType };
package/dist/index.mjs ADDED
@@ -0,0 +1,243 @@
1
+ //#region src/introspection.ts
2
+ /** The named type at the bottom of a (possibly list/non-null) type ref. */
3
+ function namedTypeName(ref) {
4
+ let cur = ref;
5
+ while (cur.kind === "LIST" || cur.kind === "NON_NULL") cur = cur.ofType;
6
+ return cur.name;
7
+ }
8
+ /** True if a LIST appears anywhere in the type ref. */
9
+ function isListType(ref) {
10
+ let cur = ref;
11
+ while (cur.kind === "LIST" || cur.kind === "NON_NULL") {
12
+ if (cur.kind === "LIST") return true;
13
+ cur = cur.ofType;
14
+ }
15
+ return false;
16
+ }
17
+ /** True if the outermost wrapper is NON_NULL. */
18
+ function isNonNull(ref) {
19
+ return ref.kind === "NON_NULL";
20
+ }
21
+ /** Render a GraphQL SDL type ref string, e.g. `[Product!]!`. */
22
+ function renderGraphQLType(ref) {
23
+ switch (ref.kind) {
24
+ case "NON_NULL": return `${renderGraphQLType(ref.ofType)}!`;
25
+ case "LIST": return `[${renderGraphQLType(ref.ofType)}]`;
26
+ default: return ref.name;
27
+ }
28
+ }
29
+ /** Introspection meta types (`__Schema`, `__Type`, …) we never generate. */
30
+ function isInternalType(name) {
31
+ return name.startsWith("__");
32
+ }
33
+ //#endregion
34
+ //#region src/ts-render.ts
35
+ /** Shared TypeScript-type rendering for branded types and the graph accessors. */
36
+ const DEFAULT_SCALAR_TYPES = {
37
+ String: "string",
38
+ ID: "string",
39
+ Int: "number",
40
+ Float: "number",
41
+ Boolean: "boolean"
42
+ };
43
+ /** Render a TS type from a GraphQL type ref, honoring list + nullability nesting. */
44
+ function renderTs(ref, scalars) {
45
+ if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
46
+ return `${renderTsInner(ref, scalars)} | null`;
47
+ }
48
+ function renderTsInner(ref, scalars) {
49
+ if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
50
+ if (ref.kind === "LIST") {
51
+ const el = renderTs(ref.ofType, scalars);
52
+ return `${el.includes(" ") ? `(${el})` : el}[]`;
53
+ }
54
+ if (ref.kind === "SCALAR") return scalars[ref.name] ?? "string";
55
+ return ref.name;
56
+ }
57
+ /** A bare property key when safe, else a quoted key. */
58
+ function propKey(name) {
59
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
60
+ }
61
+ /** Indent every non-empty line of `text` by `spaces` columns. */
62
+ function indent(text, spaces) {
63
+ const pad = " ".repeat(spaces);
64
+ return text.split("\n").map((line) => line ? pad + line : line).join("\n");
65
+ }
66
+ //#endregion
67
+ //#region src/schema-model.ts
68
+ /**
69
+ * Generate the `SchemaModel` source (`defineSchema({...})`) the compiler and
70
+ * runtime consume. This is the machine-generated equivalent of the previously
71
+ * hand-authored `schema-model.ts`.
72
+ */
73
+ const KIND_MAP = {
74
+ SCALAR: "scalar",
75
+ OBJECT: "object",
76
+ INTERFACE: "interface",
77
+ UNION: "union",
78
+ ENUM: "enum",
79
+ INPUT_OBJECT: "input"
80
+ };
81
+ function generateSchemaModel(schema, options = {}) {
82
+ const exportName = options.exportName ?? "schema";
83
+ const types = schema.types.filter((t) => !isInternalType(t.name)).map((t) => renderType(t)).filter((s) => s !== void 0);
84
+ const lines = [];
85
+ lines.push(`import { defineSchema, type SchemaModel } from "@gleanql/core";`);
86
+ lines.push("");
87
+ lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
88
+ lines.push(`export const ${exportName}: SchemaModel = defineSchema({`);
89
+ lines.push(` queryType: ${JSON.stringify(schema.queryType.name)},`);
90
+ if (schema.mutationType) lines.push(` mutationType: ${JSON.stringify(schema.mutationType.name)},`);
91
+ if (schema.subscriptionType) lines.push(` subscriptionType: ${JSON.stringify(schema.subscriptionType.name)},`);
92
+ lines.push(` types: [`);
93
+ for (const t of types) lines.push(indent(t, 4) + ",");
94
+ lines.push(` ],`);
95
+ lines.push(`});`);
96
+ return lines.join("\n") + "\n";
97
+ }
98
+ function renderType(type) {
99
+ const kind = KIND_MAP[type.kind];
100
+ if (!kind) return void 0;
101
+ const parts = [`name: ${JSON.stringify(type.name)}`, `kind: ${JSON.stringify(kind)}`];
102
+ if ((type.kind === "OBJECT" || type.kind === "INTERFACE") && type.fields) {
103
+ const fields = type.fields.map((f) => `${propKey(f.name)}: ${renderField(f)}`);
104
+ parts.push(`fields: { ${fields.join(", ")} }`);
105
+ }
106
+ if (type.kind === "UNION" && type.possibleTypes) {
107
+ const names = type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p)));
108
+ parts.push(`possibleTypes: [${names.join(", ")}]`);
109
+ }
110
+ return `{ ${parts.join(", ")} }`;
111
+ }
112
+ function renderField(field) {
113
+ const parts = [`name: ${JSON.stringify(field.name)}`, `type: ${JSON.stringify(namedTypeName(field.type))}`];
114
+ if (isListType(field.type)) parts.push(`list: true`);
115
+ if (isNonNull(field.type)) parts.push(`nonNull: true`);
116
+ if (field.args.length > 0) {
117
+ const args = field.args.map((a) => `{ name: ${JSON.stringify(a.name)}, type: ${JSON.stringify(renderGraphQLType(a.type))} }`);
118
+ parts.push(`args: [${args.join(", ")}]`);
119
+ }
120
+ return `{ ${parts.join(", ")} }`;
121
+ }
122
+ //#endregion
123
+ //#region src/types.ts
124
+ function generateTypes(schema, options = {}) {
125
+ const ctx = { scalars: {
126
+ ...DEFAULT_SCALAR_TYPES,
127
+ ...options.scalarTypes
128
+ } };
129
+ const blocks = [];
130
+ blocks.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
131
+ for (const type of schema.types) {
132
+ if (isInternalType(type.name) || type.kind === "SCALAR") continue;
133
+ const block = renderTypeBlock(type, ctx);
134
+ if (block) blocks.push(block);
135
+ }
136
+ return blocks.join("\n\n") + "\n";
137
+ }
138
+ function renderTypeBlock(type, ctx) {
139
+ switch (type.kind) {
140
+ case "OBJECT": return renderObjectLike(type, ctx, JSON.stringify(type.name));
141
+ case "INTERFACE": return renderObjectLike(type, ctx, typenameUnionOf(type));
142
+ case "UNION": return renderUnion(type);
143
+ case "ENUM": return renderEnum(type);
144
+ case "INPUT_OBJECT": return renderInputObject(type, ctx);
145
+ default: return;
146
+ }
147
+ }
148
+ /** An interface's `__typename` is the union of its possible types; an object's is its own name. */
149
+ function typenameUnionOf(type) {
150
+ return type.possibleTypes && type.possibleTypes.length > 0 ? type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p))).join(" | ") : "string";
151
+ }
152
+ function renderObjectLike(type, ctx, typenameTs) {
153
+ const members = [` __typename: ${typenameTs};`];
154
+ for (const field of type.fields ?? []) members.push(" " + renderFieldMember(field.name, field.args, field.type, ctx));
155
+ return `export interface ${type.name} {\n${members.join("\n")}\n}`;
156
+ }
157
+ function renderUnion(type) {
158
+ const members = (type.possibleTypes ?? []).map((p) => namedTypeName(p));
159
+ const body = members.length > 0 ? members.join(" | ") : "never";
160
+ return `export type ${type.name} = ${body};`;
161
+ }
162
+ function renderEnum(type) {
163
+ const values = (type.enumValues ?? []).map((v) => JSON.stringify(v.name));
164
+ const body = values.length > 0 ? values.join(" | ") : "never";
165
+ return `export type ${type.name} = ${body};`;
166
+ }
167
+ function renderInputObject(type, ctx) {
168
+ const members = (type.inputFields ?? []).map((f) => " " + renderInputMember(f, ctx));
169
+ return `export interface ${type.name} {\n${members.join("\n")}\n}`;
170
+ }
171
+ /** A field is callable when it declares arguments; otherwise it is a property. */
172
+ function renderFieldMember(name, args, type, ctx) {
173
+ const ret = renderTs(type, ctx.scalars);
174
+ if (args.length > 0) {
175
+ const argList = args.map((a) => renderInputMember(a, ctx)).join(" ");
176
+ return `${propKey(name)}(args: { ${argList} }): ${ret};`;
177
+ }
178
+ return `${propKey(name)}: ${ret};`;
179
+ }
180
+ function renderInputMember(input, ctx) {
181
+ const optional = input.type.kind !== "NON_NULL";
182
+ return `${propKey(input.name)}${optional ? "?" : ""}: ${renderTs(input.type, ctx.scalars)};`;
183
+ }
184
+ //#endregion
185
+ //#region src/graph.ts
186
+ function generateGraph(schema, options = {}) {
187
+ const scalars = {
188
+ ...DEFAULT_SCALAR_TYPES,
189
+ ...options.scalarTypes
190
+ };
191
+ const schemaImportPath = options.schemaImportPath ?? "./schema.js";
192
+ const rootFields = schema.types.find((t) => t.name === schema.queryType.name)?.fields ?? [];
193
+ const emitted = /* @__PURE__ */ new Map();
194
+ for (const t of schema.types) if (!isInternalType(t.name) && t.kind !== "SCALAR") emitted.set(t.name, t.kind);
195
+ const imports = /* @__PURE__ */ new Set();
196
+ const collect = (ref) => {
197
+ const name = namedTypeName(ref);
198
+ if (emitted.has(name)) imports.add(name);
199
+ };
200
+ for (const field of rootFields) {
201
+ collect(field.type);
202
+ for (const arg of field.args) collect(arg.type);
203
+ }
204
+ const lines = [];
205
+ lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
206
+ if (imports.size > 0) {
207
+ const names = [...imports].sort().join(", ");
208
+ lines.push(`import type { ${names} } from ${JSON.stringify(schemaImportPath)};`);
209
+ lines.push("");
210
+ }
211
+ lines.push(`export const glean = {`);
212
+ for (const field of rootFields) lines.push(indent(renderRoot(field, scalars), 2));
213
+ lines.push(` components<T extends Record<string, unknown>>(map: T): T {`);
214
+ lines.push(` return map;`);
215
+ lines.push(` },`);
216
+ lines.push(`};`);
217
+ return lines.join("\n") + "\n";
218
+ }
219
+ function renderRoot(field, scalars) {
220
+ const ret = renderTs(field.type, scalars);
221
+ const params = field.args.length > 0 ? `args: { ${field.args.map((a) => renderArg(a.name, a.type, scalars)).join(" ")} }` : "";
222
+ return [
223
+ `${propKey(field.name)}(${params}): ${ret} {`,
224
+ ` return undefined as unknown as ${ret};`,
225
+ `},`
226
+ ].join("\n");
227
+ }
228
+ function renderArg(name, type, scalars) {
229
+ const optional = type.kind !== "NON_NULL";
230
+ return `${propKey(name)}${optional ? "?" : ""}: ${renderTs(type, scalars)};`;
231
+ }
232
+ //#endregion
233
+ //#region src/index.ts
234
+ /** Generate all three source files from an introspection schema. */
235
+ function generateSchemaPackage(schema, options = {}) {
236
+ return {
237
+ schemaModel: generateSchemaModel(schema, options),
238
+ types: generateTypes(schema, options),
239
+ graph: generateGraph(schema, options)
240
+ };
241
+ }
242
+ //#endregion
243
+ export { DEFAULT_SCALAR_TYPES, generateGraph, generateSchemaModel, generateSchemaPackage, generateTypes, isInternalType, isListType, isNonNull, namedTypeName, renderGraphQLType };
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@gleanql/codegen",
3
+ "version": "0.1.0",
4
+ "description": "Glean's schema codegen: introspection to schema model, branded types and graph accessors",
5
+ "license": "MIT",
6
+ "author": "Alexander Liljengard",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/gleanql/gleanql.git",
10
+ "directory": "packages/codegen"
11
+ },
12
+ "type": "module",
13
+ "main": "./dist/index.mjs",
14
+ "types": "./dist/index.d.mts",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.mts",
18
+ "default": "./dist/index.mjs"
19
+ }
20
+ },
21
+ "files": [
22
+ "dist",
23
+ "src"
24
+ ],
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "dependencies": {
29
+ "@gleanql/core": "0.1.0"
30
+ },
31
+ "homepage": "https://gleanql.com",
32
+ "bugs": "https://github.com/gleanql/gleanql/issues",
33
+ "keywords": [
34
+ "graphql",
35
+ "codegen",
36
+ "typescript",
37
+ "introspection"
38
+ ],
39
+ "engines": {
40
+ "node": ">=20"
41
+ },
42
+ "sideEffects": false,
43
+ "scripts": {
44
+ "build": "tsdown src/index.ts --format esm --dts.eager"
45
+ }
46
+ }
package/src/graph.ts ADDED
@@ -0,0 +1,78 @@
1
+ import {
2
+ isInternalType,
3
+ namedTypeName,
4
+ type IntrospectionField,
5
+ type IntrospectionSchema,
6
+ type IntrospectionTypeRef,
7
+ } from "./introspection.js";
8
+ import { DEFAULT_SCALAR_TYPES, indent, propKey, renderTs } from "./ts-render.js";
9
+
10
+ /**
11
+ * Generate the `graph` accessor object: one method per Query root field plus the
12
+ * `components(...)` registry helper. At build time the compiler reads these to
13
+ * learn the root fields and their types; at runtime they are backed by the
14
+ * runtime's bound graph (see `@gleanql/react`'s `bindGraph`). The generated bodies
15
+ * are typed stubs — the real values are proxies.
16
+ */
17
+
18
+ export interface GenerateGraphOptions {
19
+ readonly scalarTypes?: Record<string, string>;
20
+ /** Import path for the generated branded types. Default: `./schema.js`. */
21
+ readonly schemaImportPath?: string;
22
+ }
23
+
24
+ export function generateGraph(schema: IntrospectionSchema, options: GenerateGraphOptions = {}): string {
25
+ const scalars = { ...DEFAULT_SCALAR_TYPES, ...options.scalarTypes };
26
+ const schemaImportPath = options.schemaImportPath ?? "./schema.js";
27
+
28
+ const queryType = schema.types.find((t) => t.name === schema.queryType.name);
29
+ const rootFields = queryType?.fields ?? [];
30
+
31
+ // Which named types are emitted in schema.ts (so we can import them).
32
+ const emitted = new Map<string, string>();
33
+ for (const t of schema.types) {
34
+ if (!isInternalType(t.name) && t.kind !== "SCALAR") emitted.set(t.name, t.kind);
35
+ }
36
+
37
+ const imports = new Set<string>();
38
+ const collect = (ref: IntrospectionTypeRef): void => {
39
+ const name = namedTypeName(ref);
40
+ if (emitted.has(name)) imports.add(name);
41
+ };
42
+ for (const field of rootFields) {
43
+ collect(field.type);
44
+ for (const arg of field.args) collect(arg.type);
45
+ }
46
+
47
+ const lines: string[] = [];
48
+ lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
49
+ if (imports.size > 0) {
50
+ const names = [...imports].sort().join(", ");
51
+ lines.push(`import type { ${names} } from ${JSON.stringify(schemaImportPath)};`);
52
+ lines.push("");
53
+ }
54
+ lines.push(`export const glean = {`);
55
+ for (const field of rootFields) {
56
+ lines.push(indent(renderRoot(field, scalars), 2));
57
+ }
58
+ lines.push(` components<T extends Record<string, unknown>>(map: T): T {`);
59
+ lines.push(` return map;`);
60
+ lines.push(` },`);
61
+ lines.push(`};`);
62
+ return lines.join("\n") + "\n";
63
+ }
64
+
65
+ function renderRoot(field: IntrospectionField, scalars: Record<string, string>): string {
66
+ const ret = renderTs(field.type, scalars);
67
+ const params = field.args.length > 0 ? `args: { ${field.args.map((a) => renderArg(a.name, a.type, scalars)).join(" ")} }` : "";
68
+ return [
69
+ `${propKey(field.name)}(${params}): ${ret} {`,
70
+ ` return undefined as unknown as ${ret};`,
71
+ `},`,
72
+ ].join("\n");
73
+ }
74
+
75
+ function renderArg(name: string, type: IntrospectionTypeRef, scalars: Record<string, string>): string {
76
+ const optional = type.kind !== "NON_NULL";
77
+ return `${propKey(name)}${optional ? "?" : ""}: ${renderTs(type, scalars)};`;
78
+ }
package/src/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ import type { IntrospectionSchema } from "./introspection.js";
2
+ import { generateSchemaModel, type GenerateSchemaModelOptions } from "./schema-model.js";
3
+ import { generateTypes, type GenerateTypesOptions } from "./types.js";
4
+ import { generateGraph, type GenerateGraphOptions } from "./graph.js";
5
+
6
+ /**
7
+ * `@gleanql/codegen` — generate a schema package from GraphQL introspection.
8
+ *
9
+ * The brief: "Generate a schema package from GraphQL introspection when the API
10
+ * changes." This produces the three files the rest of the system consumes —
11
+ * machine-generated equivalents of what was previously hand-authored:
12
+ *
13
+ * schema-model.ts the SchemaModel the compiler + runtime read
14
+ * schema.ts branded TS types (so TypeScript catches API drift)
15
+ * graph.ts the `graph.product(...)` accessors + `components(...)`
16
+ *
17
+ * Consume a real introspection result (`introspectionFromSchema(schema).__schema`
18
+ * or the `data.__schema` from an introspection query).
19
+ */
20
+ export * from "./introspection.js";
21
+ export * from "./schema-model.js";
22
+ export * from "./types.js";
23
+ export * from "./graph.js";
24
+
25
+ export interface GeneratedSchemaPackage {
26
+ readonly schemaModel: string;
27
+ readonly types: string;
28
+ readonly graph: string;
29
+ }
30
+
31
+ export interface GenerateSchemaPackageOptions
32
+ extends GenerateSchemaModelOptions,
33
+ GenerateTypesOptions,
34
+ GenerateGraphOptions {}
35
+
36
+ /** Generate all three source files from an introspection schema. */
37
+ export function generateSchemaPackage(
38
+ schema: IntrospectionSchema,
39
+ options: GenerateSchemaPackageOptions = {},
40
+ ): GeneratedSchemaPackage {
41
+ return {
42
+ schemaModel: generateSchemaModel(schema, options),
43
+ types: generateTypes(schema, options),
44
+ graph: generateGraph(schema, options),
45
+ };
46
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * GraphQL introspection model + type-ref helpers.
3
+ *
4
+ * We consume the standard introspection result (`data.__schema`) directly rather
5
+ * than depend on graphql-js at runtime — the shape is stable and small. These
6
+ * structural types match what `introspectionFromSchema()` produces, so a real
7
+ * introspection drops straight in.
8
+ */
9
+
10
+ export type IntrospectionTypeKind = "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT" | "LIST" | "NON_NULL";
11
+
12
+ export type IntrospectionTypeRef =
13
+ | { readonly kind: "NON_NULL"; readonly ofType: IntrospectionTypeRef }
14
+ | { readonly kind: "LIST"; readonly ofType: IntrospectionTypeRef }
15
+ | { readonly kind: "SCALAR" | "OBJECT" | "INTERFACE" | "UNION" | "ENUM" | "INPUT_OBJECT"; readonly name: string; readonly ofType?: null };
16
+
17
+ export interface IntrospectionInputValue {
18
+ readonly name: string;
19
+ readonly type: IntrospectionTypeRef;
20
+ readonly defaultValue?: string | null;
21
+ }
22
+
23
+ export interface IntrospectionField {
24
+ readonly name: string;
25
+ readonly args: readonly IntrospectionInputValue[];
26
+ readonly type: IntrospectionTypeRef;
27
+ }
28
+
29
+ export interface IntrospectionType {
30
+ readonly kind: IntrospectionTypeKind;
31
+ readonly name: string;
32
+ readonly description?: string | null;
33
+ readonly fields?: readonly IntrospectionField[] | null;
34
+ readonly inputFields?: readonly IntrospectionInputValue[] | null;
35
+ readonly interfaces?: readonly IntrospectionTypeRef[] | null;
36
+ readonly enumValues?: ReadonlyArray<{ readonly name: string }> | null;
37
+ readonly possibleTypes?: readonly IntrospectionTypeRef[] | null;
38
+ }
39
+
40
+ export interface IntrospectionSchema {
41
+ readonly queryType: { readonly name: string };
42
+ readonly mutationType?: { readonly name: string } | null;
43
+ readonly subscriptionType?: { readonly name: string } | null;
44
+ readonly types: readonly IntrospectionType[];
45
+ }
46
+
47
+ /** The named type at the bottom of a (possibly list/non-null) type ref. */
48
+ export function namedTypeName(ref: IntrospectionTypeRef): string {
49
+ let cur: IntrospectionTypeRef = ref;
50
+ while (cur.kind === "LIST" || cur.kind === "NON_NULL") cur = cur.ofType;
51
+ return cur.name;
52
+ }
53
+
54
+ /** True if a LIST appears anywhere in the type ref. */
55
+ export function isListType(ref: IntrospectionTypeRef): boolean {
56
+ let cur: IntrospectionTypeRef = ref;
57
+ while (cur.kind === "LIST" || cur.kind === "NON_NULL") {
58
+ if (cur.kind === "LIST") return true;
59
+ cur = cur.ofType;
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** True if the outermost wrapper is NON_NULL. */
65
+ export function isNonNull(ref: IntrospectionTypeRef): boolean {
66
+ return ref.kind === "NON_NULL";
67
+ }
68
+
69
+ /** Render a GraphQL SDL type ref string, e.g. `[Product!]!`. */
70
+ export function renderGraphQLType(ref: IntrospectionTypeRef): string {
71
+ switch (ref.kind) {
72
+ case "NON_NULL":
73
+ return `${renderGraphQLType(ref.ofType)}!`;
74
+ case "LIST":
75
+ return `[${renderGraphQLType(ref.ofType)}]`;
76
+ default:
77
+ return ref.name;
78
+ }
79
+ }
80
+
81
+ /** Introspection meta types (`__Schema`, `__Type`, …) we never generate. */
82
+ export function isInternalType(name: string): boolean {
83
+ return name.startsWith("__");
84
+ }
@@ -0,0 +1,81 @@
1
+ import {
2
+ isInternalType,
3
+ isListType,
4
+ isNonNull,
5
+ namedTypeName,
6
+ renderGraphQLType,
7
+ type IntrospectionField,
8
+ type IntrospectionSchema,
9
+ type IntrospectionType,
10
+ } from "./introspection.js";
11
+ import { indent, propKey } from "./ts-render.js";
12
+
13
+ /**
14
+ * Generate the `SchemaModel` source (`defineSchema({...})`) the compiler and
15
+ * runtime consume. This is the machine-generated equivalent of the previously
16
+ * hand-authored `schema-model.ts`.
17
+ */
18
+
19
+ const KIND_MAP: Record<string, string> = {
20
+ SCALAR: "scalar",
21
+ OBJECT: "object",
22
+ INTERFACE: "interface",
23
+ UNION: "union",
24
+ ENUM: "enum",
25
+ INPUT_OBJECT: "input",
26
+ };
27
+
28
+ export interface GenerateSchemaModelOptions {
29
+ /** Exported const name for the SchemaModel. Default: `schema`. */
30
+ readonly exportName?: string;
31
+ }
32
+
33
+ export function generateSchemaModel(schema: IntrospectionSchema, options: GenerateSchemaModelOptions = {}): string {
34
+ const exportName = options.exportName ?? "schema";
35
+ const types = schema.types
36
+ .filter((t) => !isInternalType(t.name))
37
+ .map((t) => renderType(t))
38
+ .filter((s): s is string => s !== undefined);
39
+
40
+ const lines: string[] = [];
41
+ lines.push(`import { defineSchema, type SchemaModel } from "@gleanql/core";`);
42
+ lines.push("");
43
+ lines.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
44
+ lines.push(`export const ${exportName}: SchemaModel = defineSchema({`);
45
+ lines.push(` queryType: ${JSON.stringify(schema.queryType.name)},`);
46
+ if (schema.mutationType) lines.push(` mutationType: ${JSON.stringify(schema.mutationType.name)},`);
47
+ if (schema.subscriptionType) lines.push(` subscriptionType: ${JSON.stringify(schema.subscriptionType.name)},`);
48
+ lines.push(` types: [`);
49
+ for (const t of types) lines.push(indent(t, 4) + ",");
50
+ lines.push(` ],`);
51
+ lines.push(`});`);
52
+ return lines.join("\n") + "\n";
53
+ }
54
+
55
+ function renderType(type: IntrospectionType): string | undefined {
56
+ const kind = KIND_MAP[type.kind];
57
+ if (!kind) return undefined; // LIST/NON_NULL never appear at top level
58
+
59
+ const parts: string[] = [`name: ${JSON.stringify(type.name)}`, `kind: ${JSON.stringify(kind)}`];
60
+
61
+ if ((type.kind === "OBJECT" || type.kind === "INTERFACE") && type.fields) {
62
+ const fields = type.fields.map((f) => `${propKey(f.name)}: ${renderField(f)}`);
63
+ parts.push(`fields: { ${fields.join(", ")} }`);
64
+ }
65
+ if (type.kind === "UNION" && type.possibleTypes) {
66
+ const names = type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p)));
67
+ parts.push(`possibleTypes: [${names.join(", ")}]`);
68
+ }
69
+ return `{ ${parts.join(", ")} }`;
70
+ }
71
+
72
+ function renderField(field: IntrospectionField): string {
73
+ const parts: string[] = [`name: ${JSON.stringify(field.name)}`, `type: ${JSON.stringify(namedTypeName(field.type))}`];
74
+ if (isListType(field.type)) parts.push(`list: true`);
75
+ if (isNonNull(field.type)) parts.push(`nonNull: true`);
76
+ if (field.args.length > 0) {
77
+ const args = field.args.map((a) => `{ name: ${JSON.stringify(a.name)}, type: ${JSON.stringify(renderGraphQLType(a.type))} }`);
78
+ parts.push(`args: [${args.join(", ")}]`);
79
+ }
80
+ return `{ ${parts.join(", ")} }`;
81
+ }
@@ -0,0 +1,41 @@
1
+ import type { IntrospectionTypeRef } from "./introspection.js";
2
+
3
+ /** Shared TypeScript-type rendering for branded types and the graph accessors. */
4
+
5
+ export const DEFAULT_SCALAR_TYPES: Record<string, string> = {
6
+ String: "string",
7
+ ID: "string",
8
+ Int: "number",
9
+ Float: "number",
10
+ Boolean: "boolean",
11
+ };
12
+
13
+ /** Render a TS type from a GraphQL type ref, honoring list + nullability nesting. */
14
+ export function renderTs(ref: IntrospectionTypeRef, scalars: Record<string, string>): string {
15
+ if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
16
+ return `${renderTsInner(ref, scalars)} | null`;
17
+ }
18
+
19
+ function renderTsInner(ref: IntrospectionTypeRef, scalars: Record<string, string>): string {
20
+ if (ref.kind === "NON_NULL") return renderTsInner(ref.ofType, scalars);
21
+ if (ref.kind === "LIST") {
22
+ const el = renderTs(ref.ofType, scalars);
23
+ return `${el.includes(" ") ? `(${el})` : el}[]`;
24
+ }
25
+ if (ref.kind === "SCALAR") return scalars[ref.name] ?? "string";
26
+ return ref.name; // OBJECT / INTERFACE / UNION / ENUM / INPUT_OBJECT
27
+ }
28
+
29
+ /** A bare property key when safe, else a quoted key. */
30
+ export function propKey(name: string): string {
31
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : JSON.stringify(name);
32
+ }
33
+
34
+ /** Indent every non-empty line of `text` by `spaces` columns. */
35
+ export function indent(text: string, spaces: number): string {
36
+ const pad = " ".repeat(spaces);
37
+ return text
38
+ .split("\n")
39
+ .map((line) => (line ? pad + line : line))
40
+ .join("\n");
41
+ }
package/src/types.ts ADDED
@@ -0,0 +1,114 @@
1
+ import {
2
+ isInternalType,
3
+ namedTypeName,
4
+ type IntrospectionInputValue,
5
+ type IntrospectionSchema,
6
+ type IntrospectionType,
7
+ type IntrospectionTypeRef,
8
+ } from "./introspection.js";
9
+ import { DEFAULT_SCALAR_TYPES, propKey, renderTs } from "./ts-render.js";
10
+
11
+ export { DEFAULT_SCALAR_TYPES } from "./ts-render.js";
12
+
13
+ /**
14
+ * Generate branded TypeScript types from introspection.
15
+ *
16
+ * To app code these read as ordinary schema types (`Product`, `Image`, …); the
17
+ * compiler recognizes them via the `__typename` brand. Nullability, lists,
18
+ * callable fields (field arguments), enums, interfaces, unions and input objects
19
+ * all surface — so TypeScript catches API drift (a removed/renamed field, a
20
+ * tightened nullability) at compile time.
21
+ */
22
+
23
+ export interface GenerateTypesOptions {
24
+ /** TS type for each GraphQL scalar. Unlisted custom scalars default to `string`. */
25
+ readonly scalarTypes?: Record<string, string>;
26
+ }
27
+
28
+ export function generateTypes(schema: IntrospectionSchema, options: GenerateTypesOptions = {}): string {
29
+ const scalars = { ...DEFAULT_SCALAR_TYPES, ...options.scalarTypes };
30
+ const ctx = { scalars };
31
+
32
+ const blocks: string[] = [];
33
+ blocks.push(`/** Generated from GraphQL introspection. Do not edit by hand. */`);
34
+
35
+ for (const type of schema.types) {
36
+ if (isInternalType(type.name) || type.kind === "SCALAR") continue;
37
+ const block = renderTypeBlock(type, ctx);
38
+ if (block) blocks.push(block);
39
+ }
40
+ return blocks.join("\n\n") + "\n";
41
+ }
42
+
43
+ interface Ctx {
44
+ readonly scalars: Record<string, string>;
45
+ }
46
+
47
+ function renderTypeBlock(type: IntrospectionType, ctx: Ctx): string | undefined {
48
+ switch (type.kind) {
49
+ case "OBJECT":
50
+ return renderObjectLike(type, ctx, JSON.stringify(type.name));
51
+ case "INTERFACE":
52
+ return renderObjectLike(type, ctx, typenameUnionOf(type));
53
+ case "UNION":
54
+ return renderUnion(type);
55
+ case "ENUM":
56
+ return renderEnum(type);
57
+ case "INPUT_OBJECT":
58
+ return renderInputObject(type, ctx);
59
+ default:
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /** An interface's `__typename` is the union of its possible types; an object's is its own name. */
65
+ function typenameUnionOf(type: IntrospectionType): string {
66
+ return type.possibleTypes && type.possibleTypes.length > 0
67
+ ? type.possibleTypes.map((p) => JSON.stringify(namedTypeName(p))).join(" | ")
68
+ : "string";
69
+ }
70
+
71
+ function renderObjectLike(type: IntrospectionType, ctx: Ctx, typenameTs: string): string {
72
+ const members: string[] = [` __typename: ${typenameTs};`];
73
+ for (const field of type.fields ?? []) {
74
+ members.push(" " + renderFieldMember(field.name, field.args, field.type, ctx));
75
+ }
76
+ return `export interface ${type.name} {\n${members.join("\n")}\n}`;
77
+ }
78
+
79
+ function renderUnion(type: IntrospectionType): string {
80
+ const members = (type.possibleTypes ?? []).map((p) => namedTypeName(p));
81
+ const body = members.length > 0 ? members.join(" | ") : "never";
82
+ return `export type ${type.name} = ${body};`;
83
+ }
84
+
85
+ function renderEnum(type: IntrospectionType): string {
86
+ const values = (type.enumValues ?? []).map((v) => JSON.stringify(v.name));
87
+ const body = values.length > 0 ? values.join(" | ") : "never";
88
+ return `export type ${type.name} = ${body};`;
89
+ }
90
+
91
+ function renderInputObject(type: IntrospectionType, ctx: Ctx): string {
92
+ const members = (type.inputFields ?? []).map((f) => " " + renderInputMember(f, ctx));
93
+ return `export interface ${type.name} {\n${members.join("\n")}\n}`;
94
+ }
95
+
96
+ /** A field is callable when it declares arguments; otherwise it is a property. */
97
+ function renderFieldMember(
98
+ name: string,
99
+ args: readonly IntrospectionInputValue[],
100
+ type: IntrospectionTypeRef,
101
+ ctx: Ctx,
102
+ ): string {
103
+ const ret = renderTs(type, ctx.scalars);
104
+ if (args.length > 0) {
105
+ const argList = args.map((a) => renderInputMember(a, ctx)).join(" ");
106
+ return `${propKey(name)}(args: { ${argList} }): ${ret};`;
107
+ }
108
+ return `${propKey(name)}: ${ret};`;
109
+ }
110
+
111
+ function renderInputMember(input: IntrospectionInputValue, ctx: Ctx): string {
112
+ const optional = input.type.kind !== "NON_NULL";
113
+ return `${propKey(input.name)}${optional ? "?" : ""}: ${renderTs(input.type, ctx.scalars)};`;
114
+ }