@voxgig/apidef 6.5.1 → 7.0.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.
Files changed (52) hide show
  1. package/dist/apidef.js +39 -2
  2. package/dist/apidef.js.map +1 -1
  3. package/dist/guide/graphql01.d.ts +39 -0
  4. package/dist/guide/graphql01.js +331 -0
  5. package/dist/guide/graphql01.js.map +1 -0
  6. package/dist/guide/guide.js +70 -10
  7. package/dist/guide/guide.js.map +1 -1
  8. package/dist/guide/heuristic01.js +1 -0
  9. package/dist/guide/heuristic01.js.map +1 -1
  10. package/dist/model.d.ts +22 -1
  11. package/dist/parse/graphql.d.ts +46 -0
  12. package/dist/parse/graphql.js +205 -0
  13. package/dist/parse/graphql.js.map +1 -0
  14. package/dist/parse.js +19 -0
  15. package/dist/parse.js.map +1 -1
  16. package/dist/transform/args.js +49 -4
  17. package/dist/transform/args.js.map +1 -1
  18. package/dist/transform/entity.js +51 -3
  19. package/dist/transform/entity.js.map +1 -1
  20. package/dist/transform/field.js +71 -1
  21. package/dist/transform/field.js.map +1 -1
  22. package/dist/transform/graphql.d.ts +6 -0
  23. package/dist/transform/graphql.js +227 -0
  24. package/dist/transform/graphql.js.map +1 -0
  25. package/dist/transform/select.js +17 -2
  26. package/dist/transform/select.js.map +1 -1
  27. package/dist/transform/top.js +31 -2
  28. package/dist/transform/top.js.map +1 -1
  29. package/dist/tsconfig.tsbuildinfo +1 -1
  30. package/dist/types.d.ts +16 -1
  31. package/dist/types.js.map +1 -1
  32. package/dist/utility.d.ts +2 -1
  33. package/dist/utility.js +52 -4
  34. package/dist/utility.js.map +1 -1
  35. package/model/apidef.aontu +23 -0
  36. package/model/guide.aontu +22 -0
  37. package/package.json +9 -2
  38. package/src/apidef.ts +44 -2
  39. package/src/guide/graphql01.ts +516 -0
  40. package/src/guide/guide.ts +90 -12
  41. package/src/guide/heuristic01.ts +1 -0
  42. package/src/model.ts +46 -1
  43. package/src/parse/graphql.ts +302 -0
  44. package/src/parse.ts +23 -0
  45. package/src/transform/args.ts +54 -5
  46. package/src/transform/entity.ts +63 -3
  47. package/src/transform/field.ts +89 -2
  48. package/src/transform/graphql.ts +311 -0
  49. package/src/transform/select.ts +19 -2
  50. package/src/transform/top.ts +31 -2
  51. package/src/types.ts +40 -0
  52. package/src/utility.ts +54 -4
package/src/model.ts CHANGED
@@ -90,13 +90,54 @@ type ModelArg = {
90
90
  }
91
91
 
92
92
 
