@voxgig/sdkgen 3.7.2 → 4.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 (126) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/action/action.js +12 -12
  3. package/dist/action/action.js.map +1 -1
  4. package/dist/action/check.js +2 -2
  5. package/dist/action/check.js.map +1 -1
  6. package/dist/action/doctor.js +4 -4
  7. package/dist/action/doctor.js.map +1 -1
  8. package/dist/action/feature.js +1 -1
  9. package/dist/action/feature.js.map +1 -1
  10. package/dist/action/kind.js +5 -5
  11. package/dist/action/kind.js.map +1 -1
  12. package/dist/action/package.js +1 -1
  13. package/dist/action/package.js.map +1 -1
  14. package/dist/action/resolve.js +2 -2
  15. package/dist/action/resolve.js.map +1 -1
  16. package/dist/action/target.js +2 -2
  17. package/dist/action/target.js.map +1 -1
  18. package/dist/cmp/AgentGuide.js +2 -2
  19. package/dist/cmp/AgentGuideContent.js +9 -9
  20. package/dist/cmp/AgentGuideFeature.js +3 -3
  21. package/dist/cmp/AgentGuideFeature.js.map +1 -1
  22. package/dist/cmp/AgentGuideTop.js +1 -1
  23. package/dist/cmp/Deploy.js +3 -3
  24. package/dist/cmp/Deploy.js.map +1 -1
  25. package/dist/helpers/definition.js +7 -7
  26. package/dist/helpers/definition.js.map +1 -1
  27. package/dist/helpers/featureSource.js +2 -2
  28. package/dist/helpers/featureSource.js.map +1 -1
  29. package/dist/helpers/manifest.js +3 -3
  30. package/dist/helpers/manifest.js.map +1 -1
  31. package/dist/helpers/modelcheck.js +1 -1
  32. package/dist/helpers/modelcheck.js.map +1 -1
  33. package/dist/helpers/opShape.d.ts +2 -1
  34. package/dist/helpers/opShape.js +1 -0
  35. package/dist/helpers/opShape.js.map +1 -1
  36. package/dist/helpers/packageMeta.js +1 -1
  37. package/dist/helpers/packageMeta.js.map +1 -1
  38. package/dist/helpers/shipped.js +2 -2
  39. package/dist/helpers/shipped.js.map +1 -1
  40. package/dist/sdkgen.d.ts +2 -2
  41. package/dist/sdkgen.js +5 -4
  42. package/dist/sdkgen.js.map +1 -1
  43. package/dist/testkit.js +7 -7
  44. package/dist/testkit.js.map +1 -1
  45. package/dist/tsconfig.tsbuildinfo +1 -1
  46. package/dist/utility.js +1 -1
  47. package/dist/utility.js.map +1 -1
  48. package/model/{sdkgen.aontu → sdkgen.aon} +14 -0
  49. package/package.json +3 -3
  50. package/project/.sdk/model/feature/feature-index.aon +24 -0
  51. package/project/.sdk/src/cmp/seneca-provider/Extras_seneca-provider.ts +54 -21
  52. package/project/.sdk/src/cmp/seneca-provider/Main_seneca-provider.ts +24 -11
  53. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +96 -0
  54. package/project/.sdk/tm/csharp/Makefile +1 -1
  55. package/project/.sdk/tm/rb/utility/struct/voxgig_struct.rb +31 -11
  56. package/project/.sdk/tm/swift/Makefile +1 -1
  57. package/project/.sdk/tm/ts/src/feature/test/TestFeature.ts +47 -1
  58. package/project/sdkgen-package.json +1 -1
  59. package/src/action/action.ts +12 -12
  60. package/src/action/check.ts +2 -2
  61. package/src/action/doctor.ts +5 -5
  62. package/src/action/feature.ts +1 -1
  63. package/src/action/kind.ts +5 -5
  64. package/src/action/package.ts +1 -1
  65. package/src/action/resolve.ts +2 -2
  66. package/src/action/target.ts +2 -2
  67. package/src/cmp/AgentGuide.ts +2 -2
  68. package/src/cmp/AgentGuideContent.ts +9 -9
  69. package/src/cmp/AgentGuideFeature.ts +3 -3
  70. package/src/cmp/AgentGuideTop.ts +1 -1
  71. package/src/cmp/Deploy.ts +3 -3
  72. package/src/helpers/definition.ts +7 -7
  73. package/src/helpers/featureSource.ts +2 -2
  74. package/src/helpers/manifest.ts +3 -3
  75. package/src/helpers/modelcheck.ts +1 -1
  76. package/src/helpers/opShape.ts +1 -0
  77. package/src/helpers/packageMeta.ts +1 -1
  78. package/src/helpers/shipped.ts +2 -2
  79. package/src/sdkgen.ts +4 -3
  80. package/src/testkit.ts +9 -9
  81. package/src/utility.ts +1 -1
  82. package/testkit.js +1 -1
  83. package/project/.sdk/model/feature/feature-index.aontu +0 -24
  84. /package/project/.sdk/model/feature/{audit.aontu → audit.aon} +0 -0
  85. /package/project/.sdk/model/feature/{cache.aontu → cache.aon} +0 -0
  86. /package/project/.sdk/model/feature/{clienttrack.aontu → clienttrack.aon} +0 -0
  87. /package/project/.sdk/model/feature/{debug.aontu → debug.aon} +0 -0
  88. /package/project/.sdk/model/feature/{idempotency.aontu → idempotency.aon} +0 -0
  89. /package/project/.sdk/model/feature/{log.aontu → log.aon} +0 -0
  90. /package/project/.sdk/model/feature/{metrics.aontu → metrics.aon} +0 -0
  91. /package/project/.sdk/model/feature/{netsim.aontu → netsim.aon} +0 -0
  92. /package/project/.sdk/model/feature/{paging.aontu → paging.aon} +0 -0
  93. /package/project/.sdk/model/feature/{proxy.aontu → proxy.aon} +0 -0
  94. /package/project/.sdk/model/feature/{ratelimit.aontu → ratelimit.aon} +0 -0
  95. /package/project/.sdk/model/feature/{rbac.aontu → rbac.aon} +0 -0
  96. /package/project/.sdk/model/feature/{retry.aontu → retry.aon} +0 -0
  97. /package/project/.sdk/model/feature/{streaming.aontu → streaming.aon} +0 -0
  98. /package/project/.sdk/model/feature/{telemetry.aontu → telemetry.aon} +0 -0
  99. /package/project/.sdk/model/feature/{test.aontu → test.aon} +0 -0
  100. /package/project/.sdk/model/feature/{timeout.aontu → timeout.aon} +0 -0
  101. /package/project/.sdk/model/target/{c.aontu → c.aon} +0 -0
  102. /package/project/.sdk/model/target/{clojure.aontu → clojure.aon} +0 -0
  103. /package/project/.sdk/model/target/{cpp.aontu → cpp.aon} +0 -0
  104. /package/project/.sdk/model/target/{csharp.aontu → csharp.aon} +0 -0
  105. /package/project/.sdk/model/target/{dart.aontu → dart.aon} +0 -0
  106. /package/project/.sdk/model/target/{elixir.aontu → elixir.aon} +0 -0
  107. /package/project/.sdk/model/target/{go-cli.aontu → go-cli.aon} +0 -0
  108. /package/project/.sdk/model/target/{go-mcp.aontu → go-mcp.aon} +0 -0
  109. /package/project/.sdk/model/target/{go.aontu → go.aon} +0 -0
  110. /package/project/.sdk/model/target/{java.aontu → java.aon} +0 -0
  111. /package/project/.sdk/model/target/{js.aontu → js.aon} +0 -0
  112. /package/project/.sdk/model/target/{kotlin.aontu → kotlin.aon} +0 -0
  113. /package/project/.sdk/model/target/{lean.aontu → lean.aon} +0 -0
  114. /package/project/.sdk/model/target/{lua.aontu → lua.aon} +0 -0
  115. /package/project/.sdk/model/target/{ocaml.aontu → ocaml.aon} +0 -0
  116. /package/project/.sdk/model/target/{perl.aontu → perl.aon} +0 -0
  117. /package/project/.sdk/model/target/{php.aontu → php.aon} +0 -0
  118. /package/project/.sdk/model/target/{py-data.aontu → py-data.aon} +0 -0
  119. /package/project/.sdk/model/target/{py.aontu → py.aon} +0 -0
  120. /package/project/.sdk/model/target/{rb.aontu → rb.aon} +0 -0
  121. /package/project/.sdk/model/target/{rust.aontu → rust.aon} +0 -0
  122. /package/project/.sdk/model/target/{scala.aontu → scala.aon} +0 -0
  123. /package/project/.sdk/model/target/{seneca-provider.aontu → seneca-provider.aon} +0 -0
  124. /package/project/.sdk/model/target/{swift.aontu → swift.aon} +0 -0
  125. /package/project/.sdk/model/target/{ts.aontu → ts.aon} +0 -0
  126. /package/project/.sdk/model/target/{zig.aontu → zig.aon} +0 -0
