@voxgig/apidef 8.11.0 → 8.12.1
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/dist/apidef.d.ts +4 -1
- package/dist/apidef.js +12 -34
- package/dist/apidef.js.map +1 -1
- package/dist/builder/entity/entity.js +0 -33
- package/dist/builder/entity/entity.js.map +1 -1
- package/dist/builder/entity/info.js +0 -4
- package/dist/builder/entity/info.js.map +1 -1
- package/dist/builder/flow.d.ts +4 -1
- package/dist/builder/flow.js +36 -27
- package/dist/builder/flow.js.map +1 -1
- package/dist/guide/graphql01.js +0 -55
- package/dist/guide/graphql01.js.map +1 -1
- package/dist/guide/guide.js +3 -126
- package/dist/guide/guide.js.map +1 -1
- package/dist/guide/heuristic01.js +40 -181
- package/dist/guide/heuristic01.js.map +1 -1
- package/dist/parse/graphql.js +0 -25
- package/dist/parse/graphql.js.map +1 -1
- package/dist/parse.js +51 -56
- package/dist/parse.js.map +1 -1
- package/dist/resolved.d.ts +23 -0
- package/dist/resolved.js +92 -0
- package/dist/resolved.js.map +1 -0
- package/dist/resolver.js +0 -2
- package/dist/resolver.js.map +1 -1
- package/dist/transform/args.js +2 -25
- package/dist/transform/args.js.map +1 -1
- package/dist/transform/casecollide.d.ts +3 -0
- package/dist/transform/casecollide.js +67 -0
- package/dist/transform/casecollide.js.map +1 -0
- package/dist/transform/contract.js +43 -38
- package/dist/transform/contract.js.map +1 -1
- package/dist/transform/entity.js +0 -56
- package/dist/transform/entity.js.map +1 -1
- package/dist/transform/field.js +1 -368
- package/dist/transform/field.js.map +1 -1
- package/dist/transform/flowstep.js +0 -49
- package/dist/transform/flowstep.js.map +1 -1
- package/dist/transform/graphql.js +0 -36
- package/dist/transform/graphql.js.map +1 -1
- package/dist/transform/operation.js +0 -23
- package/dist/transform/operation.js.map +1 -1
- package/dist/transform/top.js +1 -128
- package/dist/transform/top.js.map +1 -1
- package/dist/transform.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/types.d.ts +2 -0
- package/dist/types.js.map +1 -1
- package/dist/utility.d.ts +2 -1
- package/dist/utility.js +17 -316
- package/dist/utility.js.map +1 -1
- package/model/apidef.aon +20 -113
- package/model/guide.aon +1 -32
- package/package.json +5 -5
- package/src/apidef.ts +22 -33
- package/src/builder/entity/entity.ts +0 -33
- package/src/builder/entity/info.ts +0 -4
- package/src/builder/flow.ts +39 -24
- package/src/desc.ts +0 -22
- package/src/guide/graphql01.ts +0 -75
- package/src/guide/guide.ts +5 -127
- package/src/guide/heuristic01.ts +47 -182
- package/src/model.ts +6 -85
- package/src/parse/graphql.ts +0 -25
- package/src/parse.ts +61 -56
- package/src/resolved.ts +136 -0
- package/src/resolver.ts +0 -2
- package/src/transform/args.ts +2 -25
- package/src/transform/casecollide.ts +80 -0
- package/src/transform/contract.ts +44 -34
- package/src/transform/entity.ts +0 -56
- package/src/transform/field.ts +2 -368
- package/src/transform/flowstep.ts +0 -49
- package/src/transform/graphql.ts +0 -36
- package/src/transform/operation.ts +0 -23
- package/src/transform/top.ts +1 -131
- package/src/transform.ts +0 -1
- package/src/types.ts +4 -13
- package/src/utility.ts +18 -314
package/src/apidef.ts
CHANGED
|
@@ -75,10 +75,17 @@ import { graphqlTransform } from './transform/graphql'
|
|
|
75
75
|
import { argsTransform } from './transform/args'
|
|
76
76
|
import { selectTransform } from './transform/select'
|
|
77
77
|
import { fieldTransform } from './transform/field'
|
|
78
|
+
import { casecollideTransform } from './transform/casecollide'
|
|
78
79
|
import { flowTransform } from './transform/flow'
|
|
79
80
|
import { flowstepTransform } from './transform/flowstep'
|
|
80
81
|
import { cleanTransform } from './transform/clean'
|
|
81
82
|
|
|
83
|
+
import {
|
|
84
|
+
publishResolved, operationFacts, operationIndex, resolvedSpec,
|
|
85
|
+
} from './resolved'
|
|
86
|
+
|
|
87
|
+
import type { OperationFacts, ResolvedSpec } from './resolved'
|
|
88
|
+
|
|
82
89
|
import { makeEntityBuilder } from './builder/entity'
|
|
83
90
|
import { gcEntityFiles } from './builder/entity/entity'
|
|
84
91
|
import { makeFlowBuilder } from './builder/flow'
|
|
@@ -87,18 +94,6 @@ import { makeFlowBuilder } from './builder/flow'
|
|
|
87
94
|
const dlog = getdlog('apidef', __filename)
|
|
88
95
|
|
|
89
96
|
|
|
90
|
-
// THE WARNINGS FILE IS A REVIEWABLE ARTIFACT, so it carries no clock.
|
|
91
|
-
//
|
|
92
|
-
// Every warning is stamped with `when: Date.now()` — useful in a live log,
|
|
93
|
-
// and fatal in a file that consumers COMMIT. An SDK project regenerates and
|
|
94
|
-
// commits `.sdk/apidef-warnings.txt`, and its CI asserts that a regeneration
|
|
95
|
-
// reproduces the committed tree byte for byte; with a timestamp in it that
|
|
96
|
-
// check can never pass, and every regeneration produces a diff saying
|
|
97
|
-
// nothing about the warnings themselves. github-sdk failed exactly that way:
|
|
98
|
-
// one file, three changed lines, all of them clocks.
|
|
99
|
-
//
|
|
100
|
-
// The timestamp stays on the in-memory history, where a caller streaming
|
|
101
|
-
// warnings still wants it.
|
|
102
97
|
function warningsFileText(history: any[]): string {
|
|
103
98
|
return history
|
|
104
99
|
.map((n: any) => {
|
|
@@ -111,7 +106,6 @@ function warningsFileText(history: any[]): string {
|
|
|
111
106
|
|
|
112
107
|
function ApiDef(opts: ApiDefOptions) {
|
|
113
108
|
|
|
114
|
-
// TODO: shape opts!
|
|
115
109
|
const fs = opts.fs || Fs
|
|
116
110
|
const pino = prettyPino('apidef', opts)
|
|
117
111
|
const log = pino.child({ cmp: 'apidef' })
|
|
@@ -173,7 +167,6 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
173
167
|
start
|
|
174
168
|
})
|
|
175
169
|
|
|
176
|
-
// TODO: Validate spec
|
|
177
170
|
ctx = {
|
|
178
171
|
fs,
|
|
179
172
|
fsInjected: null != opts.fs,
|
|
@@ -189,7 +182,6 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
189
182
|
note: {},
|
|
190
183
|
warn,
|
|
191
184
|
|
|
192
|
-
// TODO: remove (moved to guide)
|
|
193
185
|
metrics: {
|
|
194
186
|
count: {
|
|
195
187
|
path: 0,
|
|
@@ -235,6 +227,7 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
235
227
|
}
|
|
236
228
|
|
|
237
229
|
ctx.def = def
|
|
230
|
+
ctx.resolved = publishResolved(spec.buildctx, spec.config?.kind ?? "openapi3", def)
|
|
238
231
|
|
|
239
232
|
steps.push('parse')
|
|
240
233
|
|
|
@@ -276,6 +269,8 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
276
269
|
await argsTransform(ctx)
|
|
277
270
|
await selectTransform(ctx)
|
|
278
271
|
await fieldTransform(ctx)
|
|
272
|
+
// Before flowTransform, so no flow is built for an entity it drops.
|
|
273
|
+
await casecollideTransform(ctx)
|
|
279
274
|
await flowTransform(ctx)
|
|
280
275
|
await flowstepTransform(ctx)
|
|
281
276
|
await cleanTransform(ctx)
|
|
@@ -293,7 +288,6 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
293
288
|
const builders = [
|
|
294
289
|
await makeEntityBuilder(ctx),
|
|
295
290
|
|
|
296
|
-
// TODO: move to sdkgen
|
|
297
291
|
await makeFlowBuilder(ctx),
|
|
298
292
|
]
|
|
299
293
|
|
|
@@ -326,11 +320,6 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
326
320
|
// folder: Path.dirname(opts.folder as string),
|
|
327
321
|
folder: opts.folder,
|
|
328
322
|
model: jmodel,
|
|
329
|
-
// Overwrite the generated model source (.aontu) rather than 3-way merge:
|
|
330
|
-
// merging against a drifting .jostraca base silently keeps stale files
|
|
331
|
-
// and can inject <<<<<<< conflict markers. Generated output is
|
|
332
|
-
// model-derived and never hand-edited. See sdkgen
|
|
333
|
-
// docs/explanation/regeneration-overwrite.md.
|
|
334
323
|
existing: { txt: { write: true, merge: false } }
|
|
335
324
|
}, root)
|
|
336
325
|
|
|
@@ -343,11 +332,6 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
343
332
|
|
|
344
333
|
steps.push('generate')
|
|
345
334
|
|
|
346
|
-
// Garbage-collect entity model files no longer derived from the def.
|
|
347
|
-
// The builders only ever WRITE: a spec change that removes or renames a
|
|
348
|
-
// derived entity used to leave the old <name>.aontu behind forever.
|
|
349
|
-
// Runs after generate so the current set is on disk; guarded so only
|
|
350
|
-
// apidef-generated files under this build's outprefix are touched.
|
|
351
335
|
try {
|
|
352
336
|
const kitEntity = (ctx.apimodel?.main as any)?.[KIT]?.entity || {}
|
|
353
337
|
gcEntityFiles(fs, log, opts.folder as string, opts.outprefix,
|
|
@@ -368,12 +352,6 @@ function ApiDef(opts: ApiDefOptions) {
|
|
|
368
352
|
warningsFileText(warn.history))
|
|
369
353
|
}
|
|
370
354
|
|
|
371
|
-
// apidef writes model source files (entity, flow, guide aontu files) into
|
|
372
|
-
// .sdk/model/. Downstream actions (sdkgen, etc.) read those via
|
|
373
|
-
// sdk.aontu @-includes, so voxgig-model has to re-resolve the model
|
|
374
|
-
// before the post-step actions run. Signal reload whenever jostraca
|
|
375
|
-
// wrote or merged any files; if nothing changed on disk,
|
|
376
|
-
// voxgig-model's resolveModel cache short-circuits the re-read.
|
|
377
355
|
const jfiles = jres?.files
|
|
378
356
|
const reload = !!jfiles && (
|
|
379
357
|
(jfiles.written?.length ?? 0) > 0 ||
|
|
@@ -486,7 +464,9 @@ ApiDef.makeBuild = async function(opts: ApiDefOptions) {
|
|
|
486
464
|
|
|
487
465
|
const ctrl = build.spec.buildargs?.apidef?.ctrl || {}
|
|
488
466
|
|
|
489
|
-
|
|
467
|
+
// `buildctx` is the model build's own context, shared across its pre and
|
|
468
|
+
// post steps, and is where the resolved definition is published.
|
|
469
|
+
return await apidef.generate({ model, build, config, ctrl, buildctx: _ctx })
|
|
490
470
|
}
|
|
491
471
|
|
|
492
472
|
build.step = 'pre'
|
|
@@ -558,4 +538,13 @@ export {
|
|
|
558
538
|
nom,
|
|
559
539
|
VALID_CANON,
|
|
560
540
|
CANON_ONE,
|
|
541
|
+
|
|
542
|
+
operationFacts,
|
|
543
|
+
operationIndex,
|
|
544
|
+
resolvedSpec,
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
export type {
|
|
548
|
+
OperationFacts,
|
|
549
|
+
ResolvedSpec,
|
|
561
550
|
}
|
|
@@ -49,9 +49,6 @@ function resolveEntity(
|
|
|
49
49
|
|
|
50
50
|
entityFiles.push({ name: entityFile, src: entitySrc })
|
|
51
51
|
|
|
52
|
-
// `./` — aontu 0.65 reads a bare single-segment include as a PACKAGE
|
|
53
|
-
// name (ADR-039), so `@"account.aon"` now resolves against the package
|
|
54
|
-
// stores and refuses. A sibling file has to say it is one.
|
|
55
52
|
barrel.push(`@"./${Path.basename(entityFile)}"`)
|
|
56
53
|
}))
|
|
57
54
|
|
|
@@ -69,23 +66,6 @@ function resolveEntity(
|
|
|
69
66
|
}
|
|
70
67
|
|
|
71
68
|
|
|
72
|
-
// Garbage-collect orphaned entity model files.
|
|
73
|
-
//
|
|
74
|
-
// The builder above EMITS one <outprefix><name>.aon per derived entity but
|
|
75
|
-
// never removes anything, so an entity that disappears from the def — a spec
|
|
76
|
-
// rename, a dropped path, a schema rename that changes the derived entity
|
|
77
|
-
// name — leaves its old file behind on every regen. The orphan is not in the
|
|
78
|
-
// regenerated entity-index barrel, so it is silently dead weight at best; at
|
|
79
|
-
// worst a later hand-include resurrects a stale surface.
|
|
80
|
-
//
|
|
81
|
-
// Deletion is guarded three ways, so nothing a user could own is touched:
|
|
82
|
-
// 1. only `<outprefix>*.aon` / `*.aontu` files in the entity folder
|
|
83
|
-
// (a different outprefix belongs to a different def sharing the folder);
|
|
84
|
-
// 2. the current entity set and the index barrel are always kept;
|
|
85
|
-
// 3. the file must START with the generated header (`# Entity: `) — a file
|
|
86
|
-
// apidef did not write is left alone.
|
|
87
|
-
//
|
|
88
|
-
// GC failure must never fail a build: errors are logged and swallowed.
|
|
89
69
|
function gcEntityFiles(
|
|
90
70
|
fs: any,
|
|
91
71
|
log: any,
|
|
@@ -110,11 +90,6 @@ function gcEntityFiles(
|
|
|
110
90
|
}
|
|
111
91
|
|
|
112
92
|
for (const entry of entries) {
|
|
113
|
-
// BOTH extensions are candidates. `.aon` is what the builder emits
|
|
114
|
-
// now; `.aontu` is what it emitted before the rename, and such files
|
|
115
|
-
// are orphaned by definition — the regenerated index barrel no longer
|
|
116
|
-
// includes them. The `# Entity: ` header guard below still applies, so
|
|
117
|
-
// only a file apidef itself wrote is ever removed.
|
|
118
93
|
if (!entry.endsWith('.aon') && !entry.endsWith('.aontu')) { continue }
|
|
119
94
|
if (!entry.startsWith(prefix)) { continue }
|
|
120
95
|
if (keep.has(entry)) { continue }
|
|
@@ -143,14 +118,6 @@ function gcEntityFiles(
|
|
|
143
118
|
|
|
144
119
|
|
|
145
120
|
function fieldAliases(_entity: any): string {
|
|
146
|
-
// Field aliasing (mapping e.g. a `<name>_id` field onto the canonical
|
|
147
|
-
// `id`) is not currently implemented. The original heuristic referenced
|
|
148
|
-
// properties that don't exist on the entity at this stage
|
|
149
|
-
// (`entity.field`, `op.param`, `p.keys` — entities carry `fields`, ops
|
|
150
|
-
// carry `points`, and `each` stamps `key$`), so it always produced `{}`
|
|
151
|
-
// and would have thrown if any branch ran. Emit an empty alias map
|
|
152
|
-
// explicitly until the alias semantics are specified.
|
|
153
|
-
// Parity: go/builder.go buildFieldAliases (also `{}`).
|
|
154
121
|
return '{}'
|
|
155
122
|
}
|
|
156
123
|
|
|
@@ -33,10 +33,6 @@ function resolveInfo(
|
|
|
33
33
|
|
|
34
34
|
const modelInfo = { main: { kit: { info: kit.info } } }
|
|
35
35
|
|
|
36
|
-
// .trim() first so substring(1, len-1) strips the wrapping `{` and `}`.
|
|
37
|
-
// Without it, formatJSONIC's trailing newline is removed instead of the
|
|
38
|
-
// closing brace, leaving a dangling `}` in the output. Mirrors the sibling
|
|
39
|
-
// entity builder (builder/entity/entity.ts), which trims for this reason.
|
|
40
36
|
let modelDefSrc = formatJSONIC(modelInfo).trim()
|
|
41
37
|
|
|
42
38
|
modelDefSrc =
|
package/src/builder/flow.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
|
|
2
|
-
// TODO: move this to sdkgen
|
|
3
2
|
|
|
4
3
|
|
|
5
4
|
import Path from 'node:path'
|
|
@@ -34,30 +33,20 @@ async function makeFlowBuilder(ctx: ApiDefContext): Promise<Function> {
|
|
|
34
33
|
})
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
err.foo.z = err.foo
|
|
36
|
+
|
|
37
|
+
const flownames: string[] = []
|
|
38
|
+
each(flows, (flow: any) => flownames.push(String(flow.name)))
|
|
39
|
+
const filebase = flowFileBases(flownames)
|
|
40
|
+
|
|
41
|
+
for (const name of flownames) {
|
|
42
|
+
if (name !== filebase[name] && ctx.warn) {
|
|
45
43
|
ctx.warn({
|
|
46
44
|
step: 'flow',
|
|
47
|
-
note: '
|
|
48
|
-
|
|
45
|
+
note: 'flow name ' + name + ' collides with another when case is' +
|
|
46
|
+
' ignored: file written as ' + filebase[name] + '.aon'
|
|
49
47
|
})
|
|
50
|
-
return flowBuilder
|
|
51
48
|
}
|
|
52
49
|
}
|
|
53
|
-
else {
|
|
54
|
-
ctx.warn({
|
|
55
|
-
step: 'flow',
|
|
56
|
-
note: 'Unable to resolve flows: unknown guide strategy: ' + ctx.opts.strategy
|
|
57
|
-
})
|
|
58
|
-
return flowBuilder
|
|
59
|
-
}
|
|
60
|
-
*/
|
|
61
50
|
|
|
62
51
|
flowBuilder = () => {
|
|
63
52
|
|
|
@@ -70,7 +59,7 @@ async function makeFlowBuilder(ctx: ApiDefContext): Promise<Function> {
|
|
|
70
59
|
let flowfile =
|
|
71
60
|
Path.join(ctx.opts.folder, 'flow',
|
|
72
61
|
(null == ctx.opts.outprefix ? '' : ctx.opts.outprefix) +
|
|
73
|
-
flow.name + '.aon')
|
|
62
|
+
(filebase[flow.name] || flow.name) + '.aon')
|
|
74
63
|
|
|
75
64
|
let flowModelSrc = formatJsonSrc(JSON.stringify(flow, null, 2))
|
|
76
65
|
|
|
@@ -79,8 +68,6 @@ async function makeFlowBuilder(ctx: ApiDefContext): Promise<Function> {
|
|
|
79
68
|
main: ${KIT}: flow: ${flow.name}:
|
|
80
69
|
` + flowModelSrc
|
|
81
70
|
|
|
82
|
-
// `./` — see the entity barrel: aontu 0.65 reads a bare
|
|
83
|
-
// single-segment include as a package name and refuses it.
|
|
84
71
|
barrel.push(`@"./${Path.basename(flowfile)}"`)
|
|
85
72
|
|
|
86
73
|
File({ name: Path.basename(flowfile) }, () => Content(flowsrc))
|
|
@@ -100,5 +87,33 @@ main: ${KIT}: flow: ${flow.name}:
|
|
|
100
87
|
|
|
101
88
|
|
|
102
89
|
export {
|
|
103
|
-
makeFlowBuilder
|
|
90
|
+
makeFlowBuilder,
|
|
91
|
+
flowFileBases,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
// Flow file base names, unique when case is ignored. Every member of a
|
|
96
|
+
// colliding group is suffixed in sorted-name order, so no member keeps the
|
|
97
|
+
// bare name and the result is the same on every run. Names that do not
|
|
98
|
+
// collide are returned unchanged.
|
|
99
|
+
function flowFileBases(names: string[]): { [name: string]: string } {
|
|
100
|
+
const bylower: { [lower: string]: string[] } = {}
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
const lower = name.toLowerCase()
|
|
103
|
+
bylower[lower] = bylower[lower] || []
|
|
104
|
+
bylower[lower].push(name)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const base: { [name: string]: string } = {}
|
|
108
|
+
for (const lower of Object.keys(bylower)) {
|
|
109
|
+
const group = bylower[lower].sort()
|
|
110
|
+
if (1 === group.length) {
|
|
111
|
+
base[group[0]] = group[0]
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
group.forEach((name, i) => (base[name] = name + '__' + (i + 1)))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return base
|
|
104
119
|
}
|
package/src/desc.ts
CHANGED
|
@@ -8,22 +8,10 @@ import type { ParameterDef } from './def'
|
|
|
8
8
|
|
|
9
9
|
|
|
10
10
|
|
|
11
|
-
// // Intermediate Guide types used during analysis
|
|
12
|
-
// type GuidePathRename = {
|
|
13
|
-
// param?: Record<string, string>
|
|
14
|
-
// }
|
|
15
11
|
|
|
16
|
-
// type GuideOp = {
|
|
17
|
-
// method: MethodName
|
|
18
|
-
// }
|
|
19
12
|
|
|
20
|
-
// type GuidePath = {
|
|
21
|
-
// rename?: GuidePathRename
|
|
22
|
-
// op?: Record<string, GuideOp>
|
|
23
|
-
// }
|
|
24
13
|
|
|
25
14
|
|
|
26
|
-
// Component analysis description
|
|
27
15
|
type CmpDesc = {
|
|
28
16
|
namedesc?: any,
|
|
29
17
|
path_rate: number,
|
|
@@ -52,7 +40,6 @@ type MethodDesc = {
|
|
|
52
40
|
}
|
|
53
41
|
|
|
54
42
|
|
|
55
|
-
// Method entity relationship analysis
|
|
56
43
|
type MethodEntityDesc = {
|
|
57
44
|
ref: string
|
|
58
45
|
|
|
@@ -79,7 +66,6 @@ type MethodEntityDesc = {
|
|
|
79
66
|
}
|
|
80
67
|
|
|
81
68
|
|
|
82
|
-
// Entity analysis description
|
|
83
69
|
type EntityDesc = {
|
|
84
70
|
name: string
|
|
85
71
|
origname: string
|
|
@@ -90,7 +76,6 @@ type EntityDesc = {
|
|
|
90
76
|
}
|
|
91
77
|
|
|
92
78
|
|
|
93
|
-
// Entity path relationship analysis
|
|
94
79
|
type EntityPathDesc = {
|
|
95
80
|
op: Record<string, any>
|
|
96
81
|
pm: PathMatch
|
|
@@ -123,16 +108,13 @@ type PathSegment = {
|
|
|
123
108
|
}
|
|
124
109
|
|
|
125
110
|
|
|
126
|
-
// Path analysis description
|
|
127
111
|
type PathDesc = {
|
|
128
112
|
orig: string
|
|
129
113
|
method: MethodName
|
|
130
114
|
segments: PathSegment[]
|
|
131
|
-
// rename: GuidePathRename
|
|
132
115
|
rename: {
|
|
133
116
|
param?: Record<string, any>
|
|
134
117
|
}
|
|
135
|
-
// op: GuidePath["op"]
|
|
136
118
|
op: Record<string, {
|
|
137
119
|
method: any
|
|
138
120
|
transform: {
|
|
@@ -149,16 +131,12 @@ type PathDesc = {
|
|
|
149
131
|
}
|
|
150
132
|
|
|
151
133
|
|
|
152
|
-
// Operation analysis description
|
|
153
134
|
type OpDesc = {
|
|
154
135
|
paths: PathDesc[]
|
|
155
136
|
}
|
|
156
137
|
|
|
157
138
|
|
|
158
139
|
export type {
|
|
159
|
-
// GuidePathRename,
|
|
160
|
-
// GuideOp,
|
|
161
|
-
// GuidePath,
|
|
162
140
|
CmpDesc,
|
|
163
141
|
BasicMethodDesc,
|
|
164
142
|
MethodDesc,
|
package/src/guide/graphql01.ts
CHANGED
|
@@ -1,22 +1,5 @@
|
|
|
1
1
|
/* Copyright (c) 2024-2026 Voxgig, MIT License */
|
|
2
2
|
|
|
3
|
-
// GraphQL guide strategy: classify schema root fields into entities and
|
|
4
|
-
// operations, the way heuristic01 classifies REST paths.
|
|
5
|
-
//
|
|
6
|
-
// Classification is SHAPE FIRST, NAME SECOND. Verb spellings diverge wildly
|
|
7
|
-
// between GraphQL ecosystems (Hasura `insert_x_one`, Amplify `createTodo`,
|
|
8
|
-
// Linear `issueCreate`, PostGraphile `createUser` — and PostGraphile's
|
|
9
|
-
// inflector plugin can change them wholesale), but the type shapes do not:
|
|
10
|
-
// a query returning the entity type behind a single required id argument is
|
|
11
|
-
// a load in every one of them.
|
|
12
|
-
//
|
|
13
|
-
// Anything on Mutation that touches an entity but matches no CRUD shape
|
|
14
|
-
// becomes an ACTION on a canonical op, reaching the SDK as an
|
|
15
|
-
// `$action`-discriminated point — the same mechanism REST action paths
|
|
16
|
-
// (/planet/{id}/terraform) already use. Nothing is ever dropped silently.
|
|
17
|
-
//
|
|
18
|
-
// `classifyGraphQLField` is a pure function of plain JSON, so it is driven
|
|
19
|
-
// by a shared TSV fixture and can be ported to Go unchanged.
|
|
20
3
|
|
|
21
4
|
import { each } from 'jostraca'
|
|
22
5
|
|
|
@@ -49,9 +32,7 @@ type GqlRetShape = {
|
|
|
49
32
|
entity?: string
|
|
50
33
|
// Connection only: the field holding the node array ('nodes' | 'edges').
|
|
51
34
|
nodes?: string
|
|
52
|
-
// Payload only: the field the entity is wrapped in, if any.
|
|
53
35
|
unwrap?: string
|
|
54
|
-
// Payload only: a delete-ish payload carries no entity.
|
|
55
36
|
deleteish?: boolean
|
|
56
37
|
}
|
|
57
38
|
|
|
@@ -63,7 +44,6 @@ type GqlArgSig = {
|
|
|
63
44
|
}
|
|
64
45
|
|
|
65
46
|
|
|
66
|
-
// Everything the classifier needs about one root field.
|
|
67
47
|
type GqlFieldSig = {
|
|
68
48
|
optype: 'query' | 'mutation'
|
|
69
49
|
name: string
|
|
@@ -101,8 +81,6 @@ const MACHINERY_RE =
|
|
|
101
81
|
/(Connection|Edge|PageInfo|Payload|Input|Filter|Comparator|Sort|OrderBy)$/
|
|
102
82
|
|
|
103
83
|
|
|
104
|
-
// Strip a leading entity name off a mutation field name, returning the
|
|
105
|
-
// residual verb. `issueCreate` -> `Create`; `createIssue` -> `create`.
|
|
106
84
|
function splitEntityVerb(fieldName: string, entity: string): string {
|
|
107
85
|
const lowerField = fieldName.toLowerCase()
|
|
108
86
|
const lowerEnt = entity.toLowerCase()
|
|
@@ -117,14 +95,6 @@ function splitEntityVerb(fieldName: string, entity: string): string {
|
|
|
117
95
|
}
|
|
118
96
|
|
|
119
97
|
|
|
120
|
-
// Does this field take a required id-ish argument?
|
|
121
|
-
//
|
|
122
|
-
// Any required id counts, not just a lone one: a command like
|
|
123
|
-
// `planetForbid(id: String!, forbid: Boolean!)` addresses an existing record
|
|
124
|
-
// just as much as `planetArchive(id: String!)` does. Requiring it to be the
|
|
125
|
-
// only required argument made an operation stop looking id-addressed the
|
|
126
|
-
// moment the API made a second argument mandatory, which flipped it from an
|
|
127
|
-
// update action to a create.
|
|
128
98
|
function idArg(args: GqlArgSig[]): GqlArgSig | undefined {
|
|
129
99
|
return args.find(
|
|
130
100
|
(a: GqlArgSig) => a.reqd && /^(id|.*Id)$/i.test(a.name))
|
|
@@ -165,13 +135,11 @@ function classifyGraphQLField(
|
|
|
165
135
|
const id = idArg(sig.args)
|
|
166
136
|
|
|
167
137
|
if ('query' === sig.optype) {
|
|
168
|
-
// Connection or list of the entity -> list.
|
|
169
138
|
if ('connection' === ret.kind || 'list' === ret.kind) {
|
|
170
139
|
why.push('query:' + ret.kind)
|
|
171
140
|
return { entity, op: 'list', optype: 'query', why }
|
|
172
141
|
}
|
|
173
142
|
|
|
174
|
-
// Single entity behind one required id -> load.
|
|
175
143
|
if ('entity' === ret.kind) {
|
|
176
144
|
if (null != id) {
|
|
177
145
|
why.push('query:entity:id=' + id.name)
|
|
@@ -193,7 +161,6 @@ function classifyGraphQLField(
|
|
|
193
161
|
return { exclude: true, why }
|
|
194
162
|
}
|
|
195
163
|
|
|
196
|
-
// --- mutation ---
|
|
197
164
|
const verb = splitEntityVerb(sig.name, entity).replace(/^[_-]+/, '')
|
|
198
165
|
const input = sig.inputTypeName ?? ''
|
|
199
166
|
|
|
@@ -204,14 +171,12 @@ function classifyGraphQLField(
|
|
|
204
171
|
return { entity, op: 'create', optype: 'mutation', why }
|
|
205
172
|
}
|
|
206
173
|
|
|
207
|
-
// update: <Entity>UpdateInput, or an update-ish verb.
|
|
208
174
|
const updateByInput = new RegExp('^' + entity + '(Update|Edit|Patch|Set)Input$', 'i').test(input)
|
|
209
175
|
if (updateByInput || UPDATE_RE.test(verb)) {
|
|
210
176
|
why.push(updateByInput ? 'mutation:input:' + input : 'mutation:verb:' + verb)
|
|
211
177
|
return { entity, op: 'update', optype: 'mutation', why }
|
|
212
178
|
}
|
|
213
179
|
|
|
214
|
-
// remove: a delete-ish verb.
|
|
215
180
|
if (REMOVE_RE.test(verb)) {
|
|
216
181
|
why.push('mutation:verb:' + verb)
|
|
217
182
|
return { entity, op: 'remove', optype: 'mutation', why }
|
|
@@ -249,7 +214,6 @@ function deriveRetShape(
|
|
|
249
214
|
return { kind: 'other' }
|
|
250
215
|
}
|
|
251
216
|
|
|
252
|
-
// Relay connection: has pageInfo plus nodes and/or edges.
|
|
253
217
|
const fnames = Object.keys(named.fields)
|
|
254
218
|
if (fnames.includes('pageInfo') &&
|
|
255
219
|
(fnames.includes('nodes') || fnames.includes('edges'))) {
|
|
@@ -289,9 +253,6 @@ function deriveRetShape(
|
|
|
289
253
|
!/^(errors?|userErrors?)$/i.test(fname)
|
|
290
254
|
})
|
|
291
255
|
|
|
292
|
-
// Prefer the field whose name matches the payload's own entity prefix
|
|
293
|
-
// (IssuePayload -> issue), which is the convention every CRUD-regular
|
|
294
|
-
// GraphQL API follows; fall back to the single remaining candidate.
|
|
295
256
|
const prefix = named.name.replace(/Payload$/, '')
|
|
296
257
|
const byName = candidates.find((fname: string) =>
|
|
297
258
|
fname.toLowerCase() === prefix.toLowerCase() ||
|
|
@@ -314,7 +275,6 @@ function deriveRetShape(
|
|
|
314
275
|
return { kind: 'other' }
|
|
315
276
|
}
|
|
316
277
|
|
|
317
|
-
// A list of the entity is a list op even without connection machinery.
|
|
318
278
|
if (field.list) {
|
|
319
279
|
return { kind: 'list', entity: named.name }
|
|
320
280
|
}
|
|
@@ -323,13 +283,11 @@ function deriveRetShape(
|
|
|
323
283
|
}
|
|
324
284
|
|
|
325
285
|
|
|
326
|
-
// Build the classifier signature for a root field.
|
|
327
286
|
function fieldSig(
|
|
328
287
|
optype: 'query' | 'mutation',
|
|
329
288
|
field: GqlField,
|
|
330
289
|
types: Record<string, GqlType>
|
|
331
290
|
): GqlFieldSig {
|
|
332
|
-
// The input-object argument, if any, drives create/update detection.
|
|
333
291
|
let inputTypeName: string | undefined = undefined
|
|
334
292
|
for (const arg of field.args) {
|
|
335
293
|
const at = types[arg.type]
|
|
@@ -352,8 +310,6 @@ function fieldSig(
|
|
|
352
310
|
}
|
|
353
311
|
|
|
354
312
|
|
|
355
|
-
// Verb suffixes a mutation field name may carry, longest first so
|
|
356
|
-
// `issueUnarchive` strips `Unarchive` rather than `Archive`.
|
|
357
313
|
const NAME_VERBS = [
|
|
358
314
|
'Unarchive', 'Archive', 'Delete', 'Remove', 'Destroy',
|
|
359
315
|
'Create', 'Update', 'Insert', 'Upsert',
|
|
@@ -390,45 +346,16 @@ function nameEntityType(
|
|
|
390
346
|
}
|
|
391
347
|
|
|
392
348
|
|
|
393
|
-
// Entity model name from a GraphQL type name: Issue -> issue,
|
|
394
|
-
// WorkflowState -> workflow_state (canonize handles the casing rules that
|
|
395
|
-
// the REST path classifier already uses).
|
|
396
349
|
function rawEntityName(typeName: string): string {
|
|
397
350
|
return depluralize(canonize(normalizeFieldName(typeName)))
|
|
398
351
|
}
|
|
399
352
|
|
|
400
353
|
|
|
401
|
-
// The leading-digit guard is applied here rather than inherited: the REST
|
|
402
|
-
// side gets it from `ensureMinEntityName`, which this path deliberately does
|
|
403
|
-
// not call (its min-length padding is the REST classifier's rule, and
|
|
404
|
-
// entities here merge by name on purpose). GraphQL type names cannot begin
|
|
405
|
-
// with a digit, but they can begin with `_`, which `normalizeFieldName`
|
|
406
|
-
// strips — so `_3DSSessions` reaches an SDK as the entity `3_ds_session` and
|
|
407
|
-
// every generated language rejects the identifier.
|
|
408
354
|
function entityName(typeName: string): string {
|
|
409
355
|
return prefixLeadingDigit(rawEntityName(typeName))
|
|
410
356
|
}
|
|
411
357
|
|
|
412
358
|
|
|
413
|
-
// Two GraphQL types that canonize to ONE entity name merge, deliberately:
|
|
414
|
-
// `_3DSSession` and `_3DSSessions` are the singular and plural spellings of
|
|
415
|
-
// one thing, and `Issue`/`Issues` likewise. That is what canonicalization is
|
|
416
|
-
// for, and the caller's `guide.entity[entname] ?? {…}` is how it happens.
|
|
417
|
-
//
|
|
418
|
-
// The leading-digit guard opens one collision that is NOT that. A guarded
|
|
419
|
-
// name (`_3DSSessions` -> `3_ds_session` -> `n3_ds_session`) can land on a
|
|
420
|
-
// name another type already owns natively (`N3DSSession` -> `n3_ds_session`).
|
|
421
|
-
// Those are unrelated types, and merging them would fold two entities' fields
|
|
422
|
-
// and ops together under a name whose `orig` records only the first — with no
|
|
423
|
-
// second guide entry for a user to correct, since guide.aon is the only
|
|
424
|
-
// correction surface (ADR-002).
|
|
425
|
-
//
|
|
426
|
-
// So a collision between a guarded name and an unguarded one takes a numeric
|
|
427
|
-
// suffix, the same convention `ensureMinEntityName` uses on the REST side.
|
|
428
|
-
// The scope is exactly the collisions this guard creates: when both names are
|
|
429
|
-
// guarded, or neither is, the merge is what it was before the guard existed
|
|
430
|
-
// and stands untouched. The same type re-encountered on another root field
|
|
431
|
-
// always reuses its own entry.
|
|
432
359
|
function resolveEntityName(
|
|
433
360
|
typeName: string,
|
|
434
361
|
entities: Record<string, GuideEntity>,
|
|
@@ -460,7 +387,6 @@ function newGuidePath(): GuidePath {
|
|
|
460
387
|
}
|
|
461
388
|
|
|
462
389
|
|
|
463
|
-
// The GraphQL guide strategy.
|
|
464
390
|
async function graphql01(ctx: ApiDefContext): Promise<Guide> {
|
|
465
391
|
const def: GqlDef = ctx.def
|
|
466
392
|
const profile: GqlProfile = (ctx.opts.profile ?? 'none') as GqlProfile
|
|
@@ -493,7 +419,6 @@ async function graphql01(ctx: ApiDefContext): Promise<Guide> {
|
|
|
493
419
|
]
|
|
494
420
|
|
|
495
421
|
for (const root of roots) {
|
|
496
|
-
// Sorted iteration: byte-stable guide output.
|
|
497
422
|
for (const fname of Object.keys(root.fields).sort()) {
|
|
498
423
|
guide.metrics.count.field++
|
|
499
424
|
|