Skip to content

Commit b8c6f17

Browse files
authored
feat(bind): GraphQL-enriched type definitions (#110)
1 parent 144fc93 commit b8c6f17

11 files changed

Lines changed: 1489 additions & 10 deletions

File tree

cli/src/codegen/generator.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ interface ModelSchema {
1212
collection: string;
1313
securityDomains?: string[];
1414
fields: FieldSchema[];
15+
graph?: {
16+
dataSourceKey: string;
17+
createInputType: string;
18+
updateInputType: string;
19+
fields?: Record<string, { nullable: boolean }>;
20+
createInputFields?: Record<string, { required: boolean }>;
21+
updateInputFields?: Record<string, { required: boolean }>;
22+
};
1523
}
1624

1725
const HEADER = '// AUTO-GENERATED by `everywhere bind` -- do not edit manually.\n';
@@ -64,11 +72,41 @@ export function generateModels(schemas: ModelSchema[]): string {
6472
lines.push(' id: string;');
6573
for (const field of schema.fields) {
6674
const tsType = fieldTypeToTS(field, knownModelNames);
75+
const nullable = schema.graph?.fields?.[field.name]?.nullable === true;
76+
const fullType = nullable ? `${tsType} | null` : tsType;
6777
const readonly = field.isDerived ? 'readonly ' : '';
68-
lines.push(` ${readonly}${field.name}: ${tsType};`);
78+
lines.push(` ${readonly}${field.name}: ${fullType};`);
6979
}
7080
lines.push('}');
7181
lines.push('');
82+
83+
const writableFields = schema.fields.filter((f) => !f.isDerived);
84+
85+
if (schema.graph?.createInputFields) {
86+
const createInputFields = schema.graph.createInputFields;
87+
lines.push(`export interface Create${schema.name}Input {`);
88+
for (const field of writableFields) {
89+
const inputField = createInputFields[field.name];
90+
if (!inputField) continue;
91+
const tsType = fieldTypeToTS(field, knownModelNames);
92+
const opt = inputField.required ? '' : '?';
93+
lines.push(` ${field.name}${opt}: ${tsType};`);
94+
}
95+
lines.push('}');
96+
lines.push('');
97+
}
98+
99+
if (schema.graph?.updateInputFields) {
100+
const updateInputFields = schema.graph.updateInputFields;
101+
lines.push(`export interface Update${schema.name}Input {`);
102+
for (const field of writableFields) {
103+
if (!updateInputFields[field.name]) continue;
104+
const tsType = fieldTypeToTS(field, knownModelNames);
105+
lines.push(` ${field.name}?: ${tsType};`);
106+
}
107+
lines.push('}');
108+
lines.push('');
109+
}
72110
}
73111

74112
return lines.join('\n');
@@ -96,6 +134,13 @@ export function generateSchema(schemas: ModelSchema[]): string {
96134
lines.push(` { ${parts.join(', ')} },`);
97135
}
98136
lines.push(' ],');
137+
if (schema.graph) {
138+
lines.push(` graph: {`);
139+
lines.push(` dataSourceKey: '${schema.graph.dataSourceKey}',`);
140+
lines.push(` createInputType: '${schema.graph.createInputType}',`);
141+
lines.push(` updateInputType: '${schema.graph.updateInputType}',`);
142+
lines.push(` },`);
143+
}
99144
lines.push(' },');
100145
}
101146

