@voxgig/sdkgen 3.4.2 → 3.4.5

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.
@@ -10,6 +10,7 @@ import {
10
10
  cmp,
11
11
  each,
12
12
  isAuthActive,
13
+ isConfigData,
13
14
  resolveAuthPrefix,
14
15
  serverVariables,
15
16
  } from '@voxgig/sdkgen'
@@ -63,8 +64,131 @@ const Config = cmp(async function Config(props: any) {
63
64
  : ''
64
65
 
65
66
  // Config is now in core/ package
67
+ // The same config as an OBJECT. Built regardless of which representation
68
+ // wins, because the JSON is what the threshold is measured on - the emitted
69
+ // source size varies by language, the model does not.
70
+ const entityDefs: any = {}
71
+ each(entity, (e: any) => {
72
+ entityDefs[e.name] = clean({
73
+ fields: e.fields,
74
+ name: e.name,
75
+ op: e.op,
76
+ relations: e.relations,
77
+ }, true)
78
+ })
79
+
80
+ const featureDefs: any = {}
81
+ each(feature, (f: any) => {
82
+ featureDefs[f.name] = f.config || {}
83
+ })
84
+
85
+ const entityStubs: any = {}
86
+ each(entity, (e: any) => {
87
+ entityStubs[e.name] = {}
88
+ })
89
+
90
+ const optionsDef: any = { base: baseUrl }
91
+ if (0 < svars.length) {
92
+ optionsDef.server = svars.reduce(
93
+ (a: any, v: any) => (a[v.name] = v.dflt, a), {})
94
+ }
95
+ if (authActive) {
96
+ optionsDef.auth = { prefix: authPrefix }
97
+ }
98
+ optionsDef.headers = headers
99
+ optionsDef.entity = entityStubs
100
+
101
+ // Key order here is the order `each` produced, which is sorted, so the
102
+ // emitted JSON is byte-stable across runs like the literal it replaces.
103
+ const configDef = {
104
+ main: { name: model.const.Name },
105
+ feature: featureDefs,
106
+ options: optionsDef,
107
+ entity: entityDefs,
108
+ }
109
+ const configJson = JSON.stringify(configDef)
110
+
111
+ // `auto` decides by size; 'data'/'literal' pin it for this SDK.
112
+ let configReprSetting = 'auto'
113
+ try {
114
+ configReprSetting = getModelPath(model, `main.${KIT}.config.repr`) || 'auto'
115
+ } catch (_e) { }
116
+ const asData = isConfigData(configJson, configReprSetting)
117
+
66
118
  File({ name: 'config.' + target.ext }, () => {
67
119
 
120
+ // ABOVE THE THRESHOLD: emit the model as DATA.
121
+ //
122
+ // A composite literal makes the compiler walk every node of the model;
123
+ // a string constant is one token. On the real gitlab model that is 30.8 s
124
+ // and 2.49 GB of compiler memory versus 0.34 s and 0.06 GB, and a binary
125
+ // 2.1x smaller. MakeConfig still returns the same map, so nothing
126
+ // downstream can tell which representation it got.
127
+ //
128
+ // JSON.stringify output is a valid Go interpreted string literal: JSON
129
+ // escapes are a subset of Go's, and Go source is UTF-8 so non-ASCII needs
130
+ // no escaping. A raw (backtick) literal could NOT be used - the model
131
+ // contains backticks in values like `$STRING`.
132
+ if (asData) {
133
+ Content(`package core
134
+
135
+ import (
136
+ "encoding/json"
137
+ "math"
138
+ "sync"
139
+ )
140
+
141
+ // The API model, emitted as data rather than as a composite literal: see
142
+ // sdkgen rung L1. Parsed by MakeConfig, and parsed once by SharedConfig.
143
+ const configJSON = ${JSON.stringify(configJson)}
144
+
145
+ // json.Unmarshal decodes EVERY JSON number as float64, but the literal
146
+ // representation emits an integer token as an untyped constant that lands in
147
+ // map[string]any as an int. MakeConfig is public API and consumers type-assert
148
+ // against it, so the two representations must not disagree about the type of
149
+ // a whole number just because the model crossed a size threshold.
150
+ //
151
+ // Whole values become int; anything fractional, or too large to be exact in a
152
+ // float64, stays float64 - which is what the literal branch does too.
153
+ func configNormalise(val any) any {
154
+ switch v := val.(type) {
155
+ case map[string]any:
156
+ for k, c := range v {
157
+ v[k] = configNormalise(c)
158
+ }
159
+ return v
160
+ case []any:
161
+ for i, c := range v {
162
+ v[i] = configNormalise(c)
163
+ }
164
+ return v
165
+ case float64:
166
+ if v == math.Trunc(v) && math.Abs(v) <= 1<<53 {
167
+ return int(v)
168
+ }
169
+ return v
170
+ }
171
+ return val
172
+ }
173
+
174
+ // MakeConfig parses a fresh, fully materialised config map. Every call
175
+ // re-parses, so prefer SharedConfig unless you need a private copy you
176
+ // intend to mutate.
177
+ func MakeConfig() map[string]any {
178
+ var out map[string]any
179
+ if err := json.Unmarshal([]byte(configJSON), &out); err != nil {
180
+ // Unreachable: the constant is generated by json.Marshal's
181
+ // counterpart and never edited by hand. Panic rather than return a
182
+ // silently empty config, which would fail far from the cause.
183
+ panic("${model.const.Name}: embedded config is not valid JSON: " + err.Error())
184
+ }
185
+ out, _ = configNormalise(out).(map[string]any)
186
+ return out
187
+ }
188
+ `)
189
+ }
190
+ else {
191
+
68
192
  Content(`package core
69
193
 
70
194
  import (
@@ -113,7 +237,10 @@ ${serverBlock}${authBlock} "headers": ${formatGoMap(headers, 3)},
113
237
  }, true), a), {}), 2)},
114
238
  }
115
239
  }
240
+ `)
241
+ }
116
242
 
243
+ Content(`
117
244
  var (
118
245
  sharedConfigOnce sync.Once
119
246
  sharedConfigVal map[string]any
@@ -11,6 +11,7 @@ import {
11
11
  each,
12
12
  indent,
13
13
  isAuthActive,
14
+ isConfigData,
14
15
  resolveAuthPrefix,
15
16
  serverVariables,
16
17
  } from '@voxgig/sdkgen'
@@ -62,13 +63,100 @@ const Config = cmp(async function Config(props: any) {
62
63
  svars.map((v: any) => ` ${JSON.stringify(v.name)}: ${JSON.stringify(v.dflt)},\n`).join('') +
63
64
  ' },\n\n '
64
65
 
66
+ // Read the base URL here rather than leaving it to a `$$...$$` stdrep
67
+ // placeholder in the fragment. stdrep can only substitute a path the model
68
+ // actually has: a model with no `info.servers` left the placeholder itself in
69
+ // the generated source, so `options.base` came out as the literal string
70
+ // '$main.kit.info.servers.0.url$'. Reading it explicitly yields '' in that
71
+ // case, which is what every other target already emits, and is identical to
72
+ // the old output whenever the model does define a server.
73
+ let baseUrl = ''
74
+ try {
75
+ baseUrl = getModelPath(model, `main.${KIT}.info.servers.0.url`)
76
+ } catch (_e) { }
77
+
78
+ // The same config as an OBJECT, so it can be measured and, above the
79
+ // threshold, emitted as data instead of as class field literals. Mirrors the
80
+ // literal branch below exactly - feature config is NOT cleaned here, because
81
+ // it is not cleaned there either.
82
+ const entityDefs: any = {}
83
+ each(entity, (e: any) => {
84
+ entityDefs[e.name] = clean({
85
+ fields: e.fields,
86
+ name: e.name,
87
+ op: e.op,
88
+ relations: e.relations,
89
+ }, true)
90
+ })
91
+
92
+ const featureDefs: any = {}
93
+ each(feature, (f: any) => {
94
+ featureDefs[f.name] = f.config
95
+ })
96
+
97
+ const entityStubs: any = {}
98
+ each(entity, (e: any) => {
99
+ entityStubs[e.name] = {}
100
+ })
101
+
102
+ const optionsDef: any = { base: baseUrl }
103
+ if (0 < svars.length) {
104
+ optionsDef.server = svars.reduce(
105
+ (a: any, v: any) => (a[v.name] = v.dflt, a), {})
106
+ }
107
+ if (authActive) {
108
+ optionsDef.auth = { prefix: authPrefix }
109
+ }
110
+ optionsDef.headers = headers
111
+ optionsDef.entity = entityStubs
112
+
113
+ const configDef = {
114
+ main: { name: model.const.Name },
115
+ feature: featureDefs,
116
+ options: optionsDef,
117
+ entity: entityDefs,
118
+ }
119
+ const configJson = JSON.stringify(configDef)
120
+
121
+ // `auto` decides by size; 'data'/'literal' pin it for this SDK.
122
+ let configReprSetting = 'auto'
123
+ try {
124
+ configReprSetting = getModelPath(model, `main.${KIT}.config.repr`) || 'auto'
125
+ } catch (_e) { }
126
+
65
127
  File({ name: 'Config.' + target.ext }, () => {
66
128
 
129
+ if (isConfigData(configJson, configReprSetting)) {
130
+ Fragment({
131
+ from: ff + 'Config.data.fragment.ts',
132
+
133
+ replace: {
134
+
135
+ '// #ImportFeatures': () => each(feature, (f: any) => {
136
+ Line(`import { ${nom(f, 'Name')}Feature } from ` +
137
+ `'./feature/${f.name}/${nom(f, 'Name')}Feature'`)
138
+ }),
139
+
140
+ '// #FeatureClasses': () => each(feature, (f: any) => {
141
+ Line(` ${f.name}: ${nom(f, 'Name')}Feature,`)
142
+ }),
143
+
144
+ // A JS string literal, so the JSON survives verbatim. JSON.stringify
145
+ // escapes the quotes and backslashes the model contains (values like
146
+ // `$STRING` carry backticks, which a template literal could not).
147
+ "'CONFIGJSON'": JSON.stringify(configJson),
148
+ }
149
+ })
150
+ return
151
+ }
152
+
67
153
  Fragment({
68
154
  from: ff + 'Config.fragment.ts',
69
155
 
70
156
  replace: {
71
157
 
158
+ "'BASEURL'": JSON.stringify(baseUrl),
159
+
72
160
  "'SERVERBLOCK'": serverBlock,
73
161
 
74
162
  "'AUTHBLOCK'": authBlock,
@@ -0,0 +1,50 @@
1
+
2
+ import { BaseFeature } from './feature/base/BaseFeature'
3
+ // #ImportFeatures
4
+
5
+
6
+ const FEATURE_CLASS: Record<string, typeof BaseFeature> = {
7
+ // #FeatureClasses
8
+ }
9
+
10
+
11
+ // THE API MODEL, EMBEDDED AS DATA (sdkgen rung L1).
12
+ //
13
+ // The literal form of this file declares the whole model as nested object
14
+ // literals on the class. For a large API that is megabytes of source that the
15
+ // TypeScript compiler must parse and infer a type for on every build, and that
16
+ // the JavaScript engine must build node by node on every module load.
17
+ //
18
+ // As a single string constant it is one token to the compiler, and V8's JSON
19
+ // parser builds the object far faster than the equivalent literal - JSON.parse
20
+ // is the well-worn trick for exactly this shape of data.
21
+ //
22
+ // Emitted only above a size threshold, or when `main.kit.config.repr` pins it:
23
+ // for a small model the literal is smaller, loads no slower, and is far easier
24
+ // to read when debugging.
25
+ const CONFIG_DATA = 'CONFIGJSON'
26
+
27
+
28
+ class Config {
29
+
30
+ makeFeature(this: any, fn: string) {
31
+ const fc = FEATURE_CLASS[fn]
32
+ const fi = new fc()
33
+ // TODO: errors etc
34
+ return fi
35
+ }
36
+
37
+ }
38
+
39
+
40
+ // Parsed ONCE, at module load, exactly like the literal form was built once.
41
+ //
42
+ // The parsed data is assigned onto an instance rather than replacing it, so
43
+ // `config.main` / `.feature` / `.options` / `.entity` read exactly as they did
44
+ // when they were field initialisers, and `makeFeature` stays where it was on
45
+ // the prototype. Callers cannot tell which representation they were given.
46
+ const config: any = Object.assign(new Config(), JSON.parse(CONFIG_DATA))
47
+
48
+ export {
49
+ config
50
+ }
@@ -29,7 +29,7 @@ class Config {
29
29
 
30
30
 
31
31
  options = {
32
- base: '$$main.kit.info.servers.0.url$$',
32
+ base: 'BASEURL',
33
33
 
34
34
  'SERVERBLOCK''AUTHBLOCK'headers: 'HEADERS',
35
35
 
@@ -207,6 +207,22 @@ const TargetRoot = cmp(function TargetRoot(props: any) {
207
207
  // language puts feature source somewhere different.
208
208
  const trim = trimFeatures(ctx$, tfolder, torigname, tname, features)
209
209
 
210
+ // Copy only ADDS and overwrites — it never removes, and it never even
211
+ // looks at a file the trim excludes. So a template that this SDK should
212
+ // NOT have lived on at whatever revision it was first copied at, and
213
+ // kept being generated from.
214
+ //
215
+ // That is how 30 cedar repos ended up with tm/go/test/feature_test.go
216
+ // still declaring the nine fh* harness helpers after upstream moved them
217
+ // into feature_harness_test.go: feature_test.go is feature-source, so it
218
+ // is trimmed for an SDK without those features, so Copy skipped it, so
219
+ // the pre-move revision survived every `target add go` those repos ever
220
+ // ran. Generation then emitted it alongside the new harness and the go
221
+ // package failed to compile — "fhHasFeature redeclared in this block".
222
+ //
223
+ // The invariant this restores: tm/<target> == source tree MINUS trim.
224
+ pruneStaleTemplates(ctx$, tfolder + '/tm/' + torigname, 'tm/' + tname, trim)
225
+
210
226
  Folder({ name: 'tm/' + tname }, () => {
211
227
  Copy({
212
228
  from: tfolder + '/tm/' + torigname,
@@ -234,6 +250,105 @@ const TargetRoot = cmp(function TargetRoot(props: any) {
234
250
  // read. Trimming a target whose templates are not ready for it produces a
235
251
  // project that does not build, so an unreadable declaration must fail safe
236
252
  // rather than fail tidy.
253
+ // Bring the consumer's `tm/<target>` back to the invariant that Copy alone
254
+ // cannot maintain: it must contain EXACTLY the source tree minus the files
255
+ // this SDK's feature set trims away.
256
+ //
257
+ // Copy adds and overwrites. It does not remove, and it does not touch an
258
+ // excluded file at all — so both of these persist silently forever:
259
+ //
260
+ // - a template the toolchain has RETIRED (absent from the source tree);
261
+ // - a template this SDK should not have (present in source, but trimmed),
262
+ // frozen at whatever revision it was first copied at.
263
+ //
264
+ // The second is the one that bit: tm/go/test/feature_test.go is feature
265
+ // source, so it is trimmed for an SDK without those features, so it was never
266
+ // refreshed after upstream moved the fh* harness helpers out of it.
267
+ //
268
+ // tm/ is toolchain-owned — the scaffold rewrites it on every add-target, and
269
+ // model/guide/guide.aontu is the one file a user owns (merged separately by
270
+ // create-sdkgen) — so removing what the toolchain says should not be there is
271
+ // consistent with how the rest of that tree is already treated.
272
+ function pruneStaleTemplates(
273
+ ctx$: any,
274
+ fromDir: string,
275
+ toRel: string,
276
+ trim: RegExp[],
277
+ ) {
278
+ const { log } = ctx$
279
+ const fs = ctx$.fs()
280
+ const folder = ctx$.folder ?? '.'
281
+ const destDir = Path.join(folder, toRel)
282
+
283
+ const listRel = (root: string): string[] => {
284
+ const out: string[] = []
285
+ const walk = (dir: string, rel: string) => {
286
+ let entries: any[]
287
+ try {
288
+ entries = fs.readdirSync(dir, { withFileTypes: true })
289
+ }
290
+ catch (e: any) {
291
+ return
292
+ }
293
+ for (const ent of entries) {
294
+ const child = Path.join(dir, ent.name)
295
+ const childRel = '' === rel ? ent.name : rel + '/' + ent.name
296
+ if (ent.isDirectory()) {
297
+ walk(child, childRel)
298
+ }
299
+ else {
300
+ out.push(childRel)
301
+ }
302
+ }
303
+ }
304
+ walk(root, '')
305
+ return out
306
+ }
307
+
308
+ const sourceFiles = listRel(fromDir)
309
+
310
+ // An unreadable source tree must not be read as "everything is stale" — that
311
+ // would empty the destination.
312
+ if (0 === sourceFiles.length) {
313
+ return
314
+ }
315
+
316
+ // What SHOULD be present: source, minus anything the trim excludes. The trim
317
+ // patterns are matched against the source-relative path, the same way Copy
318
+ // applies them.
319
+ const trimmed = (rel: string) => trim.some((re) => re.test(rel))
320
+ const want = new Set(sourceFiles.filter((rel) => !trimmed(rel)))
321
+
322
+ const stale = listRel(destDir).filter((rel) => !want.has(rel))
323
+ if (0 === stale.length) {
324
+ return
325
+ }
326
+
327
+ const removed: string[] = []
328
+ for (const rel of stale) {
329
+ try {
330
+ fs.unlinkSync(Path.join(destDir, rel))
331
+ removed.push(rel)
332
+ }
333
+ catch (e: any) {
334
+ log.warn({
335
+ point: 'target-template-prune', target: toRel, file: rel,
336
+ note: 'could not remove stale template ' + rel + ': ' + e.message
337
+ })
338
+ }
339
+ }
340
+
341
+ if (0 < removed.length) {
342
+ log.info({
343
+ point: 'target-template-prune', target: toRel, count: removed.length,
344
+ files: removed,
345
+ note: toRel + ': removed ' + removed.length +
346
+ ' stale template(s) the toolchain no longer provides for this SDK'
347
+ })
348
+ }
349
+ }
350
+
351
+
237
352
  function trimFeatures(
238
353
  ctx$: any,
239
354
  tfolder: string,
@@ -577,7 +577,16 @@ The OpenAPI spec(s) this SDK was generated from are kept in the
577
577
 
578
578
  `)
579
579
  if (upstreamUrl) {
580
- Content(`- Upstream API: [${upstreamUrl}](${upstreamUrl})
580
+ // A per-tenant server URL carries an OpenAPI server variable —
581
+ // `https://{instance}.dreamapply.com/api` — and is the right thing to
582
+ // SHOW, because it tells the reader the host is theirs to fill in. It is
583
+ // not a thing to LINK: the braces are not a resolvable address, so a
584
+ // markdown link renders as clickable and dead. Show it as code instead.
585
+ const templated = /[{}]/.test(upstreamUrl)
586
+ Content(templated
587
+ ? `- Upstream API: \`${upstreamUrl}\`
588
+ `
589
+ : `- Upstream API: [${upstreamUrl}](${upstreamUrl})
581
590
  `)
582
591
  }
583
592
  if (docsUrl && docsUrl !== upstreamUrl) {
package/src/sdkgen.ts CHANGED
@@ -19,7 +19,8 @@ import type {
19
19
  ActionResult,
20
20
  } from './types'
21
21
 
22
- import { SdkGenError, requirePath, isAuthActive, resolveAuthPrefix } from './utility'
22
+ import { SdkGenError, requirePath, isAuthActive, resolveAuthPrefix,
23
+ CONFIG_DATA_THRESHOLD, CONFIG_REPR_VALUES, isConfigData, configRepr } from './utility'
23
24
 
24
25
  import { Main } from './cmp/Main'
25
26
  import { ExternalTarget } from './cmp/ExternalTarget'
@@ -776,6 +777,10 @@ export {
776
777
  requirePath,
777
778
  isAuthActive,
778
779
  resolveAuthPrefix,
780
+ CONFIG_DATA_THRESHOLD,
781
+ CONFIG_REPR_VALUES,
782
+ isConfigData,
783
+ configRepr,
779
784
 
780
785
  // Scaffold components need this to fail a generation with an actionable
781
786
  // message rather than a bare Error (py-data guards on its sibling `py`).
package/src/utility.ts CHANGED
@@ -96,4 +96,73 @@ export {
96
96
  isAuthActive,
97
97
  resolveAuthPrefix,
98
98
  SdkGenError,
99
+ CONFIG_DATA_THRESHOLD,
100
+ CONFIG_REPR_VALUES,
101
+ isConfigData,
102
+ configRepr,
103
+ }
104
+
105
+
106
+ // CONFIG REPRESENTATION (design rung L1, threshold from design Q7).
107
+ //
108
+ // Above a size threshold the API model is emitted as DATA - a JSON string
109
+ // constant parsed once - rather than as a composite literal. Below it the
110
+ // literal stays, because for a small model the literal is smaller, simpler,
111
+ // faster to load and far easier to debug, and a symbol table would be pure
112
+ // complexity.
113
+ //
114
+ // The threshold is on the JSON, not the emitted source, because the emitted
115
+ // source size varies by language while the model does not. It is measured in
116
+ // UTF-8 BYTES rather than string length: `.length` counts UTF-16 code units,
117
+ // so a CJK-heavy model would read as roughly a third of its real size and
118
+ // stay on the expensive literal path well past the point where it hurts.
119
+ //
120
+ // Measured on the real gitlab model (923.5 KB of JSON), Go, cold cache,
121
+ // recompiling only the config package:
122
+ //
123
+ // composite literal JSON string constant
124
+ // compile+link wall 30.80 s 0.34 s 91x faster
125
+ // peak compiler RSS 2.49 GB 0.06 GB 39x less
126
+ // binary 7.44 MB 3.51 MB 2.1x smaller
127
+ //
128
+ // The reader side is unchanged either way: make_config returns the same map,
129
+ // so nothing downstream can tell which representation it got.
130
+ const CONFIG_DATA_THRESHOLD = 256 * 1024
131
+
132
+
133
+ // Should this model be emitted as data rather than as a literal?
134
+ //
135
+ // `repr` is the per-SDK override from `main.kit.config.repr`: 'auto' (the
136
+ // default) decides by size, 'data' and 'literal' pin it. The override is what
137
+ // lets a small fixture exercise the data path - by size alone no test model
138
+ // comes near the threshold, so the branch every large SDK depends on would
139
+ // never be generated, compiled or run in CI.
140
+ const CONFIG_REPR_VALUES = ['auto', 'data', 'literal']
141
+
142
+ function isConfigData(configJson: string, repr?: string): boolean {
143
+ // An unknown value is REJECTED, not ignored. The aontu declaration
144
+ // documents the closed set but does not enforce it here, and silently
145
+ // treating `repr: 'date'` as `auto` would quietly restore the compile cost
146
+ // this exists to remove - the failure mode being a slow build nobody
147
+ // connects to a typo.
148
+ if (null != repr && '' !== repr && !CONFIG_REPR_VALUES.includes(repr)) {
149
+ throw new SdkGenError(
150
+ 'sdkgen: main.kit.config.repr must be one of ' +
151
+ CONFIG_REPR_VALUES.join(', ') + ' (got: ' + repr + ')', {})
152
+ }
153
+ if ('data' === repr) {
154
+ return true
155
+ }
156
+ if ('literal' === repr) {
157
+ return false
158
+ }
159
+ return CONFIG_DATA_THRESHOLD < Buffer.byteLength(configJson, 'utf8')
160
+ }
161
+
162
+
163
+ // The chosen representation, as a word - for generation logs and for the
164
+ // per-SDK reporting the fleet regen needs, so a model crossing the threshold
165
+ // is visible rather than showing up as an unexplained whole-file diff.
166
+ function configRepr(configJson: string, repr?: string): string {
167
+ return isConfigData(configJson, repr) ? 'data' : 'literal'
99
168
  }