93
- // One concrete HTTP endpoint that can satisfy an operation. An entity op
93
+ // Transport a point speaks. 'http' is the default and covers every
94
+ // OpenAPI-derived point; 'graphql' points carry a `graphql` block instead
95
+ // of relying on method+path (they synthesize method 'POST' and empty
96
+ // parts so the HTTP-shaped machinery downstream keeps working unchanged).
97
+ type PointKind = 'http' | 'graphql'
98
+
99
+
100
+ // Pagination descriptor for a GraphQL list op. `nodes`/`cursor`/`more` are
101
+ // dotted paths relative to the unwrapped connection object.
102
+ type ModelGraphqlPage = {
103
+ style: string
104
+ nodes: string
105
+ cursor: string
106
+ more: string
107
+ }
108
+
109
+
110
+ // One GraphQL variable binding: `name` is the variable as it appears in the
111
+ // operation document, `from` the op argument it is read from, `gqltype` the
112
+ // declared GraphQL type (e.g. 'String!').
113
+ type ModelGraphqlVar = {
114
+ name: string
115
+ from: string
116
+ gqltype: string
117
+ }
118
+
119
+
120
+ // GraphQL wire data for a point. `doc` is the complete operation document,
121
+ // rendered single-line with sorted selection fields so output stays
122
+ // byte-stable and schema drift shows up in model diffs.
123
+ type ModelGraphql = {
124
+ optype: 'query' | 'mutation'
125
+ field: string
126
+ doc: string
127
+ vars: ModelGraphqlVar[]
128
+ page?: ModelGraphqlPage
129
+ }
130
+
131
+
132
+ // One concrete endpoint that can satisfy an operation. An entity op
94
133
  // (load/list/create/...) carries an array of these — apidef chooses
95
134
  // between them at runtime via `select.exist` matching against reqmatch /
96
135
  // reqdata. (Originally named `ModelTarget`; renamed for consistency with
97
136
  // the field name `points` and the runtime utility `MakePoint`.)
