ghostrail 0.17.0 → 0.18.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.
@@ -0,0 +1,201 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Refresh `test/fixtures/linear-schema.graphql`, the schema slice that
4
+ * `test/source/linear-schema.test.ts` validates our documents against.
5
+ *
6
+ * Run it when you add or change a Linear document:
7
+ *
8
+ * LINEAR_API_KEY=lin_api_... node scripts/linear-schema-snapshot.mjs
9
+ *
10
+ * It introspects the live API, checks every document in `LINEAR_DOCUMENTS`
11
+ * against the real schema, and only then writes the snapshot. So an invalid
12
+ * document fails here, against Linear, rather than being frozen into the
13
+ * fixture and passing forever after.
14
+ *
15
+ * The snapshot is pruned to the fields our documents actually select, because
16
+ * Linear's full schema is 1.3MB of introspection and 1154 types. Pruning means
17
+ * a newly selected field fails the offline test until you re-run this, which is
18
+ * the point: the new field gets checked against Linear once, here.
19
+ */
20
+
21
+ import { mkdirSync, writeFileSync } from "node:fs";
22
+ import { dirname, join } from "node:path";
23
+ import { fileURLToPath } from "node:url";
24
+ import {
25
+ TypeInfo,
26
+ buildClientSchema,
27
+ getIntrospectionQuery,
28
+ getNamedType,
29
+ isEnumType,
30
+ isInputObjectType,
31
+ isInterfaceType,
32
+ isObjectType,
33
+ isScalarType,
34
+ isUnionType,
35
+ parse,
36
+ validate,
37
+ visit,
38
+ visitWithTypeInfo,
39
+ } from "graphql";
40
+
41
+ import { LINEAR_DOCUMENTS } from "../dist/source/linear.js";
42
+
43
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
44
+ const OUT = join(ROOT, "test", "fixtures", "linear-schema.graphql");
45
+ const ENDPOINT = process.env.LINEAR_ENDPOINT ?? "https://api.linear.app/graphql";
46
+
47
+ const BUILT_IN_SCALARS = new Set(["String", "Int", "Float", "Boolean", "ID"]);
48
+
49
+ function die(message) {
50
+ console.error(message);
51
+ process.exit(1);
52
+ }
53
+
54
+ async function introspect(apiKey) {
55
+ const response = await fetch(ENDPOINT, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json", Authorization: apiKey },
58
+ body: JSON.stringify({ query: getIntrospectionQuery({ inputValueDeprecation: false }) }),
59
+ });
60
+ if (!response.ok) {
61
+ die(`introspection failed: HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
62
+ }
63
+ const body = await response.json();
64
+ if (body.errors) die(`introspection failed: ${JSON.stringify(body.errors)}`);
65
+ return buildClientSchema(body.data);
66
+ }
67
+
68
+ /**
69
+ * Walk every document and collect what the snapshot has to define: the output
70
+ * fields actually selected (keyed `Type.field`), and every named type touched
71
+ * along the way, including the input types behind variables and arguments.
72
+ */
73
+ function collectUsage(schema, documents) {
74
+ const usedFields = new Set();
75
+ const usedTypes = new Set();
76
+
77
+ const addType = (type) => {
78
+ const named = getNamedType(type);
79
+ if (!named || usedTypes.has(named.name)) return;
80
+ usedTypes.add(named.name);
81
+ // Input types are printed whole (an argument can reference any of their
82
+ // fields), so their own field types have to come along.
83
+ if (isInputObjectType(named)) {
84
+ for (const field of Object.values(named.getFields())) addType(field.type);
85
+ }
86
+ };
87
+
88
+ for (const [name, source] of Object.entries(documents)) {
89
+ const doc = parse(source);
90
+ const typeInfo = new TypeInfo(schema);
91
+ visit(
92
+ doc,
93
+ visitWithTypeInfo(typeInfo, {
94
+ Field() {
95
+ const parent = typeInfo.getParentType();
96
+ const field = typeInfo.getFieldDef();
97
+ if (!parent || !field) die(`${name}: could not resolve a field against the schema`);
98
+ usedFields.add(`${parent.name}.${field.name}`);
99
+ addType(parent);
100
+ addType(field.type);
101
+ // Every argument the field declares, not only the ones we pass: the
102
+ // snapshot has to be able to reject an argument we wrongly add later.
103
+ for (const arg of field.args) addType(arg.type);
104
+ },
105
+ VariableDefinition() {
106
+ addType(typeInfo.getInputType());
107
+ },
108
+ }),
109
+ );
110
+ }
111
+ return { usedFields, usedTypes };
112
+ }
113
+
114
+ /** Print one type, keeping only the selected fields for object-ish types. */
115
+ function printPrunedType(type, usedFields) {
116
+ const describe = (field) => {
117
+ const args = field.args ?? [];
118
+ const argText =
119
+ args.length === 0 ? "" : `(${args.map((a) => `${a.name}: ${a.type}`).join(", ")})`;
120
+ return ` ${field.name}${argText}: ${field.type}`;
121
+ };
122
+
123
+ if (isScalarType(type)) {
124
+ return BUILT_IN_SCALARS.has(type.name) ? "" : `scalar ${type.name}`;
125
+ }
126
+ if (isEnumType(type)) {
127
+ const values = type.getValues().map((v) => ` ${v.name}`);
128
+ return `enum ${type.name} {\n${values.join("\n")}\n}`;
129
+ }
130
+ if (isUnionType(type)) {
131
+ return `union ${type.name} = ${type
132
+ .getTypes()
133
+ .map((t) => t.name)
134
+ .join(" | ")}`;
135
+ }
136
+ if (isInputObjectType(type)) {
137
+ const fields = Object.values(type.getFields()).map((f) => ` ${f.name}: ${f.type}`);
138
+ return `input ${type.name} {\n${fields.join("\n")}\n}`;
139
+ }
140
+ if (isObjectType(type) || isInterfaceType(type)) {
141
+ const kept = Object.values(type.getFields()).filter((f) =>
142
+ usedFields.has(`${type.name}.${f.name}`),
143
+ );
144
+ if (kept.length === 0) return "";
145
+ const keyword = isObjectType(type) ? "type" : "interface";
146
+ return `${keyword} ${type.name} {\n${kept.map(describe).join("\n")}\n}`;
147
+ }
148
+ return "";
149
+ }
150
+
151
+ async function main() {
152
+ const apiKey = process.env.LINEAR_API_KEY;
153
+ if (!apiKey) die("LINEAR_API_KEY is not set; this script talks to the real Linear API");
154
+
155
+ const schema = await introspect(apiKey);
156
+
157
+ // Check first, write second. A document that Linear rejects must not be able
158
+ // to bake itself into the fixture and look correct from then on.
159
+ let invalid = false;
160
+ for (const [name, source] of Object.entries(LINEAR_DOCUMENTS)) {
161
+ const errors = validate(schema, parse(source));
162
+ if (errors.length > 0) {
163
+ invalid = true;
164
+ console.error(`${name} is not valid against Linear's schema:`);
165
+ for (const error of errors) console.error(` ${error.message}`);
166
+ }
167
+ }
168
+ if (invalid) die("\nrefusing to write a snapshot for documents Linear would reject");
169
+
170
+ const { usedFields, usedTypes } = collectUsage(schema, LINEAR_DOCUMENTS);
171
+ const blocks = [...usedTypes]
172
+ .sort()
173
+ .map((name) => printPrunedType(schema.getType(name), usedFields))
174
+ .filter((block) => block.length > 0);
175
+
176
+ const roots = [
177
+ "schema {",
178
+ ` query: ${schema.getQueryType().name}`,
179
+ ...(schema.getMutationType() ? [` mutation: ${schema.getMutationType().name}`] : []),
180
+ "}",
181
+ ].join("\n");
182
+
183
+ const header = [
184
+ "# Generated by scripts/linear-schema-snapshot.mjs. Do not hand-edit.",
185
+ "#",
186
+ "# A slice of Linear's schema: the fields our documents select, plus the",
187
+ "# input types they pass. Regenerate with:",
188
+ "#",
189
+ "# pnpm build && LINEAR_API_KEY=... node scripts/linear-schema-snapshot.mjs",
190
+ "",
191
+ ].join("\n");
192
+
193
+ mkdirSync(dirname(OUT), { recursive: true });
194
+ writeFileSync(OUT, `${header}${roots}\n\n${blocks.join("\n\n")}\n`);
195
+ console.log(
196
+ `wrote ${OUT}: ${blocks.length} types, ${usedFields.size} fields, ` +
197
+ `${Object.keys(LINEAR_DOCUMENTS).length} documents validated`,
198
+ );
199
+ }
200
+
201
+ await main();