cli/src/codegen/introspect.ts

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
import * as fs from 'node:fs';
2+
import * as fsp from 'node:fs/promises';
3+
import * as path from 'node:path';
4+
import JSZip from 'jszip';
5+
import { appConfig } from '../config.js';
6+
import { DEFAULT_GATEWAY, DEFAULT_HTTPS } from '../auth/defaults.js';
7+
8+
interface GraphMetadata {
9+
dataSourceKey: string;
10+
createInputType: string;
11+
updateInputType: string;
12+
fields?: Record<string, { nullable: boolean }>;
13+
createInputFields?: Record<string, { required: boolean }>;
14+
updateInputFields?: Record<string, { required: boolean }>;
15+
}
16+
17+
interface ModelSchema {
18+
name: string;
19+
collection: string;
20+
graph?: GraphMetadata;
21+
}
22+
23+
function referenceIdToGraphTypePrefix(referenceId: string): string {
24+
const parts = referenceId.split('_');
25+
const head = parts[0] ?? '';
26+
const capitalized = head.length === 0 ? '' : head.charAt(0).toUpperCase() + head.slice(1);
27+
const tail = parts.slice(1).join('_');
28+
return tail ? `${capitalized}_${tail}` : capitalized;
29+
}
30+
31+
export interface IntrospectionResult {
32+
/** Successfully enriched models, keyed by model name */
33+
graph: Record<string, GraphMetadata>;
34+
/** Models where introspection returned null for at least one field */
35+
missing: string[];
36+
}
37+
38+
export type IntrospectionSkipReason =
39+
| { kind: 'no-token' }
40+
| { kind: 'no-manifest'; path: string }
41+
| { kind: 'network-error'; message: string }
42+
| { kind: 'api-error'; message: string };
43+
44+
export type IntrospectionOutcome =
45+
| { ok: true; result: IntrospectionResult }
46+
| { ok: false; reason: IntrospectionSkipReason };
47+
48+
const ACCURACY_IMPACT = 'Generated code may not accurately reflect the live GraphQL schema.';
49+
50+
export function applyIntrospectionOutcome<T extends ModelSchema>(
51+
schemas: T[],
52+
outcome: IntrospectionOutcome
53+
): { schemas: T[]; warnings: string[] } {
54+
if (!outcome.ok) {
55+
const { reason } = outcome;
56+
if (reason.kind === 'no-token' || reason.kind === 'no-manifest') {
57+
return { schemas, warnings: [] };
58+
}
59+
return {
60+
schemas,
61+
warnings: [`GraphQL introspection failed — ${reason.message}.\n${ACCURACY_IMPACT}`],
62+
};
63+
}
64+
65+
const { graph, missing } = outcome.result;
66+
const warnings: string[] = [];
67+
68+
if (missing.length > 0) {
69+
warnings.push(
70+
`GraphQL introspection returned no data for: ${missing.join(', ')}.\n${ACCURACY_IMPACT}`
71+
);
72+
}
73+
74+
const enriched = schemas.map((s) => {
75+
const graphMeta = graph[s.name];
76+
return graphMeta ? ({ ...s, graph: graphMeta } as T) : s;
77+
});
78+
79+
return { schemas: enriched, warnings };
80+
}
81+
82+
function capitalize(s: string): string {
83+
return s.length === 0 ? '' : s.charAt(0).toUpperCase() + s.slice(1);
84+
}
85+
86+
async function readManifestContent(sourcePath: string, isZip: boolean): Promise<string | null> {
87+
if (isZip) {
88+
const buffer = await fsp.readFile(sourcePath);
89+
const zip = await JSZip.loadAsync(buffer);
90+
const entry = zip.files['appManifest.json'];
91+
if (!entry) return null;
92+
return entry.async('string');
93+
}
94+
const manifestPath = path.join(sourcePath, 'appManifest.json');
95+
if (!fs.existsSync(manifestPath)) return null;
96+
return fs.readFileSync(manifestPath, 'utf-8');
97+
}
98+
99+
function buildQuery(graphPrefix: string, schemas: ModelSchema[]): string {
100+
const selections = schemas
101+
.map(({ name, collection }) => {
102+
const dsType = `${graphPrefix}_${name}_DataSources`;
103+
const createType = `${graphPrefix}_${capitalize(collection)}Summary_Create_Input`;
104+
const updateType = `${graphPrefix}_${capitalize(collection)}Summary_Update_Input`;
105+
const summaryType = `${graphPrefix}_${name}Summary`;
106+
return [
107+
` ds_${name}: __type(name: ${JSON.stringify(dsType)}) { inputFields { name } }`,
108+
` create_${name}: __type(name: ${JSON.stringify(createType)}) { name kind inputFields { name type { kind name ofType { kind name } } } }`,
109+
` update_${name}: __type(name: ${JSON.stringify(updateType)}) { name kind inputFields { name type { kind name ofType { kind name } } } }`,
110+
` summary_${name}: __type(name: ${JSON.stringify(summaryType)}) { fields { name type { kind name ofType { kind name } } } }`,
111+
].join('\n');
112+
})
113+
.join('\n');
114+
return `query IntrospectBindTypes {\n${selections}\n}`;
115+
}
116+
117+
export async function introspectGraphTypes<T extends ModelSchema>(
118+
schemas: T[],
119+
extendSourcePath: string,
120+
isZip: boolean
121+
): Promise<IntrospectionOutcome> {
122+
const { auth = {} } = appConfig().read();
123+
if (!auth.token) {
124+
return { ok: false, reason: { kind: 'no-token' } };
125+
}
126+
const { gateway = DEFAULT_GATEWAY, https: useHttps = DEFAULT_HTTPS, token } = auth;
127+
128+
let manifestContent: string | null;
129+
try {
130+
manifestContent = await readManifestContent(extendSourcePath, isZip);
131+
} catch {
132+
const manifestPath = isZip
133+
? `${extendSourcePath}:appManifest.json`
134+
: path.join(extendSourcePath, 'appManifest.json');
135+
return { ok: false, reason: { kind: 'no-manifest', path: manifestPath } };
136+
}
137+
138+
if (!manifestContent) {
139+
const manifestPath = isZip
140+
? `${extendSourcePath}:appManifest.json`
141+
: path.join(extendSourcePath, 'appManifest.json');
142+
return { ok: false, reason: { kind: 'no-manifest', path: manifestPath } };
143+
}
144+
145+
let manifest: { referenceId?: string };
146+
try {
147+
manifest = JSON.parse(manifestContent) as { referenceId?: string };
148+
} catch {
149+
return {
150+
ok: false,
151+
reason: {
152+
kind: 'no-manifest',
153+
path: isZip ? extendSourcePath : path.join(extendSourcePath, 'appManifest.json'),
154+
},
155+
};
156+
}
157+
158+
if (typeof manifest.referenceId !== 'string' || !manifest.referenceId) {
159+
return {
160+
ok: false,
161+
reason: {
162+
kind: 'no-manifest',
163+
path: isZip ? extendSourcePath : path.join(extendSourcePath, 'appManifest.json'),
164+
},
165+
};
166+
}
167+
168+
const { referenceId } = manifest;
169+
const graphPrefix = referenceIdToGraphTypePrefix(referenceId);
170+
const endpoint = `${useHttps ? 'https' : 'http'}://${gateway}/api/v1/data/graphql`;
171+
const query = buildQuery(graphPrefix, schemas);
172+
173+
let response: Response;
174+
try {
175+
response = await fetch(endpoint, {
176+
method: 'POST',
177+
headers: {
178+
accept: 'application/json',
179+
'content-type': 'application/json',
180+
Authorization: `Bearer ${token}`,
181+
},
182+
body: JSON.stringify({ query }),
183+
});
184+
} catch (e) {
185+
return {
186+
ok: false,
187+
reason: {
188+
kind: 'network-error',
189+
message: e instanceof Error ? e.message : String(e),
190+
},
191+
};
192+
}
193+
194+
if (!response.ok) {
195+
return {
196+
ok: false,
197+
reason: { kind: 'api-error', message: `HTTP ${response.status}: ${response.statusText}` },
198+
};
199+
}
200+
201+
const body = (await response.json()) as {
202+
data?: Record<string, unknown>;
203+
errors?: { message: string }[];
204+
};
205+
206+
if (body.errors?.length) {
207+
return {
208+
ok: false,
209+
reason: { kind: 'api-error', message: body.errors.map((e) => e.message).join('; ') },
210+
};
211+
}
212+
213+
const data = body.data ?? {};
214+
const graph: Record<string, GraphMetadata> = {};
215+
const missing: string[] = [];
216+
217+
type GqlTypeRef = {
218+
kind: string;
219+
name: string | null;
220+
ofType: { kind: string; name: string | null } | null;
221+
};
222+
type GqlInputField = { name: string; type: GqlTypeRef };
223+
type GqlSummaryField = { name: string; type: GqlTypeRef };
224+
225+
for (const schema of schemas) {
226+
const { name, collection } = schema;
227+
228+
const dsResult = data[`ds_${name}`] as { inputFields: { name: string }[] } | null;
229+
const createResult = data[`create_${name}`] as {
230+
name: string;
231+
kind: string;
232+
inputFields?: GqlInputField[];
233+
} | null;
234+
const updateResult = data[`update_${name}`] as {
235+
name: string;
236+
kind: string;
237+
inputFields?: GqlInputField[];
238+
} | null;
239+
const summaryResult = data[`summary_${name}`] as { fields?: GqlSummaryField[] } | null;
240+
241+
const inputFields = dsResult?.inputFields ?? [];
242+
const preferred = inputFields.find((f) => f.name === `${referenceId}_${collection}`);
243+
const sorted = [...inputFields].sort((a, b) => a.name.localeCompare(b.name));
244+
const dataSourceKey = preferred?.name ?? sorted[0]?.name;
245+
246+
const createInputType = createResult?.kind === 'INPUT_OBJECT' ? createResult.name : null;
247+
const updateInputType = updateResult?.kind === 'INPUT_OBJECT' ? updateResult.name : null;
248+
249+
if (!dataSourceKey || !createInputType || !updateInputType) {
250+
missing.push(name);
251+
} else {
252+
const meta: GraphMetadata = { dataSourceKey, createInputType, updateInputType };
253+
254+
if (summaryResult?.fields) {
255+
meta.fields = Object.fromEntries(
256+
summaryResult.fields.map((f) => [f.name, { nullable: f.type.kind !== 'NON_NULL' }])
257+
);
258+
}
259+
260+
if (createResult?.inputFields) {
261+
meta.createInputFields = Object.fromEntries(
262+
createResult.inputFields.map((f) => [f.name, { required: f.type.kind === 'NON_NULL' }])
263+
);
264+
}
265+
266+
if (updateResult?.inputFields) {
267+
meta.updateInputFields = Object.fromEntries(
268+
updateResult.inputFields.map((f) => [f.name, { required: f.type.kind === 'NON_NULL' }])
269+
);
270+
}
271+
272+
graph[name] = meta;
273+
}
274+
}
275+
276+
return { ok: true, result: { graph, missing } };
277+
}