@@ -0,0 +1,24 @@
1
+ # Features
2
+
3
+ # Core
4
+ @"test.aon"
5
+ @"log.aon"
6
+
7
+ # Enterprise capabilities
8
+ @"retry.aon"
9
+ @"timeout.aon"
10
+ @"ratelimit.aon"
11
+ @"cache.aon"
12
+ @"idempotency.aon"
13
+ @"paging.aon"
14
+ @"streaming.aon"
15
+ @"proxy.aon"
16
+ @"telemetry.aon"
17
+ @"metrics.aon"
18
+ @"debug.aon"
19
+ @"audit.aon"
20
+ @"clienttrack.aon"
21
+ @"rbac.aon"
22
+
23
+ # Test support: network behaviour simulation
24
+ @"netsim.aon"
@@ -242,8 +242,15 @@ function seedRecord(e: any, idx: number): Record<string, any> {
242
242
  else if (e.parents.includes(f.name)) {
243
243
  // A nested entity's parent id must match a record the parent seeds, or
244
244
  // the offline store answers nothing and every nested test reads as a
245
- // false pass.
246
- out[f.name] = `${f.parentEntity}0`
245
+ // false pass. Reuses parentSeed's fallback rather than f.parentEntity
246
+ // directly: when no entity in the model shares this key's name (the
247
+ // common case for a scoping param like `user_id` with no `user`
248
+ // entity, or a same-named response field that means something else
249
+ // entirely, like GitHub's `owner`), f.parentEntity is '' and seeding
250
+ // '0' desynced the record from every query built against the SAME
251
+ // key via parentSeed (parentPairs, crudTest, ...) — 0 results, or a
252
+ // seeded field asserted against the wrong literal.
253
+ out[f.name] = parentSeed(e, f.name)
247
254
  }
248
255
  else if ('number' === f.kind) {
249
256
  out[f.name] = 100 * (idx + 1)
@@ -407,11 +414,20 @@ describe('${provider.fileBase}', () => {
407
414
 
408
415
  `)
409
416
 
410
- if (subject.cmds.includes('list')) {
411
- Content(`
412
- it('${subject.name}-list', async () => {
417
+ // Every flat entity (no parent keys), not just one "subject" — a
418
+ // provider with two or more flat siblings used to leave every one
419
+ // but the busiest untested beyond the accessor check above. A bare
420
+ // `list$()`/`load$(id)` call has no way to carry a parent key, so
421
+ // entities that need one are covered by the `nested` block below
422
+ // instead, with their keys filled in.
423
+ const flat = provider.entities.filter((e: any) => 0 === e.parents.length)
424
+
425
+ each(flat, (e: any) => {
426
+ if (e.cmds.includes('list')) {
427
+ Content(`
428
+ it('${e.name}-list', async () => {
413
429
  const seneca = await makeSeneca()
414
- const list = await seneca.entity('provider/${provider.lower}/${subject.name}').list$()
430
+ const list = await seneca.entity('provider/${provider.lower}/${e.name}').list$()
415
431
 
416
432
  assert.equal(list.length, 2)
417
433
 
@@ -420,25 +436,25 @@ describe('${provider.fileBase}', () => {
420
436
  // survive into the Seneca entity.
421
437
  assert.equal(
422
438
  list[0].canon$({ string: true }),
423
- 'provider/${provider.lower}/${subject.name}',
439
+ 'provider/${provider.lower}/${e.name}',
424
440
  )
425
441
  })
426
442
 
427
443
  `)
428
- }
444
+ }
429
445
 
430
- if (subject.cmds.includes('load')) {
431
- Content(`
432
- it('${subject.name}-load', async () => {
446
+ if (e.cmds.includes('load')) {
447
+ Content(`
448
+ it('${e.name}-load', async () => {
433
449
  const seneca = await makeSeneca()
434
450
  const found = await seneca
435
- .entity('provider/${provider.lower}/${subject.name}')
436
- .load$('${subject.name}0')
451
+ .entity('provider/${provider.lower}/${e.name}')
452
+ .load$('${e.name}0')
437
453
 
438
- assert.equal(found.${subject.idf || 'id'}, '${subject.name}0')
454
+ assert.equal(found.${e.idf || 'id'}, '${e.name}0')
439
455
  assert.equal(
440
456
  found.canon$({ string: true }),
441
- 'provider/${provider.lower}/${subject.name}',
457
+ 'provider/${provider.lower}/${e.name}',
442
458
  )
443
459
  })
444
460
 
@@ -446,17 +462,18 @@ describe('${provider.fileBase}', () => {
446
462
  // A 404 from a single-item read is an ordinary "not found" answer, not a
447
463
  // failure: the provider turns it into null rather than letting the SDK
448
464
  // throw.
449
- it('${subject.name}-load-missing', async () => {
465
+ it('${e.name}-load-missing', async () => {
450
466
  const seneca = await makeSeneca()
451
467
  const missing = await seneca
452
- .entity('provider/${provider.lower}/${subject.name}')
453
- .load$('nosuch${subject.name}')
468
+ .entity('provider/${provider.lower}/${e.name}')
469
+ .load$('nosuch${e.name}')
454
470
 
455
471
  assert.equal(missing, null)
456
472
  })
457
473
 
458
474
  `)
459
- }
475
+ }
476
+ })
460
477
 
461
478
  // A nested entity cannot build its path without the parent id. That is
462
479
  // the mistake this target exists to make impossible, so pin it.
@@ -469,17 +486,31 @@ describe('${provider.fileBase}', () => {
469
486
  const pairs = e.parents
470
487
  .map((k: string) => `${k}: '${parentSeed(e, k)}'`).join(', ')
471
488
 
472
- Content(`
489
+ // The guard is PER OP (Main's opParents), not a blanket property of
490
+ // the entity, so the op this test calls has to be one that actually
491
+ // requires `key` — hardcoding `list` assumed every nested entity's
492
+ // list is parent-scoped, which fails for e.g. an entity guarded on
493
+ // load/update/remove but whose list is unscoped (GitHub's `repo`:
494
+ // owner guards load, not list).
495
+ const guardOp = ['list', 'load', 'update', 'remove']
496
+ .find((op: string) => (e.opParents[op] || []).includes(key))
497
+
498
+ if (null != guardOp) {
499
+ const call = 'list' === guardOp ?
500
+ `${guardOp}$({})` : `${guardOp}$({ id: '${e.name}0' })`
501
+
502
+ Content(`
473
503
  it('${e.name}-needs-${key}', async () => {
474
504
  const seneca = await makeSeneca()
475
505
 
476
506
  await assert.rejects(
477
- () => seneca.entity('provider/${provider.lower}/${e.name}').list$({}),
507
+ () => seneca.entity('provider/${provider.lower}/${e.name}').${call},
478
508
  /${key} is required/,
479
509
  )
480
510
  })
481
511
 
482
512
  `)
513
+ }
483
514
  if (e.cmds.includes('list')) {
484
515
  // Assert on the SEEDED RECORDS, not merely that an array came back.
485
516
  // `Array.isArray` is true of the empty array, so the nested-list
@@ -4024,4 +4055,6 @@ export {
4024
4055
  Workflow,
4025
4056
  Readme,
4026
4057
  Docs,
4058
+ seedRecord,
4059
+ parentSeed,
4027
4060
  }
@@ -2,7 +2,7 @@ import {
2
2
  cmp, each,
3
3
  File, Content, Copy, Folder,
4
4
  entityCollection, entityOps, entityIdField, entityClassName,
5
- opRequestShape, opParams, entityPath,
5
+ opRequestShape, opParams, ownPoint, entityPath,
6
6
  collectDeps, repoInfo, packageName, packageVersion, apiName, envName,
7
7
  authorInfo, contributorList, isAuthActive, jsKey, jsProp,
8
8
  SdkGenError,
@@ -74,12 +74,19 @@ function requiredKeys(ent: any, opname: string): string[] {
74
74
  // `<entity>_id` path param to `id` besides, which is why most APIs never reach
75
75
  // the fallback.
76
76
  //
77
- // When it does NOT answer, the record key is the LAST required path param of a
78
- // single-item op: a route addresses parents first and the record last, by
79
- // construction. Without this the key was simply unknown, so a param named
80
- // `code` failed the `!== idf` test in opParentKeys and was classified as a
81
- // PARENT — `load$('SAVE20')` threw "coupon load: code is required" instead of
82
- // loading anything, and the entity was treated as nested throughout.
77
+ // When it does NOT answer, the record key is the LAST path param of the
78
+ // op's own point, in PATH order — a route addresses parents first and the
79
+ // record last, by construction. Without this the key was simply unknown,
80
+ // so a param named `code` failed the `!== idf` test in opParentKeys and
81
+ // was classified as a PARENT — `load$('SAVE20')` threw "coupon load: code
82
+ // is required" instead of loading anything, and the entity was treated as
83
+ // nested throughout.
84
+ //
85
+ // opParams(op) is NOT the source here: it alphabetizes params for output
86
+ // stability, which loses path order on a 3+-param route — Airtable's
87
+ // record (base_id, table_id, record_id) alphabetizes with table_id last,
88
+ // so the old `params[params.length - 1]` picked the wrong parent as the
89
+ // record's own key. The point's own `parts` still has the true order.
83
90
  function recordKey(ent: any): string {
84
91
  const idf = entityIdField(ent)
85
92
  if (null != idf && '' !== idf) {
@@ -88,13 +95,18 @@ function recordKey(ent: any): string {
88
95
 
89
96
  for (const opname of ['load', 'remove', 'update']) {
90
97
  const op = (ent.op || {})[opname]
91
- if (null == op) {
98
+ if (null == op || 0 === (op.points || []).length) {
92
99
  continue
93
100
  }
94
101
 
95
- const params = opParams(op).filter((p: any) => false !== p.reqd)
96
- if (0 < params.length) {
97
- return String((params[params.length - 1] as any).name)
102
+ const canonical = op.points.filter((pt: any) =>
103
+ null == (pt && pt.select && pt.select['$action']))
104
+ const point = ownPoint(0 < canonical.length ? canonical : op.points)
105
+ const parts: string[] = (point && point.parts) || []
106
+ const lastParam = [...parts].reverse().find((p: string) => p.startsWith('{'))
107
+
108
+ if (null != lastParam) {
109
+ return lastParam.slice(1, -1)
98
110
  }
99
111
  }
100
112
 
@@ -966,4 +978,5 @@ if ('undefined' !== typeof module) {
966
978
 
967
979
  export {
968
980
  Main,
981
+ recordKey,
969
982
  }
@@ -153,6 +153,92 @@ function unwrapListData(data: any): any[] | null {
153
153
  })
154
154
 
155
155
 
156
+ // GraphQL-backed op: a REST-shaped direct() call (GET, params in the URL)
157
+ // cannot reach it — every op, including list, synthesizes POST with the
158
+ // query/variables as a JSON body, not URL params (see
159
+ // MakeFetchDefUtility: spec.body only ever comes from an explicit `body`
160
+ // field, never derived from `params`). apidef already built a real, valid
161
+ // query or mutation document per point (point.graphql.doc, with variables
162
+ // declared to match), so reuse that verbatim through the SDK's own
163
+ // graphql() escape hatch instead of re-deriving a REST-shaped call that
164
+ // cannot represent one.
165
+ function generateDirectGraphql(
166
+ opname: 'load' | 'list',
167
+ entity: ModelEntity,
168
+ point: any,
169
+ strict: boolean,
170
+ ) {
171
+ const doc: string = point.graphql.doc
172
+ const vars: any[] = point.graphql.vars || []
173
+
174
+ const varLine = (target: string, key: string, v: any) =>
175
+ ` ${target}[${JSON.stringify(v.name)}] = ${key}`
176
+
177
+ const mockVarLines = vars.map((v: any, i: number) =>
178
+ varLine('variables', `'direct0${i + 1}'`, v)).join('\n')
179
+
180
+ const liveVarLines = vars.map((v: any) => {
181
+ const from = v.from || v.name
182
+ const key = ('id' === from ? entity.name : from.replace(/_id$/, '')) + '01'
183
+ return varLine('variables', `setup.idmap['${key}']`, v)
184
+ }).join('\n')
185
+
186
+ const liveIdKeys = vars.map((v: any) => {
187
+ const from = v.from || v.name
188
+ return ('id' === from ? entity.name : from.replace(/_id$/, '')) + '01'
189
+ })
190
+
191
+ const skipMissingLine = 0 < liveIdKeys.length
192
+ ? ` if (skipIfMissingIds(t, setup, ${JSON.stringify(liveIdKeys)})) return\n`
193
+ : ''
194
+
195
+ // Asserted against the OUTGOING request body (what we sent), not the
196
+ // mocked response — response-shape correctness is the entity-level
197
+ // load/list tests' job; direct/graphql only has to prove the raw path
198
+ // reaches the endpoint with the right method and payload.
199
+ const varAsserts = vars.map((_v: any, i: number) =>
200
+ ' assert(calls[0].init.body.includes(\'direct0' + (i + 1) + '\'))\n').join('')
201
+
202
+ const offlineChecks = ` assert(result.ok === true)
203
+ assert(result.status === 200)
204
+ assert(null != result.data)
205
+ assert(calls.length === 1)
206
+ assert(calls[0].init.method === 'POST')
207
+ ${varAsserts}`
208
+
209
+ const checks = strict ?
210
+ offlineChecks.replace(/^ {6}/gm, ' ').replace(/^ {4}$/gm, '') :
211
+ ` if (setup.live) {
212
+ // Live mode is lenient: synthetic ids frequently fail server-side
213
+ // validation. Skip rather than fail when the call doesn't come back
214
+ // clean.
215
+ if (!result.ok || result.status < 200 || result.status >= 300) {
216
+ return
217
+ }
218
+ } else {
219
+ ${offlineChecks} }`
220
+
221
+ Content(`
222
+ test('direct-${opname}-${entity.name}', async (t: any) => {
223
+ const setup = directSetup()
224
+ if (maybeSkipControl(t, 'direct', 'direct-${opname}-${entity.name}', setup.live)) return
225
+ ${skipMissingLine} const { client, calls } = setup
226
+
227
+ const variables: any = {}
228
+ if (setup.live) {
229
+ ${liveVarLines || ' // no variables'}
230
+ } else {
231
+ ${mockVarLines || ' // no variables'}
232
+ }
233
+
234
+ const result: any = await client.graphql(${JSON.stringify(doc)}, variables)
235
+
236
+ ${checks}
237
+ })
238
+ `)
239
+ }
240
+
241
+
156
242
  function generateDirectLoad(model: Model, entity: ModelEntity, strict: boolean) {
157
243
  const loadOp = entity.op?.load
158
244
  const loadPoint: ModelPoint | undefined = loadOp?.points?.[0]
@@ -161,6 +247,11 @@ function generateDirectLoad(model: Model, entity: ModelEntity, strict: boolean)
161
247
  return
162
248
  }
163
249
 
250
+ if ('graphql' === (loadPoint as any).kind) {
251
+ generateDirectGraphql('load', entity, loadPoint, strict)
252
+ return
253
+ }
254
+
164
255
  const allLoadParams = loadPoint.args?.params || []
165
256
  const loadPath = normalizePathParams(loadPoint.parts || [], allLoadParams, loadPoint.rename?.param)
166
257
 
@@ -377,6 +468,11 @@ function generateDirectList(model: Model, entity: ModelEntity, strict: boolean)
377
468
  return
378
469
  }
379
470
 
471
+ if ('graphql' === (listPoint as any).kind) {
472
+ generateDirectGraphql('list', entity, listPoint, strict)
473
+ return
474
+ }
475
+
380
476
  const listParams = listPoint.args?.params || []
381
477
  const listPath = normalizePathParams(listPoint.parts || [], listParams, listPoint.rename?.param)
382
478
 
@@ -11,7 +11,7 @@ clean:
11
11
  rm -rf bin obj test/bin test/obj
12
12
 
13
13
  # --- Release: tag for NuGet ------------------------------------------------
14
- # NuGet publication is pending (see model/target/csharp.aontu publish block);
14
+ # NuGet publication is pending (see model/target/csharp.aon publish block);
15
15
  # publishing a version currently means pushing the tag csharp/vX.Y.Z. When the
16
16
  # registry goes live, `dotnet pack` + `dotnet nuget push` slot in before the
17
17
  # tag (mirroring the voxgig/struct csharp Makefile). Run headless under the
@@ -5,8 +5,28 @@ module VoxgigStruct
5
5
  # --- Debug Logging Configuration ---
6
6
  DEBUG = false
7
7
 
8
- def self.log(msg)
9
- puts "[DEBUG] #{msg}" if DEBUG
8
+ # LAZY. The argument to a Ruby method is evaluated BEFORE the call, so
9
+ # `log("... #{val.inspect} ...")` built its message on every call and then
10
+ # threw it away, because DEBUG is false. `val` here is a node of the API
11
+ # model, and `inspect` serialises the whole subtree — so each property access
12
+ # cost O(size of the tree it was reading from), turning a walk over the model
13
+ # into O(n^2).
14
+ #
15
+ # Measured on gitlab-sdk (276 entities in config.options), which constructs
16
+ # one client per generated test:
17
+ #
18
+ # validate(options) 4.620s -> 0.039s (118x)
19
+ # client construction 5.790s -> 0.041s (141x)
20
+ # per-entity validate 0.0182s -> 0.0001s, and flat instead of climbing
21
+ #
22
+ # The whole rb suite took 7,894s against 431s for ts and 255s for php on the
23
+ # SAME model. Ruby was not slow; this was.
24
+ #
25
+ # A block is not evaluated unless yielded, so with DEBUG false the message
26
+ # now costs nothing. Call sites pass `log { "..." }`, never `log("...")`.
27
+ def self.log(msg = nil)
28
+ return unless DEBUG
29
+ puts "[DEBUG] #{block_given? ? yield : msg}"
10
30
  end
11
31
 
12
32
  # --- Helper to convert internal undefined marker to Ruby nil ---
@@ -137,33 +157,33 @@ module VoxgigStruct
137
157
  # --- Internal getprop ---
138
158
  # Returns the value if found; otherwise returns alt (default is UNDEF)
139
159
  def self._getprop(val, key, alt = UNDEF)
140
- log("(_getprop) called with val=#{val.inspect} and key=#{key.inspect}")
160
+ log { "(_getprop) called with val=#{val.inspect} and key=#{key.inspect}" }
141
161
  return alt if val.nil? || key.nil?
142
162
  if islist(val)
143
163
  key = (key.to_s =~ /\A\d+\z/) ? key.to_i : key
144
164
  unless key.is_a?(Numeric) && key >= 0 && key < val.size
145
- log("(_getprop) index #{key.inspect} out of bounds; returning alt")
165
+ log { "(_getprop) index #{key.inspect} out of bounds; returning alt" }
146
166
  return alt
147
167
  end
148
168
  result = val[key]
149
- log("(_getprop) returning #{result.inspect} from array for key #{key}")
169
+ log { "(_getprop) returning #{result.inspect} from array for key #{key}" }
150
170
  return result
151
171
  elsif ismap(val)
152
172
  key_str = key.to_s
153
173
  if val.key?(key_str)
154
174
  result = val[key_str]
155
- log("(_getprop) found key #{key_str.inspect} in hash, returning #{result.inspect}")
175
+ log { "(_getprop) found key #{key_str.inspect} in hash, returning #{result.inspect}" }
156
176
  return result
157
177
  elsif key.is_a?(String) && val.key?(key.to_sym)
158
178
  result = val[key.to_sym]
159
- log("(_getprop) found symbol key #{key.to_sym.inspect} in hash, returning #{result.inspect}")
179
+ log { "(_getprop) found symbol key #{key.to_sym.inspect} in hash, returning #{result.inspect}" }
160
180
  return result
161
181
  else
162
- log("(_getprop) key #{key.inspect} not found; returning alt")
182
+ log { "(_getprop) key #{key.inspect} not found; returning alt" }
163
183
  return alt
164
184
  end
165
185
  else
166
- log("(_getprop) value is not a node; returning alt")
186
+ log { "(_getprop) value is not a node; returning alt" }
167
187
  alt
168
188
  end
169
189
  end
@@ -210,7 +230,7 @@ module VoxgigStruct
210
230
  end
211
231
 
212
232
  def self.setprop(parent, key, val = :no_val_provided)
213
- log(">>> setprop called with parent=#{parent.inspect}, key=#{key.inspect}, val=#{val.inspect}")
233
+ log { ">>> setprop called with parent=#{parent.inspect}, key=#{key.inspect}, val=#{val.inspect}" }
214
234
  return parent unless iskey(key)
215
235
  if ismap(parent)
216
236
  key_str = key.to_s
@@ -236,7 +256,7 @@ module VoxgigStruct
236
256
  end
237
257
  end
238
258
  end
239
- log("<<< setprop result: #{parent.inspect}")
259
+ log { "<<< setprop result: #{parent.inspect}" }
240
260
  parent
241
261
  end
242
262
 
@@ -12,7 +12,7 @@ clean:
12
12
 
13
13
  # --- Release: tag for the SwiftPM registry ---------------------------------
14
14
  # SwiftPM has no upload step - publishing IS the git tag. Publication is
15
- # pending (see model/target/swift.aontu publish block); publishing a version
15
+ # pending (see model/target/swift.aon publish block); publishing a version
16
16
  # currently means pushing the tag swift/vX.Y.Z. Run headless under the boru key
17
17
  # vault, which injects the GitHub token so nothing lands on disk or argv:
18
18
  # boru vault exec --for=github=<alias> -- make publish
@@ -8,6 +8,35 @@ import { BaseFeature } from '../base/BaseFeature'
8
8
  const S_NOT_FOUND = 'Not found'
9
9
 
10
10
 
11
+ // Which param is entity X's own identifier, as opposed to a parent key —
12
+ // the load op's canonical point's LAST path segment, by construction (a
13
+ // route addresses parents first, the record last). Mirrors recordKey in
14
+ // sdkgen's Main_seneca-provider.ts; written again here because a template
15
+ // ships standalone, outside that package. A renamed id (e.g. Airtable's
16
+ // record_id) needs its own seeded field: matching only ever happens
17
+ // against the API's real param names, never a bare 'id' the API itself
18
+ // does not use.
19
+ function ownIdField(config: any, getpath: any, entityName: string): string {
20
+ for (const opname of ['load', 'remove', 'update']) {
21
+ const points = getpath(config, ['entity', entityName, 'op', opname, 'points']) || []
22
+ const canonical = points.filter((pt: any) =>
23
+ null == (pt && pt.select && pt.select['$action']))
24
+ const use = 0 < canonical.length ? canonical : points
25
+ let best = use[0]
26
+ for (const pt of use) {
27
+ if (null == pt || null == pt.parts || null == best || null == best.parts) continue
28
+ const ptterm = 0 < pt.parts.length && String(pt.parts[pt.parts.length - 1]).startsWith('{')
29
+ const bestterm = 0 < best.parts.length && String(best.parts[best.parts.length - 1]).startsWith('{')
30
+ if (ptterm !== bestterm ? ptterm : pt.parts.length < best.parts.length) best = pt
31
+ }
32
+ const parts: string[] = (best && best.parts) || []
33
+ const last = [...parts].reverse().find((p: string) => p.startsWith('{'))
34
+ if (null != last) return last.slice(1, -1)
35
+ }
36
+ return 'id'
37
+ }
38
+
39
+
11
40
  class TestFeature extends BaseFeature {
12
41
  version = '0.0.1'
13
42
  name = 'test'
@@ -30,10 +59,16 @@ class TestFeature extends BaseFeature {
30
59
 
31
60
  this._client._mode = 'test'
32
61
 
62
+ const getpath = struct.getpath
63
+
33
64
  // Ensure entity ids are correct.
34
65
  walk(entity, (k: any, v: any, _parent: any, path: any) => {
35
66
  if (2 === size(path)) {
36
67
  setprop(v, 'id', k)
68
+ const idField = ownIdField(ctx.config, getpath, String(path[0]))
69
+ if ('id' !== idField) {
70
+ setprop(v, idField, k)
71
+ }
37
72
  }
38
73
  return v
39
74
  })
@@ -172,6 +207,16 @@ class TestFeature extends BaseFeature {
172
207
 
173
208
  const ent = clone(ctx.reqdata)
174
209
  setprop(ent, 'id', id)
210
+
211
+ // A record created during the run needs the same real-key seeding
212
+ // the initial walk gives seed data (see ownIdField above) — without
213
+ // it, only `id` is set, and a load by the entity's own key right
214
+ // after create (recordKey !== 'id') finds nothing.
215
+ const idField = ownIdField(ctx.config, struct.getpath, getprop(op, 'entity'))
216
+ if ('id' !== idField && null == getprop(ent, idField)) {
217
+ setprop(ent, idField, id)
218
+ }
219
+
175
220
  setprop(entmap, id, ent)
176
221
  delprop(ent, '$KEY')
177
222
  const out = clone(ent)
@@ -310,7 +355,8 @@ class TestFeature extends BaseFeature {
310
355
 
311
356
 
312
357
  export {
313
- TestFeature
358
+ TestFeature,
359
+ ownIdField,
314
360
  }
315
361
 
316
362
 
@@ -3,7 +3,7 @@
3
3
  "package": 1
4
4
  },
5
5
  "name": "@voxgig/sdkgen",
6
- "version": "3.7.2",
6
+ "version": "4.0.0",
7
7
  "provides": {
8
8
  "target": [
9
9
  "c",
@@ -12,7 +12,7 @@ import type {
12
12
  } from '../types'
13
13
 
14
14
 
15
- const indexEntry = (name: string) => `@"${name}.aontu"`
15
+ const indexEntry = (name: string) => `@"${name}.aon"`
16
16
 
17
17
 
18
18
  // An index line that is an ACTIVE include, and the name it includes — or
@@ -21,17 +21,17 @@ const indexEntry = (name: string) => `@"${name}.aontu"`
21
21
  // Parsed rather than compared as a string, because both spellings around an
22
22
  // include are legal aontu and mean opposite things:
23
23
  //
24
- // @"go.aontu" -> active, name 'go'
25
- // @"go.aontu" # pinned -> active, name 'go' (trailing comment)
26
- // @"go.aontu" -> active, name 'go' (indented)
27
- // # @"go.aontu" -> NOT active
24
+ // @"go.aon" -> active, name 'go'
25
+ // @"go.aon" # pinned -> active, name 'go' (trailing comment)
26
+ // @"go.aon" -> active, name 'go' (indented)
27
+ // # @"go.aon" -> NOT active
28
28
  //
29
29
  // A substring test (what this used to be) reads the commented-out form as
30
30
  // present, so `target add go` on a project that had switched the target off
31
31
  // by hand appended nothing and reported success while the target stayed
32
32
  // absent from the model. A whole-line equality test fixes that but then
33
33
  // misses the trailing-comment form, and appends a SECOND active include.
34
- const INDEX_ENTRY_RE = /^\s*@"([^"]+)\.aontu"\s*(?:#.*)?$/
34
+ const INDEX_ENTRY_RE = /^\s*@"([^"]+)\.aon"\s*(?:#.*)?$/
35
35
 
36
36
  function indexEntryName(line: string): string | undefined {
37
37
  const m = line.match(INDEX_ENTRY_RE)
@@ -46,7 +46,7 @@ function hasIndexEntry(content: string, name: string): boolean {
46
46
  }
47
47
 
48
48
 
49
- // Append `@"<name>.aontu"` import lines for each name not already present in
49
+ // Append `@"<name>.aon"` import lines for each name not already present in
50
50
  // the index content. Checking against the accumulating result (not the
51
51
  // original) means duplicate names in the same call are added at most once.
52
52
  function appendIndexEntries(content: string, names: string[]): string {
@@ -62,7 +62,7 @@ function appendIndexEntries(content: string, names: string[]): string {
62
62
  }
63
63
 
64
64
 
65
- // Drop the `@"<name>.aontu"` line for each name — the inverse of
65
+ // Drop the `@"<name>.aon"` line for each name — the inverse of
66
66
  // appendIndexEntries, matching line-exact for the same reasons.
67
67
  //
68
68
  // Nothing calls this yet: a `remove` action is the fast-follow this exists
@@ -100,12 +100,12 @@ function parseAddNames(args: any[]): string[] {
100
100
  // The current index file for each kind, which `UpdateIndex` appends to.
101
101
  //
102
102
  // `seed` IS THE UPGRADE PATH. A project scaffolded before a kind existed has
103
- // no `model/<kind>/<kind>-index.aontu` — every project alive today is in
103
+ // no `model/<kind>/<kind>-index.aon` — every project alive today is in
104
104
  // exactly that position for `docs` — and reading it unguarded made the FIRST
105
105
  // `docs add` in any existing project fail on ENOENT before it wrote anything.
106
106
  //
107
107
  // Seeded per call rather than defaulted for every kind: a missing
108
- // `target-index.aontu` in a scaffolded project is a broken project, and
108
+ // `target-index.aon` in a scaffolded project is a broken project, and
109
109
  // quietly recreating it would hide that. A kind the project has never used is
110
110
  // a different thing, and only its own action knows which case it is in.
111
111
  function loadContent(
@@ -119,7 +119,7 @@ function loadContent(
119
119
  const modelfolder = Path.dirname(actx.url)
120
120
 
121
121
  which.map((w: string) => {
122
- const indexfile = Path.join(modelfolder, w, w + '-index.aontu')
122
+ const indexfile = Path.join(modelfolder, w, w + '-index.aon')
123
123
 
124
124
  content[`${w}_index`] = (null != seed?.[w] && !fs.existsSync(indexfile)) ?
125
125
  seed[w] : fs.readFileSync(indexfile, 'utf8')
@@ -136,7 +136,7 @@ function loadContent(
136
136
  // indexes of the kinds that existed then. So a project scaffolded before a
137
137
  // kind existed — which is every project alive today, for `docs` — never
138
138
  // includes its index, and the item's model file is an orphan: it is on disk,
139
- // `<kind>-index.aontu` includes it, and NOTHING includes that. `main.kit.docs`
139
+ // `<kind>-index.aon` includes it, and NOTHING includes that. `main.kit.docs`
140
140
  // is then absent from the compiled model, so `package list`, `package update`
141
141
  // and `doctor` cannot see the item at all.
142
142
  //
@@ -302,7 +302,7 @@ function checkDefinition(
302
302
  return found
303
303
  }
304
304
 
305
- // 4. Does it declare the key its FILENAME promises? `model/target/iot-go.aontu`
305
+ // 4. Does it declare the key its FILENAME promises? `model/target/iot-go.aon`
306
306
  // is copied to that name and included by it, so a file still declaring
307
307
  // `main: kit: target: go:` installs an item the consumer's model never
308
308
  // sees — the exact mistake a package author makes copying a bundled
@@ -474,7 +474,7 @@ function checkFeatureSource(
474
474
  level: 'warn', point: 'feature-source-unrecognised', file,
475
475
  kind: 'target', name: tname,
476
476
  note: 'tm/' + tname + ': ' + strays.map((e) => e.path).join(', ') +
477
- ' — named like feature source, but no `model/feature/<name>.aontu` ' +
477
+ ' — named like feature source, but no `model/feature/<name>.aon` ' +
478
478
  'declares ' + strays.map((e) => e.name).join(', ') +
479
479
  ', so the trim cannot recognise them and every project gets them ' +
480
480
  'whatever its model selects'