@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.
- package/bin/lib/ext-scaffold.mjs +220 -0
- package/bin/lib/graphql-codegen.mjs +737 -0
- package/bin/nivaro.mjs +150 -29
- package/dist/index.d.ts +50 -1
- package/dist/index.js +29 -0
- package/package.json +4 -2
|
@@ -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
|
+
}
|