@nivaro/sdk 0.1.15 → 0.1.17

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,737 @@
1
+ /**
2
+ * Typed GraphQL client generation (#1282) — `nivaro types --graphql`.
3
+ *
4
+ * Input: the `__schema` of a standard introspection result, read from the
5
+ * live instance AS THE TOKEN'S USER (so only the collections that user may
6
+ * see). Output: one standalone TypeScript file — types for every object,
7
+ * input, enum and union in the schema, a `Selection<T>` / `Selected<T, S>`
8
+ * pair that types a query's result from the fields it asks for, and
9
+ * `createGraphQLClient()` with list / byId / create / update / delete /
10
+ * aggregated / metadata helpers per collection plus a `graphql(doc, vars)`
11
+ * escape hatch. The file needs no dependency and no DOM lib: it carries its
12
+ * own fetch type.
13
+ *
14
+ * Plain ESM (no build step): the CLI imports it directly, the unit tests run
15
+ * it under `node --test`.
16
+ */
17
+
18
+ /** The introspection document the CLI posts. Seven `ofType` levels cover
19
+ * `[[T!]!]!`, the deepest wrapping the core emits. */
20
+ export const INTROSPECTION_QUERY = `query NivaroIntrospection {
21
+ __schema {
22
+ queryType { name }
23
+ mutationType { name }
24
+ types {
25
+ kind name description
26
+ fields(includeDeprecated: true) {
27
+ name description isDeprecated deprecationReason
28
+ args { name type { ...TypeRef } defaultValue }
29
+ type { ...TypeRef }
30
+ }
31
+ inputFields { name type { ...TypeRef } defaultValue }
32
+ enumValues(includeDeprecated: true) { name }
33
+ possibleTypes { name }
34
+ }
35
+ }
36
+ }
37
+ fragment TypeRef on __Type {
38
+ kind name
39
+ ofType { kind name ofType { kind name ofType { kind name ofType { kind name
40
+ ofType { kind name ofType { kind name ofType { kind name } } } } } } }
41
+ }`
42
+
43
+ // Null-prototype maps: a server-sent name like `constructor` must not find
44
+ // an inherited member.
45
+ const BUILTIN_SCALARS = Object.assign(Object.create(null), {
46
+ ID: 'string',
47
+ String: 'string',
48
+ Int: 'number',
49
+ Float: 'number',
50
+ Boolean: 'boolean'
51
+ })
52
+
53
+ /** Custom scalars whose wire form is known; anything else is `unknown`. */
54
+ const KNOWN_SCALARS = Object.assign(Object.create(null), {
55
+ Date: 'string',
56
+ DateTime: 'string',
57
+ Timestamp: 'string',
58
+ Time: 'string',
59
+ BigInt: 'string',
60
+ UUID: 'string'
61
+ })
62
+
63
+ /** Names a generated type may not take: TS keywords, the globals the file
64
+ * uses, and the file's own helpers. A schema type with one of them gets a
65
+ * trailing underscore. */
66
+ const RESERVED = new Set([
67
+ 'any',
68
+ 'as',
69
+ 'boolean',
70
+ 'break',
71
+ 'case',
72
+ 'catch',
73
+ 'class',
74
+ 'const',
75
+ 'continue',
76
+ 'debugger',
77
+ 'declare',
78
+ 'default',
79
+ 'delete',
80
+ 'do',
81
+ 'else',
82
+ 'enum',
83
+ 'export',
84
+ 'extends',
85
+ 'false',
86
+ 'finally',
87
+ 'for',
88
+ 'from',
89
+ 'function',
90
+ 'if',
91
+ 'implements',
92
+ 'import',
93
+ 'in',
94
+ 'infer',
95
+ 'instanceof',
96
+ 'interface',
97
+ 'is',
98
+ 'keyof',
99
+ 'let',
100
+ 'module',
101
+ 'namespace',
102
+ 'never',
103
+ 'new',
104
+ 'null',
105
+ 'number',
106
+ 'object',
107
+ 'of',
108
+ 'package',
109
+ 'private',
110
+ 'protected',
111
+ 'public',
112
+ 'readonly',
113
+ 'require',
114
+ 'return',
115
+ 'static',
116
+ 'string',
117
+ 'super',
118
+ 'switch',
119
+ 'symbol',
120
+ 'this',
121
+ 'throw',
122
+ 'true',
123
+ 'try',
124
+ 'type',
125
+ 'typeof',
126
+ 'undefined',
127
+ 'unique',
128
+ 'unknown',
129
+ 'var',
130
+ 'void',
131
+ 'while',
132
+ 'with',
133
+ 'yield',
134
+ 'Array',
135
+ 'Boolean',
136
+ 'Date',
137
+ 'Error',
138
+ 'Function',
139
+ 'JSON',
140
+ 'Map',
141
+ 'NonNullable',
142
+ 'Number',
143
+ 'Object',
144
+ 'Omit',
145
+ 'Partial',
146
+ 'Pick',
147
+ 'Promise',
148
+ 'ReadonlyArray',
149
+ 'Record',
150
+ 'Set',
151
+ 'String',
152
+ 'Symbol',
153
+ 'Selection',
154
+ 'Selected',
155
+ 'GqlBase',
156
+ 'GqlSelField',
157
+ 'GqlPick',
158
+ 'GqlUnionValue',
159
+ 'GqlWriteData',
160
+ 'GqlFetch',
161
+ 'GqlResponse',
162
+ 'GraphQLClientOptions',
163
+ 'GraphQLRequestError',
164
+ 'GraphQLClient',
165
+ 'createGraphQLClient',
166
+ 'gqlRender',
167
+ 'gqlDocument',
168
+ 'globalThis',
169
+ 'DEFAULT_SELECTIONS'
170
+ ])
171
+
172
+ /** The TS name a GraphQL type is emitted under. */
173
+ export function tsTypeName(name) {
174
+ if (!valid(name)) throw new Error(`Refusing to write the type name ${JSON.stringify(name)}`)
175
+ return RESERVED.has(name) ? `${name}_` : name
176
+ }
177
+
178
+ function named(ref) {
179
+ let r = ref
180
+ while (r && (r.kind === 'NON_NULL' || r.kind === 'LIST')) r = r.ofType
181
+ return r ?? null
182
+ }
183
+
184
+ /** GraphQL type-reference string for a variable declaration: `[String!]!`. */
185
+ export function gqlTypeRef(ref) {
186
+ if (ref.kind === 'NON_NULL') return `${gqlTypeRef(ref.ofType)}!`
187
+ if (ref.kind === 'LIST') return `[${gqlTypeRef(ref.ofType)}]`
188
+ return ref.name
189
+ }
190
+
191
+ function isRequired(ref) {
192
+ return ref.kind === 'NON_NULL'
193
+ }
194
+
195
+ /**
196
+ * The only names the generator writes into code or into a GraphQL document.
197
+ * The introspection answer comes from a server and is untrusted: a type,
198
+ * field, argument, enum value or collection whose name fails this test is
199
+ * left out of the generated file (a reference to a dropped type reads as
200
+ * `unknown`; an operation that needs a dropped argument is dropped).
201
+ */
202
+ export const GQL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
203
+ const valid = (name) => typeof name === 'string' && GQL_NAME.test(name)
204
+
205
+ function prop(name) {
206
+ return GQL_NAME.test(name) ? name : JSON.stringify(name)
207
+ }
208
+
209
+ /** Untrusted text made safe for the inside of a block comment: nothing can
210
+ * end the comment, control characters and line separators are flattened. */
211
+ export function commentText(text) {
212
+ return String(text ?? '')
213
+ .replace(/\r\n?|[\u2028\u2029]/g, '\n')
214
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point
215
+ .replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, ' ')
216
+ .replace(/\*\//g, '* /')
217
+ .replace(/\/\*/g, '/ *')
218
+ }
219
+
220
+ function comment(text, indent = '') {
221
+ if (!text) return ''
222
+ const clean = commentText(text).trim()
223
+ if (!clean) return ''
224
+ const lines = clean.split('\n')
225
+ if (lines.length === 1) return `${indent}/** ${lines[0]} */\n`
226
+ return `${indent}/**\n${lines.map((l) => `${indent} * ${l}`).join('\n')}\n${indent} */\n`
227
+ }
228
+
229
+ /**
230
+ * Builds the generator's view of the schema: types by name, the root type
231
+ * names, and every collection with the operations the core gives it.
232
+ */
233
+ /** Which policy action a helper needs. */
234
+ const OP_ACTION = {
235
+ list: 'read',
236
+ byId: 'read',
237
+ metadata: 'read',
238
+ aggregated: 'read',
239
+ create: 'create',
240
+ update: 'update',
241
+ delete: 'delete'
242
+ }
243
+
244
+ /**
245
+ * The caller's access, from GET /api/security/my/permissions, as the
246
+ * generator reads it: admins keep everything; anyone else keeps only the
247
+ * helpers their role's policies allow (`*` = every collection).
248
+ * @param {{ is_admin?: boolean, collections?: Array<{ collection: string, actions: string[] }> } | null} perms
249
+ */
250
+ export function accessFromPermissions(perms) {
251
+ if (!perms || perms.is_admin) return null
252
+ const map = new Map()
253
+ for (const c of perms.collections ?? []) {
254
+ const set = map.get(c.collection) ?? new Set()
255
+ for (const a of c.actions ?? []) set.add(a)
256
+ map.set(c.collection, set)
257
+ }
258
+ return map
259
+ }
260
+
261
+ function allowed(access, collection, method) {
262
+ if (!access) return true
263
+ const action = OP_ACTION[method]
264
+ const own = access.get(collection)
265
+ const any = access.get('*')
266
+ return !!(own?.has(action) || own?.has('*') || any?.has(action) || any?.has('*'))
267
+ }
268
+
269
+ /** The type reference with every name checked; null when a name fails. */
270
+ function cleanRef(ref, depth = 0) {
271
+ if (!ref || typeof ref !== 'object' || depth > 10) return null
272
+ if (ref.kind === 'NON_NULL' || ref.kind === 'LIST') {
273
+ const inner = cleanRef(ref.ofType, depth + 1)
274
+ return inner ? { kind: ref.kind, name: null, ofType: inner } : null
275
+ }
276
+ return valid(ref.name) ? { kind: String(ref.kind), name: ref.name, ofType: null } : null
277
+ }
278
+
279
+ /**
280
+ * A copy of the introspection `__schema` holding only what may be written:
281
+ * valid names everywhere, references to dropped types removed, fields whose
282
+ * required argument was dropped removed. Descriptions stay — they are only
283
+ * ever written inside comments, through commentText().
284
+ */
285
+ export function sanitizeSchema(schema) {
286
+ const kept = new Set(
287
+ (schema?.types ?? [])
288
+ .filter((t) => t && valid(t.name) && !t.name.startsWith('__'))
289
+ .map((t) => t.name)
290
+ )
291
+ for (const s of Object.keys(BUILTIN_SCALARS)) kept.add(s)
292
+ const ref = (r) => {
293
+ const c = cleanRef(r)
294
+ if (!c) return null
295
+ let n = c
296
+ while (n.ofType) n = n.ofType
297
+ return kept.has(n.name) ? c : null
298
+ }
299
+ const args = (list) => {
300
+ const out = []
301
+ for (const a of list ?? []) {
302
+ const type = valid(a?.name) ? ref(a.type) : null
303
+ if (type) out.push({ name: a.name, type, defaultValue: a.defaultValue ?? null })
304
+ else if (a?.type?.kind === 'NON_NULL' && a.defaultValue == null) return null
305
+ }
306
+ return out
307
+ }
308
+ const types = []
309
+ for (const t of schema?.types ?? []) {
310
+ if (!t || !kept.has(t.name) || t.name.startsWith('__')) continue
311
+ const fields = []
312
+ for (const f of t.fields ?? []) {
313
+ if (!valid(f?.name)) continue
314
+ const type = ref(f.type)
315
+ const a = args(f.args)
316
+ if (!type || !a) continue
317
+ fields.push({
318
+ name: f.name,
319
+ description: f.description ?? null,
320
+ isDeprecated: !!f.isDeprecated,
321
+ deprecationReason: f.deprecationReason ?? null,
322
+ args: a,
323
+ type
324
+ })
325
+ }
326
+ const inputFields = []
327
+ for (const f of t.inputFields ?? []) {
328
+ const type = valid(f?.name) ? ref(f.type) : null
329
+ if (type) inputFields.push({ name: f.name, type, defaultValue: f.defaultValue ?? null })
330
+ }
331
+ types.push({
332
+ kind: String(t.kind),
333
+ name: t.name,
334
+ description: t.description ?? null,
335
+ fields: t.fields ? fields : null,
336
+ inputFields: t.inputFields ? inputFields : null,
337
+ enumValues: t.enumValues ? t.enumValues.filter((v) => valid(v?.name)).map((v) => ({ name: v.name })) : null,
338
+ possibleTypes: t.possibleTypes
339
+ ? t.possibleTypes.filter((p) => kept.has(p?.name)).map((p) => ({ name: p.name }))
340
+ : null
341
+ })
342
+ }
343
+ const root = (r) => (r && valid(r.name) ? { name: r.name } : null)
344
+ return { queryType: root(schema?.queryType), mutationType: root(schema?.mutationType), types }
345
+ }
346
+
347
+ export function analyzeSchema(rawSchema, access = null) {
348
+ const schema = sanitizeSchema(rawSchema)
349
+ const types = new Map()
350
+ for (const t of schema.types) types.set(t.name, t)
351
+ const queryName = schema.queryType?.name ?? 'Query'
352
+ const mutationName = schema.mutationType?.name ?? null
353
+ const query = types.get(queryName)
354
+ const mutation = mutationName ? types.get(mutationName) : null
355
+ const qf = new Map((query?.fields ?? []).map((f) => [f.name, f]))
356
+ const mf = new Map((mutation?.fields ?? []).map((f) => [f.name, f]))
357
+ const collections = []
358
+ for (const f of query?.fields ?? []) {
359
+ const byId = qf.get(`${f.name}_by_id`)
360
+ if (!byId) continue
361
+ const item = named(byId.type)
362
+ if (!item || types.get(item.name)?.kind !== 'OBJECT') continue
363
+ const ops = [
364
+ ['list', 'query', f],
365
+ ['byId', 'query', byId],
366
+ ['metadata', 'query', qf.get(`${f.name}_metadata`)],
367
+ ['aggregated', 'query', qf.get(`${f.name}_aggregated`)],
368
+ ['create', 'mutation', mf.get(`create_${f.name}`)],
369
+ ['update', 'mutation', mf.get(`update_${f.name}_item`)],
370
+ ['delete', 'mutation', mf.get(`delete_${f.name}_item`)]
371
+ ]
372
+ .filter(([method, , field]) => field && allowed(access, f.name, method))
373
+ .map(([method, kind, field]) => ({ method, kind, field }))
374
+ if (ops.length === 0) continue
375
+ collections.push({ name: f.name, itemType: item.name, ops })
376
+ }
377
+ collections.sort((a, b) => a.name.localeCompare(b.name))
378
+ return { types, queryName, mutationName, collections }
379
+ }
380
+
381
+ function scalarTs(name) {
382
+ return BUILTIN_SCALARS[name] ?? KNOWN_SCALARS[name] ?? 'unknown'
383
+ }
384
+
385
+ /** TS type of an OUTPUT reference (object fields, operation results). */
386
+ function outTs(ref, types, nullable = true) {
387
+ if (ref.kind === 'NON_NULL') return outTs(ref.ofType, types, false)
388
+ const inner =
389
+ ref.kind === 'LIST'
390
+ ? `Array<${outTs(ref.ofType, types)}>`
391
+ : types.get(ref.name)?.kind === 'SCALAR' || BUILTIN_SCALARS[ref.name]
392
+ ? scalarTs(ref.name)
393
+ : types.has(ref.name)
394
+ ? tsTypeName(ref.name)
395
+ : 'unknown'
396
+ return nullable ? `${inner} | null` : inner
397
+ }
398
+
399
+ /** TS type of an INPUT reference (arguments, input-object fields). */
400
+ function inTs(ref, types) {
401
+ if (ref.kind === 'NON_NULL') return inTs(ref.ofType, types)
402
+ if (ref.kind === 'LIST') return `Array<${inTs(ref.ofType, types)}${nullableIn(ref.ofType)}>`
403
+ return types.get(ref.name)?.kind === 'SCALAR' || BUILTIN_SCALARS[ref.name]
404
+ ? scalarTs(ref.name)
405
+ : types.has(ref.name)
406
+ ? tsTypeName(ref.name)
407
+ : 'unknown'
408
+ }
409
+ function nullableIn(ref) {
410
+ return ref.kind === 'NON_NULL' ? '' : ' | null'
411
+ }
412
+
413
+ /** Fields a default selection takes: leaf fields with no required argument. */
414
+ function leafFields(type, types) {
415
+ const out = []
416
+ for (const f of type.fields ?? []) {
417
+ if ((f.args ?? []).some((a) => isRequired(a.type) && a.defaultValue == null)) continue
418
+ const n = named(f.type)
419
+ const kind = types.get(n.name)?.kind ?? (BUILTIN_SCALARS[n.name] ? 'SCALAR' : null)
420
+ if (kind === 'SCALAR' || kind === 'ENUM') out.push(f.name)
421
+ }
422
+ return out
423
+ }
424
+
425
+ const HELPERS = `// ─── Selection typing ───────────────────────────────────────────────────────
426
+
427
+ /** A union-typed field's value. Select it with a raw string of inline
428
+ * fragments: \`{ item: '... on vendors { id name }' }\`. */
429
+ export type GqlUnionValue = { __typename: string } & { [key: string]: unknown }
430
+
431
+ type GqlBase<V> = NonNullable<V> extends ReadonlyArray<infer U> ? NonNullable<U> : NonNullable<V>
432
+ type GqlSelField<V> = GqlBase<V> extends GqlUnionValue
433
+ ? string
434
+ : GqlBase<V> extends object
435
+ ? Selection<GqlBase<V>> | string
436
+ : true
437
+
438
+ /** The fields to fetch: \`true\` for a leaf, a nested Selection (or a raw
439
+ * GraphQL sub-selection string) for an object. */
440
+ export type Selection<T> = { [K in keyof T]?: GqlSelField<T[K]> }
441
+
442
+ type GqlPick<V, S> = V extends null | undefined
443
+ ? V
444
+ : V extends ReadonlyArray<infer U>
445
+ ? Array<GqlPick<U, S>>
446
+ : S extends string
447
+ ? V
448
+ : V extends object
449
+ ? Selected<V, S>
450
+ : V
451
+
452
+ /** The shape a query returns for the selection \`S\` over the type \`T\`. */
453
+ export type Selected<T, S> = { [K in keyof S & keyof T]: GqlPick<T[K], S[K]> }
454
+
455
+ /** A create / update payload: the item's own field names, plus anything the
456
+ * server accepts besides (nested rows, \`_change_reason\`). */
457
+ export type GqlWriteData<T> = { [K in keyof T]?: unknown } & {
458
+ _change_reason?: string
459
+ [key: string]: unknown
460
+ }
461
+
462
+ // ─── Transport ───────────────────────────────────────────────────────────────
463
+
464
+ type GqlResponse = {
465
+ ok: boolean
466
+ status: number
467
+ text(): Promise<string>
468
+ }
469
+ export type GqlFetch = (
470
+ url: string,
471
+ init: { method: string; headers: Record<string, string>; body: string }
472
+ ) => Promise<GqlResponse>
473
+
474
+ export interface GraphQLClientOptions {
475
+ /** API origin, e.g. https://cms.example.com — the client posts to /api/graphql. */
476
+ url: string
477
+ /** A static token or an API key, sent as a Bearer token. */
478
+ token?: string
479
+ headers?: Record<string, string>
480
+ /** Defaults to the global fetch. */
481
+ fetch?: GqlFetch
482
+ }
483
+
484
+ /** A GraphQL answer that carried \`errors\`, or a request that failed. */
485
+ export class GraphQLRequestError extends Error {
486
+ readonly status: number
487
+ readonly errors: Array<{ message: string; extensions?: Record<string, unknown> }>
488
+ constructor(
489
+ message: string,
490
+ status: number,
491
+ errors: Array<{ message: string; extensions?: Record<string, unknown> }>
492
+ ) {
493
+ super(message)
494
+ this.name = 'GraphQLRequestError'
495
+ this.status = status
496
+ this.errors = errors
497
+ }
498
+ }
499
+
500
+ function gqlRender(sel: Record<string, unknown>): string {
501
+ const parts: string[] = []
502
+ for (const [key, value] of Object.entries(sel)) {
503
+ if (!value) continue
504
+ if (value === true) parts.push(key)
505
+ else if (typeof value === 'string') parts.push(\`\${key} { \${value} }\`)
506
+ else parts.push(\`\${key} { \${gqlRender(value as Record<string, unknown>)} }\`)
507
+ }
508
+ if (parts.length === 0) throw new Error('A selection must name at least one field')
509
+ return parts.join(' ')
510
+ }
511
+
512
+ function gqlDocument(
513
+ kind: 'query' | 'mutation',
514
+ field: string,
515
+ varTypes: Record<string, string>,
516
+ vars: Record<string, unknown>,
517
+ selection: Record<string, unknown> | null
518
+ ): { query: string; variables: Record<string, unknown> } {
519
+ const used = Object.keys(varTypes).filter((k) => vars[k] !== undefined)
520
+ const defs = used.map((k) => \`$\${k}: \${varTypes[k]}\`).join(', ')
521
+ const args = used.map((k) => \`\${k}: $\${k}\`).join(', ')
522
+ const head = defs ? \`\${kind}(\${defs})\` : kind
523
+ const call = args ? \`\${field}(\${args})\` : field
524
+ const body = selection ? \` { \${gqlRender(selection)} }\` : ''
525
+ const variables: Record<string, unknown> = {}
526
+ for (const k of used) variables[k] = vars[k]
527
+ return { query: \`\${head} { \${call}\${body} }\`, variables }
528
+ }
529
+ `
530
+
531
+ /**
532
+ * Generates the client file from an introspection `__schema`.
533
+ * @param {object} schema `data.__schema` of the introspection answer
534
+ * @param {{ source?: string, access?: Map<string, Set<string>> | null }} [opts]
535
+ * `source` is shown in the header; `access` (accessFromPermissions) keeps
536
+ * only the helpers the caller may use
537
+ * @returns {string}
538
+ */
539
+ export function generateGraphQLClient(schema, opts = {}) {
540
+ const { types, queryName, mutationName, collections } = analyzeSchema(schema, opts.access ?? null)
541
+ const out = []
542
+ out.push(
543
+ '/* eslint-disable */\n' +
544
+ '// biome-ignore-all lint: generated file\n' +
545
+ '/**\n' +
546
+ ' * Typed GraphQL client — generated by `nivaro types --graphql`.\n' +
547
+ ` * Source: ${commentText(opts.source ?? 'a Nivaro instance').replace(/\n/g, ' ')}. Do not edit by hand; re-run the command.\n` +
548
+ ' *\n' +
549
+ ' * const cms = createGraphQLClient({ url: process.env.NIVARO_URL!, token })\n' +
550
+ ' * const rows = await cms.<collection>.list({ limit: 10 }, { id: true })\n' +
551
+ ' */\n'
552
+ )
553
+
554
+ // ── schema types ──
555
+ const roots = new Set([queryName, mutationName, 'Subscription'].filter(Boolean))
556
+ const sorted = [...types.values()].sort((a, b) => a.name.localeCompare(b.name))
557
+ out.push('// ─── Schema types ───────────────────────────────────────────────────────────\n')
558
+ for (const t of sorted) {
559
+ if (roots.has(t.name)) continue
560
+ const tn = tsTypeName(t.name)
561
+ if (t.kind === 'ENUM') {
562
+ const vals = (t.enumValues ?? []).map((v) => JSON.stringify(v.name))
563
+ out.push(`${comment(t.description)}export type ${tn} = ${vals.join(' | ') || 'never'}\n`)
564
+ } else if (t.kind === 'UNION') {
565
+ const members = (t.possibleTypes ?? []).map((p) => p.name).join(', ')
566
+ out.push(`/** Union of ${members}. */\nexport type ${tn} = GqlUnionValue\n`)
567
+ } else if (t.kind === 'INPUT_OBJECT') {
568
+ const lines = (t.inputFields ?? []).map((f) => {
569
+ const req = isRequired(f.type) && f.defaultValue == null
570
+ return ` ${prop(f.name)}${req ? '' : '?'}: ${inTs(f.type, types)}${req ? '' : ' | null'}`
571
+ })
572
+ out.push(
573
+ `${comment(t.description)}export interface ${tn} {\n${lines.join('\n')}${lines.length ? '\n' : ''}}\n`
574
+ )
575
+ } else if (t.kind === 'OBJECT' || t.kind === 'INTERFACE') {
576
+ const lines = (t.fields ?? []).map((f) => {
577
+ const deprecated = f.isDeprecated
578
+ ? ` /** @deprecated ${commentText(f.deprecationReason).replace(/\n/g, ' ')} */\n`
579
+ : comment(f.description, ' ')
580
+ return `${deprecated} ${prop(f.name)}: ${outTs(f.type, types)}`
581
+ })
582
+ out.push(
583
+ `${comment(t.description)}export interface ${tn} {\n${lines.join('\n')}${lines.length ? '\n' : ''}}\n`
584
+ )
585
+ }
586
+ }
587
+
588
+ out.push(HELPERS)
589
+
590
+ // ── default selections ──
591
+ const defaultsFor = new Set()
592
+ for (const c of collections)
593
+ for (const op of c.ops) {
594
+ const n = named(op.field.type)
595
+ const kind = types.get(n.name)?.kind
596
+ if (kind === 'OBJECT' || kind === 'INTERFACE') defaultsFor.add(n.name)
597
+ }
598
+ out.push(
599
+ '// ─── Default selections (every leaf field) ──────────────────────────────────\n\n' +
600
+ 'export const DEFAULT_SELECTIONS = {\n'
601
+ )
602
+ for (const name of [...defaultsFor].sort()) {
603
+ const leaves = leafFields(types.get(name), types)
604
+ const body = leaves.map((l) => `${prop(l)}: true`).join(', ')
605
+ out.push(` ${prop(name)}: {${body ? ` ${body} ` : ''}},\n`)
606
+ }
607
+ out.push('} as const\n\n')
608
+
609
+ // ── client ──
610
+ out.push(
611
+ '// ─── Client ─────────────────────────────────────────────────────────────────\n\n' +
612
+ 'export function createGraphQLClient(opts: GraphQLClientOptions) {\n' +
613
+ " const endpoint = `${opts.url.replace(/\\/+$/, '')}/api/graphql`\n" +
614
+ ' const doFetch: GqlFetch | undefined =\n' +
615
+ ' opts.fetch ?? (globalThis as unknown as { fetch?: GqlFetch }).fetch\n\n' +
616
+ ' /** Runs any document: the escape hatch for what the helpers do not cover. */\n' +
617
+ ' async function graphql<T = unknown>(\n' +
618
+ ' query: string,\n' +
619
+ ' variables?: Record<string, unknown>\n' +
620
+ ' ): Promise<T> {\n' +
621
+ " if (!doFetch) throw new Error('No fetch available — pass opts.fetch')\n" +
622
+ " const headers: Record<string, string> = { 'content-type': 'application/json', ...opts.headers }\n" +
623
+ ' if (opts.token) headers.authorization = `Bearer ${opts.token}`\n' +
624
+ ' const res = await doFetch(endpoint, {\n' +
625
+ " method: 'POST',\n" +
626
+ ' headers,\n' +
627
+ ' body: JSON.stringify({ query, variables: variables ?? {} })\n' +
628
+ ' })\n' +
629
+ ' const text = await res.text()\n' +
630
+ ' let json: { data?: unknown; errors?: Array<{ message: string }> } | null = null\n' +
631
+ ' try {\n' +
632
+ ' json = JSON.parse(text)\n' +
633
+ ' } catch {\n' +
634
+ ' json = null\n' +
635
+ ' }\n' +
636
+ ' if (json?.errors?.length)\n' +
637
+ ' throw new GraphQLRequestError(json.errors[0].message, res.status, json.errors)\n' +
638
+ ' if (!res.ok || !json)\n' +
639
+ ' throw new GraphQLRequestError(`HTTP ${res.status}: ${text.slice(0, 300)}`, res.status, [])\n' +
640
+ ' return json.data as T\n' +
641
+ ' }\n\n' +
642
+ ' async function run<T>(\n' +
643
+ " kind: 'query' | 'mutation',\n" +
644
+ ' field: string,\n' +
645
+ ' varTypes: Record<string, string>,\n' +
646
+ ' vars: Record<string, unknown>,\n' +
647
+ ' selection: Record<string, unknown> | null\n' +
648
+ ' ): Promise<T> {\n' +
649
+ ' const doc = gqlDocument(kind, field, varTypes, vars, selection)\n' +
650
+ ' const data = await graphql<Record<string, T>>(doc.query, doc.variables)\n' +
651
+ ' return data[field]\n' +
652
+ ' }\n\n' +
653
+ ' return {\n' +
654
+ ' graphql,\n'
655
+ )
656
+ const usedKeys = new Set(['graphql'])
657
+ for (const c of collections) {
658
+ let key = c.name
659
+ while (usedKeys.has(key)) key = `${key}_`
660
+ usedKeys.add(key)
661
+ const itemTs = tsTypeName(c.itemType)
662
+ out.push(` ${prop(key)}: {\n`)
663
+ for (const op of c.ops) out.push(renderOp(op, c, itemTs, types))
664
+ out.push(' },\n')
665
+ }
666
+ out.push(
667
+ ' }\n}\n\n/** The client `createGraphQLClient` returns. */\nexport type GraphQLClient = ReturnType<typeof createGraphQLClient>\n'
668
+ )
669
+ return out.join('\n')
670
+ }
671
+
672
+ function renderOp(op, c, itemTs, types) {
673
+ const { field, kind, method } = op
674
+ const args = field.args ?? []
675
+ const varTypes = Object.fromEntries(args.map((a) => [a.name, gqlTypeRef(a.type)]))
676
+ const n = named(field.type)
677
+ const retKind = types.get(n.name)?.kind
678
+ const selectable = retKind === 'OBJECT' || retKind === 'INTERFACE'
679
+ const retName = tsTypeName(n.name)
680
+ const writeData = method === 'create' || method === 'update'
681
+
682
+ const argTs = (a) => {
683
+ if (writeData && a.name === 'data') return `GqlWriteData<${itemTs}>`
684
+ return inTs(a.type, types) + (isRequired(a.type) ? '' : ' | null')
685
+ }
686
+ const names = args.map((a) => a.name).sort()
687
+ const shape = names.join(',')
688
+ const positional = shape === 'id' || shape === 'data' || shape === 'data,id'
689
+
690
+ // the result type, wrapped the way the field's own type is wrapped
691
+ const resultOf = (inner) => {
692
+ const wrap = (ref, nullable = true) => {
693
+ if (ref.kind === 'NON_NULL') return wrap(ref.ofType, false)
694
+ const body = ref.kind === 'LIST' ? `Array<${wrap(ref.ofType)}>` : inner
695
+ return nullable ? `${body} | null` : body
696
+ }
697
+ return wrap(field.type)
698
+ }
699
+
700
+ const params = []
701
+ let varsExpr
702
+ if (positional) {
703
+ const order = shape === 'data,id' ? ['id', 'data'] : names
704
+ for (const nm of order) {
705
+ const a = args.find((x) => x.name === nm)
706
+ params.push(`${nm}: ${argTs(a)}`)
707
+ }
708
+ varsExpr = `{ ${order.join(', ')} }`
709
+ } else if (args.length) {
710
+ const allOptional = args.every((a) => !isRequired(a.type) || a.defaultValue != null)
711
+ const fields = args
712
+ .map((a) => {
713
+ const req = isRequired(a.type) && a.defaultValue == null
714
+ return `${prop(a.name)}${req ? '' : '?'}: ${argTs(a)}`
715
+ })
716
+ .join('; ')
717
+ params.push(`args: { ${fields} }${allOptional ? ' = {}' : ''}`)
718
+ varsExpr = 'args as Record<string, unknown>'
719
+ } else {
720
+ varsExpr = '{}'
721
+ }
722
+
723
+ const doc = comment(field.description, ' ')
724
+ const vt = JSON.stringify(varTypes)
725
+ if (!selectable) {
726
+ const ret = resultOf(scalarTs(n.name) === 'unknown' ? 'unknown' : scalarTs(n.name))
727
+ return `${doc} ${method}(${params.join(', ')}): Promise<${ret}> {\n return run<${ret}>('${kind}', ${JSON.stringify(field.name)}, ${vt}, ${varsExpr}, null)\n },\n`
728
+ }
729
+ const defaultType = `(typeof DEFAULT_SELECTIONS)[${JSON.stringify(n.name)}]`
730
+ params.push(`select?: S`)
731
+ const ret = resultOf(`Selected<${retName}, S>`)
732
+ return (
733
+ `${doc} ${method}<S extends Selection<${retName}> = ${defaultType}>(${params.join(', ')}): Promise<${ret}> {\n` +
734
+ ` return run<${ret}>('${kind}', ${JSON.stringify(field.name)}, ${vt}, ${varsExpr}, (select ?? DEFAULT_SELECTIONS[${JSON.stringify(n.name)}]) as Record<string, unknown>)\n` +
735
+ ' },\n'
736
+ )
737
+ }