98
137
  type ModelPoint = {
99
138
  orig: string
139
+ kind?: PointKind
140
+ graphql?: ModelGraphql
100
141
  method: MethodName
101
142
  parts: string[]
102
143
  rename: Partial<{
@@ -210,6 +251,10 @@ type ModelEntityFlowStep = {
210
251
  export type {
211
252
  OpName,
212
253
  ArgKind,
254
+ PointKind,
255
+ ModelGraphql,
256
+ ModelGraphqlVar,
257
+ ModelGraphqlPage,
213
258
  NamesCluster,
214
259
  Model,
215
260
  ModelEntityRelations,
@@ -0,0 +1,302 @@
1
+ /* Copyright (c) 2024-2026 Voxgig, MIT License */
2
+
3
+ // GraphQL ingestion: normalise an SDL document or an introspection result
4
+ // into the plain `def` structure the guide and transform stages consume.
5
+ //
6
+ // The OpenAPI parser hands downstream stages the spec object itself, with
7
+ // `$ref`s resolved in place. GraphQL has no equivalent literal document, so
8
+ // this builds an explicit graph instead:
9
+ //
10
+ // def.types — every named type, keyed by type name
11
+ // def.query — root Query fields, keyed by field name
12
+ // def.mutation — root Mutation fields, keyed by field name
13
+ // def.servers — synthesised from the `endpoint` option (a schema carries
14
+ // no deployment URL, but transform/top.ts requires one)
15
+ // def.info — synthesised; SDL has no info block
16
+ //
17
+ // Type references are held as NAME STRINGS, never object pointers, so the
18
+ // result is acyclic and JSON-serialisable by construction — GraphQL type
19
+ // graphs are freely recursive (Issue.team.issues), and apidef writes
20
+ // `<def>.full.json` under the debug flag.
21
+
22
+ import { relativizePath } from '../utility'
23
+
24
+
25
+ // A single argument on a root field or a type field.
26
+ type GqlArg = {
27
+ name: string
28
+ gqltype: string // rendered GraphQL type, e.g. 'String!' or '[Int!]'
29
+ type: string // named (unwrapped) type, e.g. 'String'
30
+ reqd: boolean
31
+ deflt?: any
32
+ }
33
+
34
+
35
+ // A field on an object/interface type, or a root field.
36
+ type GqlField = {
37
+ name: string
38
+ gqltype: string
39
+ type: string // named (unwrapped) type
40
+ reqd: boolean
41
+ list: boolean
42
+ args: GqlArg[]
43
+ deprecated: boolean
44
+ desc?: string
45
+ }
46
+
47
+
48
+ // A named type in the schema. `fields` is present for OBJECT, INTERFACE and
49
+ // INPUT_OBJECT kinds; `values` for ENUM; `possible` for UNION/INTERFACE.
50
+ type GqlType = {
51
+ name: string
52
+ kind: string // OBJECT | INPUT_OBJECT | ENUM | SCALAR | INTERFACE | UNION
53
+ fields: Record<string, GqlField>
54
+ values?: string[]
55
+ possible?: string[]
56
+ interfaces?: string[]
57
+ desc?: string
58
+ }
59
+
60
+
61
+ type GqlDef = {
62
+ graphql: true
63
+ info: Record<string, any>
64
+ servers: { url: string }[]
65
+ types: Record<string, GqlType>
66
+ query: Record<string, GqlField>
67
+ mutation: Record<string, GqlField>
68
+ subscription: Record<string, GqlField>
69
+ }
70
+
71
+
72
+ // `graphql` is an OPTIONAL peer dependency: REST-only consumers should not
73
+ // have to install it. Resolve it lazily, and fail with an actionable message
74
+ // rather than a bare MODULE_NOT_FOUND.
75
+ function loadGraphQL(meta: { file: string }): any {
76
+ try {
77
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
78
+ return require('graphql')
79
+ }
80
+ catch (err: any) {
81
+ throw new Error(
82
+ '@voxgig/apidef: parse: GraphQL: the "graphql" package is required to' +
83
+ ' parse GraphQL schemas - install it alongside @voxgig/apidef' +
84
+ ` (${relativizePath(meta.file)})`
85
+ )
86
+ }
87
+ }
88
+
89
+
90
+ // Introspection JSON arrives either bare (`{__schema:...}`) or wrapped in a
91
+ // GraphQL response envelope (`{data:{__schema:...}}`).
92
+ function asIntrospection(source: string): any {
93
+ const trimmed = source.trimStart()
94
+ if (!trimmed.startsWith('{')) {
95
+ return undefined
96
+ }
97
+
98
+ let parsed: any
99
+ try {
100
+ parsed = JSON.parse(source)
101
+ }
102
+ catch (err: any) {
103
+ return undefined
104
+ }
105
+
106
+ if (null != parsed?.__schema) {
107
+ return parsed
108
+ }
109
+ if (null != parsed?.data?.__schema) {
110
+ return parsed.data
111
+ }
112
+
113
+ return undefined
114
+ }
115
+
116
+
117
+ // Render a type reference to its GraphQL source form ('[Issue!]!') and its
118
+ // named form ('Issue'), plus the required/list flags the classifier keys on.
119
+ function describeType(G: any, gtype: any) {
120
+ const gqltype = String(gtype)
121
+ const named = G.getNamedType(gtype)
122
+ return {
123
+ gqltype,
124
+ type: named.name,
125
+ reqd: G.isNonNullType(gtype),
126
+ list: G.isListType(G.isNonNullType(gtype) ? gtype.ofType : gtype),
127
+ }
128
+ }
129
+
130
+
131
+ function buildArgs(G: any, gargs: any[]): GqlArg[] {
132
+ return (gargs || []).map((ga: any) => {
133
+ const d = describeType(G, ga.type)
134
+ const arg: GqlArg = {
135
+ name: ga.name,
136
+ gqltype: d.gqltype,
137
+ type: d.type,
138
+ reqd: d.reqd,
139
+ }
140
+ if (undefined !== ga.defaultValue && null !== ga.defaultValue) {
141
+ arg.deflt = ga.defaultValue
142
+ }
143
+ return arg
144
+ })
145
+ }
146
+
147
+
148
+ function buildField(G: any, gfield: any): GqlField {
149
+ const d = describeType(G, gfield.type)
150
+ const field: GqlField = {
151
+ name: gfield.name,
152
+ gqltype: d.gqltype,
153
+ type: d.type,
154
+ reqd: d.reqd,
155
+ list: d.list,
156
+ args: buildArgs(G, gfield.args),
157
+ deprecated: null != gfield.deprecationReason,
158
+ }
159
+ if (null != gfield.description && '' !== gfield.description) {
160
+ field.desc = gfield.description
161
+ }
162
+ return field
163
+ }
164
+
165
+
166
+ function fieldMap(G: any, gtype: any): Record<string, GqlField> {
167
+ const out: Record<string, GqlField> = {}
168
+ const gfields = gtype.getFields ? gtype.getFields() : {}
169
+ // Sorted: downstream output must be byte-stable.
170
+ for (const name of Object.keys(gfields).sort()) {
171
+ out[name] = buildField(G, gfields[name])
172
+ }
173
+ return out
174
+ }
175
+
176
+
177
+ function typeKind(G: any, gtype: any): string {
178
+ if (G.isObjectType(gtype)) return 'OBJECT'
179
+ if (G.isInputObjectType(gtype)) return 'INPUT_OBJECT'
180
+ if (G.isEnumType(gtype)) return 'ENUM'
181
+ if (G.isInterfaceType(gtype)) return 'INTERFACE'
182
+ if (G.isUnionType(gtype)) return 'UNION'
183
+ if (G.isScalarType(gtype)) return 'SCALAR'
184
+ return 'UNKNOWN'
185
+ }
186
+
187
+
188
+ function buildTypes(G: any, schema: any): Record<string, GqlType> {
189
+ const out: Record<string, GqlType> = {}
190
+ const typeMap = schema.getTypeMap()
191
+
192
+ for (const name of Object.keys(typeMap).sort()) {
193
+ // Introspection meta types (__Schema, __Type, ...) are not API surface.
194
+ if (name.startsWith('__')) {
195
+ continue
196
+ }
197
+
198
+ const gtype = typeMap[name]
199
+ const kind = typeKind(G, gtype)
200
+
201
+ const desc: GqlType = {
202
+ name,
203
+ kind,
204
+ fields: ('OBJECT' === kind || 'INTERFACE' === kind || 'INPUT_OBJECT' === kind) ?
205
+ fieldMap(G, gtype) : {},
206
+ }
207
+
208
+ if ('ENUM' === kind) {
209
+ desc.values = gtype.getValues().map((v: any) => v.name).sort()
210
+ }
211
+
212
+ if ('UNION' === kind) {
213
+ desc.possible = schema.getPossibleTypes(gtype).map((t: any) => t.name).sort()
214
+ }
215
+
216
+ if ('INTERFACE' === kind) {
217
+ desc.possible = schema.getPossibleTypes(gtype).map((t: any) => t.name).sort()
218
+ }
219
+
220
+ if ('OBJECT' === kind || 'INTERFACE' === kind) {
221
+ const ifaces = (gtype.getInterfaces ? gtype.getInterfaces() : [])
222
+ .map((t: any) => t.name).sort()
223
+ if (0 < ifaces.length) {
224
+ desc.interfaces = ifaces
225
+ }
226
+ }
227
+
228
+ if (null != gtype.description && '' !== gtype.description) {
229
+ desc.desc = gtype.description
230
+ }
231
+
232
+ out[name] = desc
233
+ }
234
+
235
+ return out
236
+ }
237
+
238
+
239
+ function rootFields(G: any, gtype: any): Record<string, GqlField> {
240
+ return null == gtype ? {} : fieldMap(G, gtype)
241
+ }
242
+
243
+
244
+ // Parse a GraphQL schema (SDL text or introspection JSON) into `def`.
245
+ //
246
+ // `opts.endpoint` is REQUIRED: a schema declares no deployment URL, but a
247
+ // usable SDK needs a base URL and transform/top.ts fails the build without
248
+ // `servers[0].url`.
249
+ async function parseGraphQL(
250
+ source: string,
251
+ meta: { file: string },
252
+ opts?: { endpoint?: string, title?: string, version?: string }
253
+ ): Promise<GqlDef> {
254
+ const G = loadGraphQL(meta)
255
+
256
+ const endpoint = opts?.endpoint
257
+ if (null == endpoint || '' === String(endpoint).trim()) {
258
+ throw new Error(
259
+ '@voxgig/apidef: parse: GraphQL: an endpoint option is required' +
260
+ ' (a GraphQL schema declares no server URL)' +
261
+ ` (${relativizePath(meta.file)})`
262
+ )
263
+ }
264
+
265
+ let schema: any
266
+ const introspection = asIntrospection(source)
267
+
268
+ if (null != introspection) {
269
+ schema = G.buildClientSchema(introspection)
270
+ }
271
+ else {
272
+ schema = G.buildSchema(source)
273
+ }
274
+
275
+ const def: GqlDef = {
276
+ graphql: true,
277
+ info: {
278
+ title: opts?.title ?? '',
279
+ version: opts?.version ?? '',
280
+ description: '',
281
+ },
282
+ servers: [{ url: endpoint }],
283
+ types: buildTypes(G, schema),
284
+ query: rootFields(G, schema.getQueryType()),
285
+ mutation: rootFields(G, schema.getMutationType()),
286
+ subscription: rootFields(G, schema.getSubscriptionType()),
287
+ }
288
+
289
+ return def
290
+ }
291
+
292
+
293
+ export {
294
+ parseGraphQL,
295
+ }
296
+
297
+ export type {
298
+ GqlDef,
299
+ GqlType,
300
+ GqlField,
301
+ GqlArg,
302
+ }
package/src/parse.ts CHANGED
@@ -5,6 +5,8 @@ import { Yaml } from '@tabnas/yaml'
5
5
 
6
6
  import { relativizePath } from './utility'
7
7
 
8
+ import { parseGraphQL } from './parse/graphql'
9
+
8
10
 
9
11
  // NOTE: @tabnas/yaml types its Plugin against @tabnas/parser, while
10
12
  // Jsonic.use expects @tabnas/jsonic's own (structurally identical) Plugin
@@ -46,6 +48,27 @@ async function parse(kind: string, source: any, meta: { file: string }) {
46
48
  throw pe
47
49
  }
48
50
  }
51
+ else if ('GraphQL' === kind) {
52
+
53
+ validateSource(kind, source, meta)
54
+
55
+ try {
56
+ const def = await parseGraphQL(source, meta, (meta as any).graphql)
57
+ return def
58
+ }
59
+ catch (pe: any) {
60
+ // Already-decorated errors (missing endpoint, missing package) carry
61
+ // the package prefix; only raw parser failures need wrapping.
62
+ if ('string' === typeof pe.message &&
63
+ !pe.message.startsWith('@voxgig/apidef:')) {
64
+ pe.message =
65
+ `@voxgig/apidef: parse: syntax: ${pe.message}` +
66
+ ` (${relativizePath(meta.file)})`
67
+ }
68
+
69
+ throw pe
70
+ }
71
+ }
49
72
  else {
50
73
  throw new Error(
51
74
  `@voxgig/apidef: parse: unknown kind: ${kind}` +
@@ -41,11 +41,35 @@ const argsTransform: Transform = async function(
41
41
  each(mop.points, (mpoint: ModelPoint) => {
42
42
  const argdefs: ParameterDef[] = []
43
43
 
44
- const pathdef: PathDef = def.paths[mpoint.orig]
45
- argdefs.push(...(pathdef.parameters ?? []))
46
-
47
- const opdef: MethodDef = (pathdef as any)[mpoint.method.toLowerCase()]
48
- argdefs.push(...(opdef?.parameters ?? []))
44
+ if ('graphql' === mpoint.kind) {
45
+ // GraphQL root-field arguments become 'param' args, so the existing
46
+ // arg machinery (select.exist matching, request typing, test
47
+ // generation) works on them unchanged. Input-object arguments are
48
+ // the request body and are bound as variables by the document
49
+ // renderer instead, so they are not surfaced as params here.
50
+ const fielddef: any = graphqlFieldDef(def, mpoint)
51
+ for (const arg of (fielddef?.args ?? [])) {
52
+ const argtype = def.types?.[arg.type]
53
+ if (null != argtype && 'INPUT_OBJECT' === argtype.kind) {
54
+ continue
55
+ }
56
+ argdefs.push({
57
+ name: arg.name,
58
+ in: 'path',
59
+ // A schema default makes a non-null argument omittable by the
60
+ // caller, so it is not required of the SDK caller either.
61
+ required: arg.reqd && undefined === arg.deflt,
62
+ schema: { type: gqlScalarType(arg.type) },
63
+ } as any)
64
+ }
65
+ }
66
+ else {
67
+ const pathdef: PathDef = def.paths[mpoint.orig]
68
+ argdefs.push(...(pathdef.parameters ?? []))
69
+
70
+ const opdef: MethodDef = (pathdef as any)[mpoint.method.toLowerCase()]
71
+ argdefs.push(...(opdef?.parameters ?? []))
72
+ }
49
73
 
50
74
  resolveArgs(ment, mop, mpoint, argdefs)
51
75
  })
@@ -59,6 +83,31 @@ const argsTransform: Transform = async function(
59
83
  }
60
84
 
61
85
 
86
+ // Locate the normalised root-field descriptor a GraphQL point came from.
87
+ function graphqlFieldDef(def: any, mpoint: ModelPoint): any {
88
+ const field = mpoint.graphql?.field ?? mpoint.orig
89
+ return 'mutation' === mpoint.graphql?.optype ?
90
+ def.mutation?.[field] : def.query?.[field]
91
+ }
92
+
93
+
94
+ // Map a GraphQL named type onto the JSON-schema-ish scalar names the
95
+ // existing arg/field typing understands.
96
+ //
97
+ // Only the built-in scalars have a known JSON shape. A custom scalar can be
98
+ // anything — JSON/JSONObject accept objects and arrays, DateTime is a
99
+ // string, Upload is a file handle — so anything unrecognised stays
100
+ // unconstrained rather than being wrongly advertised (and validated) as a
101
+ // string. ID and String are the two custom-free string cases.
102
+ function gqlScalarType(typeName: string): string | undefined {
103
+ return 'Int' === typeName ? 'integer' :
104
+ 'Float' === typeName ? 'number' :
105
+ 'Boolean' === typeName ? 'boolean' :
106
+ ('String' === typeName || 'ID' === typeName) ? 'string' :
107
+ undefined
108
+ }
109
+
110
+
62
111
  const ARG_KIND: Record<string, ModelArg["kind"]> = {
63
112
  'query': 'query',
64
113
  'header': 'header',
@@ -42,7 +42,10 @@ const entityTransform: Transform = async function(
42
42
  // ID. Move "/people" onto person here; this also clears the way for
43
43
  // sensible flow generation (one entity, one collection, multiple
44
44
  // sub-resources).
45
- mergeCollectionPaths(guide, ctx.log)
45
+ // Path-shaped collection merging is meaningless for root-field guides.
46
+ if (true !== ctx.def?.graphql) {
47
+ mergeCollectionPaths(guide, ctx.log)
48
+ }
46
49
 
47
50
  each(guide.entity, (guideEntity: GuideEntity, entname: string) => {
48
51
  // `active: false` in guide.aontu drops the entity. The guide model has
@@ -58,8 +61,18 @@ const entityTransform: Transform = async function(
58
61
 
59
62
  ctx.log.debug({ point: 'guide-entity', note: entname })
60
63
 
61
- const paths$ = resolvePathList(guideEntity, ctx.def)
62
- const relations = buildRelations(guideEntity, paths$)
64
+ const graphql = true === ctx.def?.graphql
65
+
66
+ const paths$ = graphql ?
67
+ resolveFieldList(guideEntity, ctx.def) :
68
+ resolvePathList(guideEntity, ctx.def)
69
+
70
+ // Ancestry is inferred from literal/{param} path pairs, which root
71
+ // fields do not have; GraphQL relations come from the schema instead
72
+ // (see transform/graphql.ts).
73
+ const relations = graphql ?
74
+ { ancestors: [] } :
75
+ buildRelations(guideEntity, paths$)
63
76
 
64
77
  const modelent: ModelEntity = {
65
78
  name: entname,
@@ -214,6 +227,53 @@ function resolvePathList(guideEntity: GuideEntity, def: { paths: Record<string,
214
227
  }
215
228
 
216
229
 
230
+ // Root-field equivalent of resolvePathList for GraphQL guides. A root field
231
+ // has no path to split, so `parts` stays empty (GraphQL points address the
232
+ // single endpoint and carry their operation document instead) and `def` is
233
+ // the normalised root-field descriptor rather than a path item.
234
+ function resolveFieldList(guideEntity: GuideEntity, def: any) {
235
+ const paths$: PathDesc[] = []
236
+
237
+ each((guideEntity as any).field, (guideField: GuidePath, orig: string) => {
238
+ if (!guideActive(guideField)) {
239
+ return
240
+ }
241
+
242
+ // The root field lives under query or mutation depending on the op type
243
+ // the guide recorded.
244
+ const optype = Object.values(guideField.op ?? {})
245
+ .map((o: any) => o.optype)
246
+ .find((t: any) => null != t) ?? 'query'
247
+
248
+ const fielddef = 'mutation' === optype ?
249
+ def.mutation?.[orig] : def.query?.[orig]
250
+
251
+ // The guide expresses GraphQL renames as `rename: arg:` (root fields
252
+ // have arguments, not path params), while the model's arg machinery
253
+ // reads `rename.param`. Translate so a user override actually applies.
254
+ const grename: any = guideField.rename ?? {}
255
+ const rename: any = null != grename.arg ?
256
+ { ...grename, param: { ...(grename.param ?? {}), ...grename.arg } } :
257
+ grename
258
+
259
+ const pathdesc: PathDesc = {
260
+ orig,
261
+ parts: [],
262
+ rename,
263
+ method: '', // operation collectOps will copy and assign per op
264
+ op: guideField.op,
265
+ def: fielddef,
266
+ }
267
+
268
+ paths$.push(pathdesc)
269
+ })
270
+
271
+ ; (guideEntity as any).paths$ = paths$
272
+
273
+ return paths$
274
+ }
275
+
276
+
217
277
 
218
278
  function buildRelations(guideEntity: any, paths$: PathDesc[]) {
219
279
  // An ancestor is a literal collection segment (e.g. "rems") followed by
@@ -4,7 +4,7 @@ import { each, getx } from 'jostraca'
4
4
 
5
5
  import type { TransformResult, Transform } from '../transform'
6
6
 
7
- import { validator, canonize, inferFieldType, normalizeFieldName, envelopeProp } from '../utility'
7
+ import { validator, canonizeField, inferFieldType, normalizeFieldName, envelopeProp } from '../utility'
8
8
 
9
9
  import { KIT } from '../types'
10
10
 
@@ -93,7 +93,10 @@ function resolveOpFields(
93
93
 
94
94
  for (let fielddef of fielddefs) {
95
95
  const fieldname = (fielddef as any).key$ as string
96
- const name = canonize(normalizeFieldName(fieldname))
96
+ // Field names are WIRE identifiers — see canonizeField. Using the
97
+ // entity-name canonizer here renamed modelType -> model_type and
98
+ // items -> item, so the SDK read keys the server never sends.
99
+ const name = canonizeField(normalizeFieldName(fieldname))
97
100
  const mfield: ModelField = {
98
101
  name,
99
102
  type: inferFieldType(name, validator(fielddef.type)),
@@ -107,12 +110,96 @@ function resolveOpFields(
107
110
  }
108
111
 
109
112
 
113
+ // GraphQL entity fields come straight from the object type: every
114
+ // non-deprecated scalar field, minus any that require arguments (selecting
115
+ // `download(format: Format!)` without binding its argument makes every
116
+ // operation using the fragment fail GraphQL validation), plus one id-stub
117
+ // reference per to-one relation.
118
+ function findGraphqlFieldDefs(
119
+ ment: ModelEntity,
120
+ mpoint: ModelPoint,
121
+ def: any
122
+ ): SchemaDef[] {
123
+ const typeName = (mpoint.graphql as any)?.entityType$ ??
124
+ (ment as any).orig$ ?? ''
125
+ const gtype = def.types?.[typeName]
126
+
127
+ if (null == gtype) {
128
+ return []
129
+ }
130
+
131
+ const out: SchemaDef[] = []
132
+
133
+ // Sorted by construction in parse/graphql.ts, so output stays byte-stable.
134
+ for (const fname of Object.keys(gtype.fields)) {
135
+ const f = gtype.fields[fname]
136
+
137
+ if (f.deprecated) {
138
+ continue
139
+ }
140
+
141
+ // A field taking required arguments cannot appear in a fixed fragment.
142
+ if (f.args.some((a: any) => a.reqd)) {
143
+ continue
144
+ }
145
+
146
+ const ftype = def.types?.[f.type]
147
+ const kind = ftype?.kind
148
+
149
+ if ('SCALAR' === kind || 'ENUM' === kind) {
150
+ out.push({
151
+ key$: fname,
152
+ // Enum values are always strings; scalars map by name, with unknown
153
+ // custom scalars left unconstrained.
154
+ type: 'ENUM' === kind ? 'string' : gqlFieldType(f.type),
155
+ required: f.reqd,
156
+ } as any)
157
+ }
158
+ else if (('OBJECT' === kind || 'INTERFACE' === kind) && !f.list) {
159
+ // To-one relation. The default fragment selects `team { id }`, so the
160
+ // response carries a nested stub object — declare it as such. Naming a
161
+ // flat `team_id` here would advertise a field the wire never returns,
162
+ // since nothing flattens the response.
163
+ const idField = ftype.fields?.id
164
+ if (null != idField) {
165
+ out.push({
166
+ key$: fname,
167
+ type: 'object',
168
+ required: false,
169
+ } as any)
170
+ }
171
+ }
172
+ }
173
+
174
+ return out
175
+ }
176
+
177
+
178
+ // GraphQL named type -> the type names the field typing understands.
179
+ //
180
+ // Built-ins only: a custom scalar (JSON, JSONObject, Upload, ...) can hold
181
+ // any JSON value, so advertising it as a string would misdescribe the data
182
+ // and make generated validation reject values the schema accepts. Enums are
183
+ // mapped by the caller, which knows they are strings.
184
+ function gqlFieldType(typeName: string): string | undefined {
185
+ return 'Int' === typeName ? 'integer' :
186
+ 'Float' === typeName ? 'number' :
187
+ 'Boolean' === typeName ? 'boolean' :
188
+ ('String' === typeName || 'ID' === typeName) ? 'string' :
189
+ undefined
190
+ }
191
+
192
+
110
193
  function findFieldDefs(
111
194
  _ment: ModelEntity,
112
195
  mop: ModelOp,
113
196
  mpoint: ModelPoint,
114
197
  def: any
115
198
  ): SchemaDef[] {
199
+ if ('graphql' === mpoint.kind) {
200
+ return findGraphqlFieldDefs(_ment, mpoint, def)
201
+ }
202
+
116
203
  const fielddefs: SchemaDef[] = []
117
204
  const pathdef = def.paths[mpoint.orig]
118
205