cli/src/commands/everywhere/bind.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
generateModelHooks,
1616
generateIndex,
1717
} from '../../codegen/generator.js';
18+
import { introspectGraphTypes, applyIntrospectionOutcome } from '../../codegen/introspect.js';
1819
import { pluginConfig } from '../../config.js';
1920
import { formatSchemas } from '../../format-schemas.js';
2021

@@ -55,9 +56,19 @@ export default class BindCommand extends EverywhereBaseCommand {
5556
const verbose = flags.verbose || dryRun;
5657

5758
const result = await this.loadRecords(args['app-source'], pluginDir);
58-
const schemas = result.records.map((record) => parseBusinessObject(JSON.parse(record.content)));
59+
const parsed = result.records.map((record) => parseBusinessObject(JSON.parse(record.content)));
5960
const outputDir = path.join(everywhereDir, OUTPUT_DIR);
6061

62+
const introspectionOutcome = await introspectGraphTypes(
63+
parsed,
64+
result.source.path,
65+
result.source.kind === 'zip'
66+
);
67+
const { schemas, warnings } = applyIntrospectionOutcome(parsed, introspectionOutcome);
68+
if (verbose) {
69+
for (const w of warnings) this.warn(w);
70+
}
71+
6172
if (verbose) {
6273
this.log(`Source: ${result.source.path} (${result.source.kind})`);
6374
this.log(`Output: ${outputDir}`);

0 commit comments

Comments
 (0)