-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmolplugin.js
More file actions
334 lines (301 loc) · 15 KB
/
Copy pathmolplugin.js
File metadata and controls
334 lines (301 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
// graphql-codegen plugin: emits the $mol-style typed seam for one .graphql file.
//
// For an operation file (query/mutation):
// export function $demo_app_notes(): demo_app_notesQuery {
// return $demo_graphql_request(`<operation + all spread fragment definitions>`) as demo_app_notesQuery
// }
// The result/variables types are baked in by the generator - no reliance on
// byte-for-byte literal matching. Fragment spreads are merged into the sent
// document at codegen time (transitively, by unique fragment name).
//
// For a subscription file the wrapper returns the raw SSE host, typed:
// export function $demo_note_live_note_liked(): $demo_graphql_subscription_host<demo_note_live_note_likedSubscription> {
// return $demo_graphql_subscription(`<subscription document>`) as ...
// }
// Only the TYPE comes from the codegen; the stream runtime stays the
// hand-written host (graphql/subscription/) - a stream is not a request.
//
// Operations are auto-named from the file location: the canonical name is the
// wrapper symbol without the `$` (note/card/like.graphql -> demo_note_card_like).
// Whatever the author wrote - `query { ... }` (anonymous) or any name - is
// overridden to the canonical BEFORE the stock plugin runs, so the wrapper
// symbol, the result type and the name the server/APM sees all match the file
// path 1:1. Fragments are NOT renamed - they are spread by name (Relay model),
// so their declared names are the API - but a non-canonical fragment name gets
// a non-blocking warning recommending the path-derived one.
//
// For a fragment file:
// export type $demo_note_card_note = DemoNoteCard_noteFragment
// export type $demo_note_card_note_ref = $demo_graphql_ref<...> - bare-name ref alias, usable in .view.tree.
// export function $demo_note_card_note_unmask(ref): DemoNoteCard_noteFragment - identity, no request;
// overloaded to preserve the ref's nullability (nullable ref in - nullable fragment out).
// export function $demo_note_card_note_unmask_not_null(ref): DemoNoteCard_noteFragment - throws on a
// null/undefined ref, the runtime-checked alternative to TS `!`.
//
// It wraps the stock `typescript` / `typescript-operations` plugins and escapes
// every `$` they (or the embedded GraphQL) produce as `\u0024`: the $mol builder
// scans sources for $-prefixed names to build its module graph, and would
// otherwise treat GraphQL variables ($id) and masking keys (' $fragmentRefs')
// as module references. TS/JS treat $ as the same character, so types and
// runtime strings are unchanged. $-names WE emit ($demo_..., doc-comment deps)
// stay unescaped on purpose - those are real module references.
//
// `config.revalidation` picks the invalidation metadata baked into the wrappers:
// 'all' (default) pass caller opts through - every query subscribes to the
// universal marker, every mutation bumps it (refetch everything)
// 'by_typenames' walk the operation against the schema and bake in the
// static type set: `reads` for a query, `writes` for a
// mutation (payload types plus @touches); an empty set falls
// back to the universal marker (unknown effect = assume all)
// 'disable' bake in an empty set: never subscribe, never bump
// `{ revalidate: false }` at the call site opts out in every mode.
const typescriptPlugin = require('@graphql-codegen/typescript')
const operationsPlugin = require('@graphql-codegen/typescript-operations')
const { getNamedType, isObjectType, isAbstractType, print } = require('graphql')
const SUFFIX = { query: 'Query', mutation: 'Mutation', subscription: 'Subscription' }
const REVALIDATION_MODES = ['all', 'by_typenames', 'disable']
module.exports = {
plugin: async (schema, documents, config, info) => {
if (config.molMode === 'schema') {
const out = await typescriptPlugin.plugin(schema, documents, stockConfig(config), info)
return escapeDollars(flatten(out))
}
const runtime = config.molRuntime || '$graphql'
const fragments = config.molFragments || {}
const symbol = config.molSymbol
const revalidation = config.revalidation || 'all'
if (!REVALIDATION_MODES.includes(revalidation)) {
throw new Error(`Unknown revalidation mode "${revalidation}": use ${REVALIDATION_MODES.map(mode => `'${mode}'`).join(' | ')}`)
}
// rename operations to the canonical before the stock plugin runs, so the
// generated result/variables types are derived from the canonical name too
const named = documents.map(doc => renameOperations(doc, symbol.slice(1)))
const types = await operationsPlugin.plugin(schema, named, stockConfig(config), info)
const lines = [escapeDollars(flatten(types))]
for (const doc of named) {
for (const def of doc.document.definitions) {
if (def.kind === 'OperationDefinition') {
lines.push(...operationCode(def, doc, { symbol, runtime, fragments, schema, revalidation }))
} else if (def.kind === 'FragmentDefinition') {
lines.push(...fragmentCode(def, { symbol, runtime, location: doc.location }))
}
}
}
return lines.join('\n')
},
}
// strip our own config keys before handing config to the stock plugins
function stockConfig(config) {
const { molMode, molRuntime, molFragments, molSymbol, molSchemaTypes, revalidation, ...rest } = config
return rest
}
function flatten(out) {
if (typeof out === 'string') return out
return [...(out.prepend || []), out.content || '', ...(out.append || [])].join('\n')
}
function escapeDollars(code) {
return code.replace(/\$/g, '\\u0024')
}
// AST-level rename of every operation definition to the path-derived canonical;
// fragment definitions pass through untouched
function renameOperations(doc, name) {
return {
...doc,
document: {
...doc.document,
definitions: doc.document.definitions.map(def =>
def.kind === 'OperationDefinition'
? { ...def, name: { kind: 'Name', value: name } }
: def,
),
},
}
}
function operationCode(def, doc, { symbol, runtime, fragments, schema, revalidation }) {
const opName = def.name.value
const resultType = opName + SUFFIX[def.operation]
const varsType = resultType + 'Variables'
// merge spread fragments (transitive closure over the global registry);
// the operation is printed from its (renamed) AST, not the raw source;
// @touches is a client-side invalidation hint - never sent to the server
const closure = fragmentClosure(def, fragments, doc.location)
const merged = [print(def).trim(), ...closure.map(name => fragments[name].source)]
.join('\n\n')
.replace(/\s*@touches\([^)]*\)/g, '')
// typed variables parameter, plus an optional per-call opts (the invalidation
// escape hatch: `{ revalidate: false }` opts this call out of refetch-on-mutation)
const varDefs = def.variableDefinitions || []
const required = varDefs.some(v => v.type.kind === 'NonNullType' && !v.defaultValue)
const varsParam = varDefs.length === 0 ? '' : `variables${required ? '' : '?'}: ${varsType}`
const varsArg = varDefs.length === 0 ? 'undefined' : 'variables'
// a subscription is a stream, not a request: the wrapper only TYPES the raw
// SSE host with the schema-derived result type; the runtime host itself stays
// hand-written (no request seam, no revalidation opts)
if (def.operation === 'subscription') {
const hostType = `${runtime}_subscription_host<${resultType}>`
const code = ['']
if (closure.length) {
code.push(`/** Spreads fragments: ${closure.map(name => fragments[name].symbol).join(', ')} */`)
}
code.push(
`export function ${symbol}(${varsParam}): ${hostType} {`,
`\treturn ${runtime}_subscription(\`${escapeTemplate(merged)}\`, ${varsArg}) as ${hostType}`,
`}`,
)
return code
}
const optsParam = 'opts?: { revalidate?: boolean }'
const param = [varsParam, optsParam].filter(Boolean).join(', ')
const optsArg = optsArgCode(def, { schema, revalidation, fragments, location: doc.location })
const code = ['']
if (closure.length) {
// doc-comment so the $mol builder records dependencies on the fragment
// modules (it scans $-names, skipping non-doc comments)
code.push(`/** Spreads fragments: ${closure.map(name => fragments[name].symbol).join(', ')} */`)
}
code.push(
`export function ${symbol}(${param}): ${resultType} {`,
`\treturn ${runtime}_request(\`${escapeTemplate(merged)}\`, ${varsArg}, ${optsArg}) as ${resultType}`,
`}`,
)
return code
}
// Third wrapper argument per revalidation mode. 'all' passes the caller opts
// through untouched (universal marker). 'disable' pins an empty type set:
// subscribe/bump nothing. 'by_typenames' bakes in the operation's static type
// set: reads for a query, writes (payload types + @touches) for a mutation;
// an empty computed set falls back to the universal marker (unknown effect =
// assume everything). The `...opts` spread keeps `revalidate: false` working
// while callers cannot override the baked-in sets.
function optsArgCode(def, { schema, revalidation, fragments, location }) {
if (revalidation === 'all') return 'opts'
const key = def.operation === 'mutation' ? 'writes' : 'reads'
if (revalidation === 'disable') return `{ ${key}: [], ...opts }`
const types = new Set(typeSet(def, schema, fragments, location))
for (const name of touchesTypes(def, schema, location)) types.add(name)
if (!types.size) return 'opts'
return `{ ${key}: [${[...types].sort().map(name => `'${name}'`).join(', ')}], ...opts }`
}
// Object types an operation touches: for a query, what it READS; for a
// mutation, what its payload claims it WROTE. A static walk of the selection
// against the schema: fragment spreads resolve through the global registry,
// abstract types expand to every possible concrete type (a safe superset,
// no runtime __typename needed), root operation types are excluded (every
// query would carry Query - zero discrimination).
function typeSet(def, schema, fragments, location) {
const roots = new Set([schema.getQueryType(), schema.getMutationType(), schema.getSubscriptionType()].filter(Boolean))
const seen = new Set()
const spread = new Set()
const record = type => {
if (isObjectType(type)) {
if (!roots.has(type)) seen.add(type.name)
} else if (isAbstractType(type)) {
for (const concrete of schema.getPossibleTypes(type)) seen.add(concrete.name)
}
}
const walk = (selectionSet, parentType) => {
for (const sel of selectionSet.selections) {
if (sel.kind === 'Field') {
if (sel.name.value.startsWith('__')) continue // meta fields carry no schema type
const fieldDef = parentType.getFields()[sel.name.value]
if (!fieldDef) throw new Error(`${location}: unknown field "${sel.name.value}" on type "${parentType.name}"`)
const type = getNamedType(fieldDef.type)
record(type)
if (sel.selectionSet) walk(sel.selectionSet, type)
} else if (sel.kind === 'FragmentSpread') {
const name = sel.name.value
if (spread.has(name)) continue
spread.add(name)
const frag = fragments[name] // existence checked by fragmentClosure before this runs
walk(frag.node.selectionSet, schema.getType(frag.node.typeCondition.name.value))
} else if (sel.kind === 'InlineFragment') {
const cond = sel.typeCondition ? schema.getType(sel.typeCondition.name.value) : parentType
walk(sel.selectionSet, cond)
}
}
}
walk(def.selectionSet, def.operation === 'mutation' ? schema.getMutationType() : schema.getQueryType())
return [...seen]
}
// @touches(types: ["Note", ...]) - the escape hatch for mutations whose side
// effects reach types absent from their payload (urql's additionalTypenames
// analogue). Parsed here, unioned into `writes`, stripped from the sent text.
function touchesTypes(def, schema, location) {
const dir = (def.directives || []).find(dir => dir.name.value === 'touches')
if (!dir) return []
const arg = (dir.arguments || []).find(arg => arg.name.value === 'types')
if (!arg || arg.value.kind !== 'ListValue') {
throw new Error(`${location}: @touches expects a list argument: @touches(types: ["TypeName"])`)
}
return arg.value.values.map(value => {
if (value.kind !== 'StringValue' || !schema.getType(value.value)) {
throw new Error(`${location}: @touches types must name schema types, got ${value.value ?? value.kind}`)
}
return value.value
})
}
function fragmentCode(def, { symbol, runtime, location }) {
// symmetric to operation auto-naming, but advisory only: renaming a fragment
// would break its spread sites, so the declared name stays authoritative
const canonical = symbol.slice(1)
if (def.name.value !== canonical) {
console.warn(
`${location}: fragment "${def.name.value}" does not match its file location - ` +
`consider renaming it (and its spreads) to "${canonical}"`,
)
}
const fragType = def.name.value + 'Fragment'
const refType = `${runtime}_ref<${fragType}>`
return [
``,
`/** Data declared by fragment \`${def.name.value}\` - spread it anywhere as \`...${def.name.value}\`. */`,
`export type ${symbol} = ${fragType}`,
``,
`/** Opaque ref to this fragment - a bare name usable where generics don't fit, e.g. a .view.tree property: \`<prop> null ${symbol}_ref\`. */`,
`export type ${symbol}_ref = ${refType}`,
``,
`/**`,
` * Identity accessor: turns an opaque fragment ref (masked parent data) into the typed fragment fields.`,
` * Preserves the ref's nullability: a non-null ref yields the fragment, a nullable ref (nullable schema`,
` * field, null list element) yields a nullable fragment - the compiler forces the null branch.`,
` */`,
`export function ${symbol}_unmask(ref: ${refType}): ${fragType}`,
`export function ${symbol}_unmask(ref: ${refType} | null | undefined): ${fragType} | null | undefined`,
`export function ${symbol}_unmask(ref: ${refType} | null | undefined): ${fragType} | null | undefined {`,
`\treturn ref as ${fragType} | null | undefined`,
`}`,
``,
`/** Checked accessor: unmask that throws on a null/undefined ref - the runtime-checked alternative to TS \`!\`. */`,
`export function ${symbol}_unmask_not_null(ref: ${refType} | null | undefined): ${fragType} {`,
`\tif (ref == null) throw new Error('null fragment ref for ${def.name.value}')`,
`\treturn ref as ${fragType}`,
`}`,
]
}
function fragmentClosure(def, fragments, location) {
const seen = new Set()
const queue = [...collectSpreads(def)]
while (queue.length) {
const name = queue.shift()
if (seen.has(name)) continue
const frag = fragments[name]
if (!frag) throw new Error(`${location}: fragment "${name}" is spread but not defined in any .graphql file`)
seen.add(name)
queue.push(...frag.spreads)
}
return [...seen]
}
function collectSpreads(node, acc = new Set()) {
if (node.kind === 'FragmentSpread') acc.add(node.name.value)
for (const key of ['selectionSet', 'selections']) {
const sub = node[key]
if (Array.isArray(sub)) sub.forEach(child => collectSpreads(child, acc))
else if (sub && typeof sub === 'object') collectSpreads(sub, acc)
}
return acc
}
// escape for embedding in a template literal; all `$` become `\u0024` so the
// $mol dependency scanner ignores GraphQL variables (also neutralizes `${`)
function escapeTemplate(str) {
return str.replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$/g, '\\u0024')
}