@voxgig/sdkgen-infrapack 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,6 +16,11 @@ main: kit: target: 'seneca-provider': {
16
16
  test: { active: false }
17
17
  }
18
18
 
19
+ sdk: {
20
+ package: *'' | string
21
+ version: *'' | string
22
+ }
23
+
19
24
  sdk: dep: {
20
25
  kind: *'npm' | 'git' | 'release'
21
26
  ref: *'' | string
@@ -2,6 +2,7 @@ import {
2
2
  cmp, each,
3
3
  File, Content, Folder,
4
4
  jsKey, jsProp,
5
+ npmTrustScript,
5
6
  pointSegments,
6
7
  } from '@voxgig/sdkgen'
7
8
 
@@ -23,7 +24,12 @@ const SdkPin = cmp(function SdkPin(props: any) {
23
24
 
24
25
  File({ name: 'sdk-pin.json' }, () => {
25
26
  Content(JSON.stringify({
26
- note: 'GENERATED. The SDK this provider is generated from. ' +
27
+ note: provider.standalone ?
28
+ 'GENERATED. The SDK this provider depends on and is generated from. ' +
29
+ '`make sdk-src` fetches it; `make regen` replaces the API definition ' +
30
+ 'and guide in .sdk/ with its own and regenerates this repo. Set the version in ' +
31
+ '.sdk/model/project.aontu, not in this file.' :
32
+ 'GENERATED. The SDK this provider is generated from. ' +
27
33
  '`make sdk-src` fetches it; `make regen` regenerates this repo ' +
28
34
  'from it. Edit the SDK project model, not this file.',
29
35
  repo: provider.sdkRepoUrl,
@@ -1394,9 +1400,38 @@ async function run() {
1394
1400
 
1395
1401
 
1396
1402
 
1403
+ const PUBLISH_WORKFLOW = 'publish.yml'
1404
+
1405
+
1397
1406
  const Workflow = cmp(function Workflow(props: any) {
1398
1407
  const { provider } = props
1399
1408
 
1409
+ // The script runs from the provider's own .sdk, which only a standalone
1410
+ // builder has, and npm trusts GitHub Actions only on github.com.
1411
+ const repository = 'github.com' === String(provider.repoHost).toLowerCase() ?
1412
+ provider.repoPath : null
1413
+ const trusted = provider.standalone && null != repository
1414
+ const trustNote = !trusted ? '' :
1415
+ '#\n' +
1416
+ '# .sdk/admin/setup-npm-trust.sh registers exactly this, and with --check\n' +
1417
+ '# reports any drift from it:\n' +
1418
+ '#\n' +
1419
+ `# npm trust github ${provider.pkgName} \\\n` +
1420
+ `# --repository ${provider.repoPath} \\\n` +
1421
+ `# --file ${PUBLISH_WORKFLOW} \\\n` +
1422
+ '# --allow-publish\n'
1423
+
1424
+ if (provider.standalone) {
1425
+ Folder({ name: '.sdk' }, () => {
1426
+ Folder({ name: 'admin' }, () => {
1427
+ File({ name: 'setup-npm-trust.sh', mode: 0o755 }, () => {
1428
+ Content(npmTrustScript(repository,
1429
+ [{ pkg: provider.pkgName, file: PUBLISH_WORKFLOW }]))
1430
+ })
1431
+ })
1432
+ })
1433
+ }
1434
+
1400
1435
  Folder({ name: '.github' }, () => {
1401
1436
  Folder({ name: 'workflows' }, () => {
1402
1437
  File({ name: 'build.yml' }, () => {
@@ -1499,7 +1534,7 @@ ${!provider.liveApp ? '' : `
1499
1534
  `)
1500
1535
  })
1501
1536
 
1502
- File({ name: 'publish.yml' }, () => {
1537
+ File({ name: PUBLISH_WORKFLOW }, () => {
1503
1538
  Content(`# Generated by @voxgig/sdkgen. Do not edit.
1504
1539
  #
1505
1540
  # Publishes ${provider.pkgName} to npm on a \`v*\` tag push, via GitHub OIDC
@@ -1524,9 +1559,9 @@ ${!provider.liveApp ? '' : `
1524
1559
  # is what makes the isolation real rather than nominal.
1525
1560
  #
1526
1561
  # The trusted publisher must be registered on npmjs.com for this package
1527
- # against THIS filename (publish.yml); renaming this file breaks publishing
1562
+ # against THIS filename (${PUBLISH_WORKFLOW}); renaming this file breaks publishing
1528
1563
  # until the npm-side config is updated to match.
1529
- #
1564
+ ${trustNote}#
1530
1565
  # npm cannot configure a trusted publisher for a package that does not exist
1531
1566
  # yet — the settings page appears once a version is on the registry. So the
1532
1567
  # FIRST version of a new package is published by hand, once, with an
@@ -1718,7 +1753,8 @@ const seneca = Seneca()
1718
1753
  },
1719
1754
  },
1720
1755
  })
1721
- .use('${provider.pkgName}')
1756
+ .use('${provider.pkgName}'${'' === provider.specBase ?
1757
+ `, { sdk: { base: 'https://${provider.lower}.example.com' } }` : ''})
1722
1758
 
1723
1759
  await seneca.ready()
1724
1760
 
@@ -1734,7 +1770,7 @@ await seneca.ready()
1734
1770
  `'${cparts.map((p: string) => 'some-' + p).join(
1735
1771
  null != subject.idsep && '' !== String(subject.idsep) ?
1736
1772
  String(subject.idsep) : '/')}'` :
1737
- 0 === subject.parents.length ? `'some-id'` :
1773
+ 0 === subject.parents.length ? (loadHasKey(subject) ? `'some-id'` : '') :
1738
1774
  `{ ` + subject.parents.map((p: string) => `${jsKey(p)}: 'some-${p}'`).join(', ') +
1739
1775
  `, id: 'some-id' }`
1740
1776
  Content(`const ${subject.name} = await seneca
@@ -1898,7 +1934,10 @@ whose logs cannot be read.
1898
1934
  each(provider.entities, (e: any) => {
1899
1935
  each(e.cmds, (cmd: any) => {
1900
1936
  const c = String(cmd.val$ ?? cmd)
1901
- Content(`| \`sys:entity,cmd:${c},zone:provider,base:${provider.lower},name:${e.name}\` | ${CMD_DESC[c]}. |
1937
+ const desc = 'save' !== c ? CMD_DESC[c] :
1938
+ !e.ops.includes('update') ? 'Create a record' :
1939
+ !e.ops.includes('create') ? 'Update a record' : CMD_DESC[c]
1940
+ Content(`| \`sys:entity,cmd:${c},zone:provider,base:${provider.lower},name:${e.name}\` | ${desc}. |
1902
1941
  `)
1903
1942
  })
1904
1943
  })
@@ -1978,9 +2017,15 @@ const sdk = seneca.export('${provider.pluginName}/sdk')()
1978
2017
 
1979
2018
  ## Contributing
1980
2019
 
1981
- This plugin is GENERATED. Changes belong in the SDK project's model and
2020
+ ${provider.standalone ?
2021
+ `This plugin is GENERATED, by the builder in \`.sdk/\` from the API definition
2022
+ of the SDK it depends on. Changes to the API belong in the SDK project's
2023
+ model, this package's own decisions in \`.sdk/model/project.aontu\`, and
2024
+ everything else in the components that build this target — anything edited
2025
+ elsewhere in this repository is overwritten by the next \`make regen\`.` :
2026
+ `This plugin is GENERATED. Changes belong in the SDK project's model and
1982
2027
  components, not here — anything edited in this repository is overwritten by
1983
- the next generation run.
2028
+ the next generation run.`}
1984
2029
 
1985
2030
  The [Senecajs org](http://senecajs.org) encourages open participation. If you
1986
2031
  feel you can help in any way, be it with bug reporting, documentation,
@@ -2677,6 +2722,7 @@ const DocHowto = cmp(function DocHowto(props: any) {
2677
2722
  // an absent value to the same thing, so a missing base is treated as absent
2678
2723
  // rather than printed as a default nobody can use.
2679
2724
  const liveBase = provider.liveBase || ''
2725
+ const specBase = provider.specBase || ''
2680
2726
 
2681
2727
  // The same choice the tests and the manual scripts make: fewest parent keys
2682
2728
  // (nothing to arrange), then most cmds. Recipes prefer it, so one entity
@@ -2698,6 +2744,7 @@ const DocHowto = cmp(function DocHowto(props: any) {
2698
2744
  // the API calls it. `apiKey` is that name, for the SDK-direct examples.
2699
2745
  const idf = (_e: any) => 'id'
2700
2746
  const apiKey = (e: any) => e.rk || 'id'
2747
+ const modelsId = (e: any) => null != e.ent?.id || null != (e.ent?.fields || {})[apiKey(e)]
2701
2748
 
2702
2749
  // A parent key's example value. This MIRRORS seedRecord rather than
2703
2750
  // inventing something more readable: the offline recipe below seeds with
@@ -2767,7 +2814,15 @@ const DocHowto = cmp(function DocHowto(props: any) {
2767
2814
 
2768
2815
  const eList = forCmd('list')
2769
2816
  const eLoad = forCmd('load')
2770
- const eSave = forCmd('save')
2817
+ const forOp = (op: string) => {
2818
+ const able = ents.filter((e: any) => e.ops.includes(op))
2819
+ return able.find((e: any) => e === subject) ||
2820
+ able.find((e: any) => 0 === e.parents.length) ||
2821
+ able[0] || null
2822
+ }
2823
+
2824
+ const eCreate = forOp('create')
2825
+ const eUpdate = forOp('update')
2771
2826
  const eRemove = forCmd('remove')
2772
2827
 
2773
2828
  // Sections as data, so the contents list and the sections cannot disagree.
@@ -2841,53 +2896,63 @@ genuinely wrong.`)
2841
2896
  }
2842
2897
 
2843
2898
 
2844
- if (null != eSave) {
2845
- const created = literal(createData(eSave))
2899
+ if (null != eCreate) {
2900
+ const created = literal(createData(eCreate))
2901
+ const updates = eCreate.ops.includes('update')
2846
2902
 
2847
- sec('Create a record', `\`make$\` builds an entity and \`save$\` writes it. An entity with no id
2848
- is a create:
2903
+ sec('Create a record', `\`make$\` builds an entity and \`save$\` writes it. ${updates ?
2904
+ 'An entity with no id\nis a create:' :
2905
+ `The API has no update\nfor a \`${eCreate.name}\`, so \`save$\` always creates one, even from an entity\nthat carries an id:`}
2849
2906
 
2850
2907
  \`\`\`js
2851
- const ${eSave.name} = await seneca
2852
- .entity('${canon(eSave)}')
2908
+ const ${eCreate.name} = await seneca
2909
+ .entity('${canon(eCreate)}')
2853
2910
  .make$(${created})
2854
2911
  .save$()
2855
2912
 
2856
- console.log(${eSave.name}.${idf(eSave)})
2913
+ console.log(${eCreate.name}${modelsId(eCreate) ? '.' + idf(eCreate) : ''})
2857
2914
  \`\`\`
2858
- ${0 === eSave.parents.length ? '' : `
2859
- Note that \`${eSave.parents.join('`, `')}\` travels in the DATA for a write,
2860
- not in a query: a \`${eSave.name}\` is created inside its parent.
2915
+ ${0 === eCreate.parents.length ? '' : `
2916
+ Note that \`${eCreate.parents.join('`, `')}\` travels in the DATA for a write,
2917
+ not in a query: a \`${eCreate.name}\` is created inside its parent.
2861
2918
  `}
2862
- \`save$\` resolves to the record as the API returned it, which is the only
2919
+ ${modelsId(eCreate) ?
2920
+ `\`save$\` resolves to the record as the API returned it, which is the only
2863
2921
  reliable source of the id. Read it from there rather than predicting it:
2864
2922
  what an API does with an id you supply on create is its own business, and
2865
- several ignore it entirely.`)
2923
+ several ignore it entirely.` :
2924
+ `\`save$\` resolves to the record as the API returned it. The API definition
2925
+ declares no id for a \`${eCreate.name}\`, so the record is the only place to
2926
+ read what identifies one.`}`)
2927
+ }
2928
+
2866
2929
 
2867
- const f = changeable(eSave)
2930
+ if (null != eUpdate) {
2931
+ const f = changeable(eUpdate)
2868
2932
 
2869
- sec('Update a record', `The same call updates. \`save$\` dispatches on the id: an entity carrying
2870
- one is an update, an entity without one is a create. So the safe shape is
2933
+ sec('Update a record', `${eUpdate.ops.includes('create') ?
2934
+ 'The same call updates. `save$` dispatches on the id: an entity carrying\none is an update, an entity without one is a create.' :
2935
+ `\`save$\` updates: the API has no create for a \`${eUpdate.name}\`.`} So the safe shape is
2871
2936
  load, change, save:
2872
2937
 
2873
- \`\`\`js${eSave.cmds.includes('load') ? `
2874
- const ${eSave.name} = await seneca
2875
- .entity('${canon(eSave)}')
2876
- .load$(${oneArgs(eSave)})
2938
+ \`\`\`js${eUpdate.cmds.includes('load') ? `
2939
+ const ${eUpdate.name} = await seneca
2940
+ .entity('${canon(eUpdate)}')
2941
+ .load$(${oneArgs(eUpdate)})
2877
2942
  ` : `
2878
- const ${eSave.name} = seneca
2879
- .entity('${canon(eSave)}')
2880
- .make$(${literal(0 === eSave.parents.length ?
2881
- { [idf(eSave)]: `${eSave.name}0` } :
2882
- { ...Object.fromEntries(eSave.parents.map(
2883
- (k: string) => [k, parentVal(eSave, k)])),
2884
- [idf(eSave)]: `${eSave.name}0` })})
2943
+ const ${eUpdate.name} = seneca
2944
+ .entity('${canon(eUpdate)}')
2945
+ .make$(${literal(0 === eUpdate.parents.length ?
2946
+ { [idf(eUpdate)]: `${eUpdate.name}0` } :
2947
+ { ...Object.fromEntries(eUpdate.parents.map(
2948
+ (k: string) => [k, parentVal(eUpdate, k)])),
2949
+ [idf(eUpdate)]: `${eUpdate.name}0` })})
2885
2950
  `}${null == f ? `
2886
2951
  // change the fields you need
2887
2952
  ` : `
2888
- ${eSave.name}.${f.name} = ${newValue(f)}
2953
+ ${eUpdate.name}.${f.name} = ${newValue(f)}
2889
2954
  `}
2890
- await ${eSave.name}.save$()
2955
+ await ${eUpdate.name}.save$()
2891
2956
  \`\`\`
2892
2957
 
2893
2958
  Mutating the record you loaded sends it as it stood plus your change, so
@@ -3027,13 +3092,16 @@ constructor, so \`base\` chooses the host:
3027
3092
  })
3028
3093
  \`\`\`
3029
3094
 
3030
- ${'' === liveBase ?
3095
+ ${'' === specBase ?
3031
3096
  `The API definition declares no server, so there is no default worth
3032
3097
  relying on: set \`base\` explicitly, or run against the mock instead (see
3033
3098
  [${OFFLINE_TITLE}](${anchor(OFFLINE_TITLE)})).` :
3034
- `The SDK's own default is \`${liveBase}\`, which is where the
3099
+ specBase === liveBase ?
3100
+ `The SDK's own default is \`${liveBase}\`, which is where the
3035
3101
  companion test server listens, so local development usually needs no
3036
- \`base\` at all.`}`)
3102
+ \`base\` at all.` :
3103
+ `The SDK's own default is \`${specBase}\`, the server the API definition
3104
+ declares, so \`base\` is needed only to reach another one.`}`)
3037
3105
 
3038
3106
 
3039
3107
  if (!provider.authActive) {
@@ -3321,10 +3389,15 @@ $ npm run repo-publish
3321
3389
  Only \`dist\`, the TypeScript sources and the licence file are published;
3322
3390
  the test suite and its build output stay in the repository.
3323
3391
 
3324
- Before publishing, check that \`package.json\` still depends on the
3392
+ ${'npm' === provider.sdkDepKind ?
3393
+ `Before publishing, check that \`package.json\` still depends on the
3325
3394
  published SDK by version range and not on a local path: a \`file:\`
3326
3395
  dependency left behind from local development installs perfectly on your
3327
- own machine and cannot be resolved by anybody else.
3396
+ own machine and cannot be resolved by anybody else.` :
3397
+ `Publish the SDK to npm first. \`package.json\` depends on it as
3398
+ \`${provider.sdkDep}\`, which everyone installing this package would
3399
+ have to fetch${'git' === provider.sdkDepKind ? ' with git' : ''}. Then drop \`sdk.dep\` from the model, regenerate,
3400
+ and check that the dependency is a version range.`}
3328
3401
 
3329
3402
  One last thing: this repository is GENERATED from the ${provider.api} API
3330
3403
  model by [@voxgig/sdkgen](https://github.com/voxgig/sdkgen). An edit made
@@ -3501,6 +3574,14 @@ Seneca({ legacy: false })
3501
3574
  if (live) {
3502
3575
  Content(` .use('${provider.pkgName}', { sdk: { base: '${provider.liveBase}' } })
3503
3576
  \`\`\`
3577
+ `)
3578
+ }
3579
+ else if ('' !== provider.specBase) {
3580
+ Content(` .use('${provider.pkgName}')
3581
+ \`\`\`
3582
+
3583
+ The SDK's default base URL is \`${provider.specBase}\`, the server the
3584
+ ${provider.api} definition declares. Pass \`sdk: { base }\` to reach another.
3504
3585
  `)
3505
3586
  }
3506
3587
  else {
@@ -3531,8 +3612,8 @@ Any option the \`${provider.sdkClass}\` constructor accepts:
3531
3612
 
3532
3613
  | Key | Effect |
3533
3614
  | --- | ------ |
3534
- | \`base\` | Base URL for API requests. ${live ?
3535
- `The SDK's own default is \`${provider.liveBase}\`.` :
3615
+ | \`base\` | Base URL for API requests. ${'' !== provider.specBase ?
3616
+ `The SDK's own default is \`${provider.specBase}\`, the server the API definition declares.` :
3536
3617
  'There is no default: this API declares no server, so it must be set.'} |
3537
3618
  | \`prefix\` / \`suffix\` | URL fragments placed around the path. |
3538
3619
  | \`headers\` | Headers sent on every request. These win over the \`authorization\` header the provider adds from a configured key. |
@@ -3618,7 +3699,9 @@ before any request is made, rather than issuing one that would 404.
3618
3699
  `)
3619
3700
  }
3620
3701
  if (e.cmds.includes('load')) {
3621
- Content(`| \`load$(q)\` | ${reqd([...e.parents, 'id'])} | One \`${e.name}\`, or \`null\` if not found. |
3702
+ Content(loadHasKey(e) ?
3703
+ `| \`load$(q)\` | ${reqd([...e.parents, 'id'])} | One \`${e.name}\`, or \`null\` if not found. |
3704
+ ` : `| \`load$(q)\` | ${0 < e.parents.length ? reqd(e.parents) : 'nothing: the route names no record'} | The one \`${e.name}\`, or \`null\` when \`id\` names one it does not carry. |
3622
3705
  `)
3623
3706
  }
3624
3707
  if (e.cmds.includes('save')) {
@@ -3636,7 +3719,8 @@ before any request is made, rather than issuing one that would 404.
3636
3719
 
3637
3720
  if ('id' !== apiKeyOf(e)) {
3638
3721
  Content(`
3639
- The API addresses \`${e.name}\` records by \`${apiKeyOf(e)}\`; the provider
3722
+ The API ${e.ops.some((op: string) => ['load', 'remove', 'update'].includes(op)) ?
3723
+ 'addresses' : 'identifies'} \`${e.name}\` records by \`${apiKeyOf(e)}\`; the provider
3640
3724
  carries that value as the entity's \`id\`, so every query and entity above
3641
3725
  uses \`id\`. A record the API returns with an unrelated \`id\` of its own
3642
3726
  ${false === e.parkfree ?
@@ -3661,7 +3745,7 @@ also defines are passed through unchanged in both directions.
3661
3745
  | ----- | ---- | ----- |
3662
3746
  `)
3663
3747
  each(e.fields, (f: any) => {
3664
- Content(`| \`${f.name}\` | ${f.kind} | ${f.name === (e.rk || 'id') ?
3748
+ Content(`| \`${f.name}\` | ${f.kind}${f.nullable ? ' or null' : ''} | ${f.name === (e.rk || 'id') ?
3665
3749
  ('id' === f.name ? 'Id field.' : 'API key; carried as the entity\'s `id`.') :
3666
3750
  e.parents.includes(f.name) ? ('' === f.parentEntity ?
3667
3751
  'Parent key. Required by every command.' :
@@ -4440,11 +4524,16 @@ rather than as a surprise in production.
4440
4524
  `)
4441
4525
 
4442
4526
  if ('' === provider.liveBase) {
4443
- Content(`The API definition declares no server, so this plugin has no default host: the
4527
+ Content(`${'' === provider.specBase ?
4528
+ `The API definition declares no server, so this plugin has no default host: the
4444
4529
  base URL arrives through the \`sdk.base\` option, supplied by whoever configures
4445
4530
  the plugin for a particular deployment. The tests therefore run entirely
4446
4531
  against the SDK's mock transport, which is the one host that is always
4447
- available.
4532
+ available.` :
4533
+ `The SDK's default host is \`${provider.specBase}\`, the server the API
4534
+ definition declares, and the \`sdk.base\` option points the plugin at another.
4535
+ Nothing declares a test server, so the tests run entirely against the SDK's
4536
+ mock transport, which is the one host that is always available.`}
4448
4537
 
4449
4538
 
4450
4539
  `)
@@ -4476,7 +4565,10 @@ that has to be applied again, silently, forever.
4476
4565
 
4477
4566
  The source of truth is the SDK project's model — the repository and tag named
4478
4567
  in \`sdk-pin.json\`, which \`make sdk-src\` fetches to \`${provider.sdkSrc}\` —
4479
- together with the sdkgen component that emits this target. A change to *what*
4568
+ together with the sdkgen component that emits this target.${provider.standalone ? `
4569
+ This repository builds itself: the builder in \`.sdk/\` carries a copy of that
4570
+ SDK's API definition, which \`make regen\` refreshes from the fetched source,
4571
+ and generation refuses to write when the copy no longer matches it.` : ''} A change to *what*
4480
4572
  the API offers belongs in the model; a change to
4481
4573
  *how* the provider expresses it belongs in the component. Both are versioned,
4482
4574
  both regenerate every provider built this way rather than just this one, and
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  cmp, each,
3
3
  File, Content, Copy, Folder,
4
- entityCollection, entityOps, entityIdField, entityClassName,
4
+ entityCollection, entityOps, entityIdField, entityDataIdField, entityClassName,
5
5
  entityActions,
6
6
  opRequestShape, opParams, ownPoint, entityPath,
7
7
  collectDeps, repoInfo, packageName, packageVersion, apiName, envName,
@@ -20,6 +20,10 @@ import {
20
20
  Tests, Scripts, Workflow, Readme, Docs, SdkPin, SDK_SRC_DIR,
21
21
  } from './Extras_seneca-provider'
22
22
  import { Gitignore } from './Gitignore_seneca-provider'
23
+ import { Makefile } from './Makefile_seneca-provider'
24
+ import {
25
+ standaloneBuilder, sdkIdentity, checkSdkSource,
26
+ } from './Standalone_seneca-provider'
23
27
 
24
28
 
25
29
 
@@ -208,7 +212,7 @@ function recordKey(ent: any): string {
208
212
  }
209
213
  }
210
214
 
211
- return '' !== fallback ? fallback : 'id'
215
+ return '' !== fallback ? fallback : (entityDataIdField(ent) || 'id')
212
216
  }
213
217
 
214
218
 
@@ -278,9 +282,18 @@ function parentKeys(ent: any): string[] {
278
282
  }
279
283
 
280
284
 
285
+ const sentinelKey = (type: any): string =>
286
+ String(type ?? '').replace(/[`$]/g, '').trim().toUpperCase()
287
+
288
+ const unionMembers = (type: any): any[] =>
289
+ Array.isArray(type) && 'ONE' === sentinelKey(type[0]) && Array.isArray(type[1]) ?
290
+ type[1] : []
291
+
292
+
281
293
  function fieldKind(type: any): string {
282
294
  if (Array.isArray(type)) {
283
- return 'string'
295
+ const member = unionMembers(type).find((m: any) => 'NULL' !== sentinelKey(m))
296
+ return null == member ? 'string' : fieldKind(member)
284
297
  }
285
298
 
286
299
  const t = String(type || '').toUpperCase()
@@ -294,6 +307,11 @@ function fieldKind(type: any): string {
294
307
  }
295
308
 
296
309
 
310
+ function fieldNullable(type: any): boolean {
311
+ return unionMembers(type).some((m: any) => 'NULL' === sentinelKey(m))
312
+ }
313
+
314
+
297
315
  function parentEntityOf(key: string, names: string[]): string {
298
316
  const stem = key.replace(/_id$/, '')
299
317
  return names.includes(stem) ? stem : ''
@@ -301,13 +319,13 @@ function parentEntityOf(key: string, names: string[]): string {
301
319
 
302
320
 
303
321
  function providerRepo(model: any, lower: string, tname: string):
304
- { url: string, path: string } {
322
+ { url: string, path: string, host: string } {
305
323
  const host = model?.main?.[KIT]?.repo?.host || 'github.com'
306
324
  const declared = model?.main?.[KIT]?.target?.[tname]?.output?.repo
307
325
  const path = null != declared && '' !== declared ?
308
326
  String(declared) : `senecajs/seneca-${lower}-provider`
309
327
 
310
- return { url: `https://${host}/${path}`, path }
328
+ return { url: `https://${host}/${path}`, path, host }
311
329
  }
312
330
 
313
331
 
@@ -326,8 +344,10 @@ const Main = cmp(function Main(props: any) {
326
344
  const { target, ctx$ } = props
327
345
  const { model } = ctx$
328
346
 
347
+ const standalone = standaloneBuilder(target)
348
+
329
349
  const targets = model.main[KIT].target || {}
330
- if (null == targets.ts) {
350
+ if (!standalone && null == targets.ts) {
331
351
  throw new SdkGenError(
332
352
  'seneca-provider requires the `ts` target in the same SDK: it imports ' +
333
353
  'the TypeScript SDK that `ts` generates. Add it with:\n' +
@@ -347,8 +367,7 @@ const Main = cmp(function Main(props: any) {
347
367
  // included, so the two can never disagree.
348
368
  // The TypeScript SDK this provider WRAPS — a different target, so it
349
369
  // keeps its own name and does not follow this provider's alias.
350
- const sdkPkg = packageName(model, 'npm')
351
- const sdkVersion = packageVersion(model, 'ts')
370
+ const { sdkPkg, sdkVersion } = sdkIdentity(model, target)
352
371
 
353
372
  const entityColl = entityCollection(model)
354
373
 
@@ -434,6 +453,7 @@ const Main = cmp(function Main(props: any) {
434
453
  .map((f: any) => ({
435
454
  name: f.n,
436
455
  kind: fieldKind(f.t),
456
+ nullable: fieldNullable(f.t),
437
457
  parentEntity: parentEntityOf(f.n, entityNames),
438
458
  }))
439
459
 
@@ -444,6 +464,7 @@ const Main = cmp(function Main(props: any) {
444
464
  req.push({
445
465
  name: key,
446
466
  kind: 'string',
467
+ nullable: false,
447
468
  parentEntity: parentOf[key],
448
469
  })
449
470
  }
@@ -510,6 +531,7 @@ const Main = cmp(function Main(props: any) {
510
531
  Name, lower, ENV, sdkClass, pluginName, fileBase,
511
532
  sdkPkg, sdkVersion, entities,
512
533
  sdkDep,
534
+ standalone,
513
535
  // Whether that dependency comes from outside a registry. The generated
514
536
  // CI note says so, because "npm install is all you need" stops being
515
537
  // true the moment git or a tarball URL is in the path.
@@ -522,6 +544,8 @@ const Main = cmp(function Main(props: any) {
522
544
  sdkInstallFlag: sdkDep.startsWith('github:') ? ' --allow-git=all' :
523
545
  (sdkDep.startsWith('http') ? ' --allow-remote=all' : ''),
524
546
  repoUrl: repo.url,
547
+ repoPath: repo.path,
548
+ repoHost: repo.host,
525
549
  sdkRepoUrl,
526
550
  sdkTag,
527
551
  sdkRepoDir,
@@ -531,6 +555,8 @@ const Main = cmp(function Main(props: any) {
531
555
  version: packageVersion(model, target.name),
532
556
  liveBase,
533
557
  liveApp,
558
+ // The server the API definition declares, which is the SDK's own default.
559
+ specBase,
534
560
  publisher: PUBLISHER,
535
561
  publisherUrl: PUBLISHER_URL,
536
562
  probePath: (entities.find((e: any) =>
@@ -549,6 +575,10 @@ const Main = cmp(function Main(props: any) {
549
575
  authPrefix: resolveAuthPrefix(model),
550
576
  }
551
577
 
578
+ if (standalone) {
579
+ checkSdkSource(ctx$, provider, model)
580
+ }
581
+
552
582
  // `.gitignore` is EMITTED rather than copied — npm strips that filename
553
583
  // from the tarball, so as a template it reached only checkout users. See
554
584
  // Gitignore_seneca-provider. Called before the Copy, as every language
@@ -560,6 +590,7 @@ const Main = cmp(function Main(props: any) {
560
590
  replace: { ...ctx$.stdrep },
561
591
  })
562
592
 
593
+ Makefile({ provider })
563
594
  SdkPin({ provider })
564
595
  PackageJson({ provider, target })
565
596
  ProviderSource({ provider })
@@ -1218,7 +1249,12 @@ ${actionBranch('load',
1218
1249
  ` const hit = await ornull(() => this.shared.sdk.${e.acc}()[op$](${aq}))
1219
1250
  return null == hit ? null : entize(${out('hit')})
1220
1251
  `)}${guard('load', 'q')}${splitLine('load')} const res = await ornull(() => this.shared.sdk.${e.acc}().load(${sdkArg('load')}))
1221
- return null == res ? null : entize(${out('res', loadVals)})
1252
+ ${0 < addressKeys(e.ent, 'load').length || 0 < eparts.length ? '' :
1253
+ ` // The route names no record, so an id finds only the record carrying it.
1254
+ if (null != res && null != q.id && String(${jsProp('plain(res)', rk)}) !== String(q.id)) {
1255
+ return null
1256
+ }
1257
+ `} return null == res ? null : entize(${out('res', loadVals)})
1222
1258
  }
1223
1259
 
1224
1260
  `)
@@ -1268,7 +1304,12 @@ ${dropPark}
1268
1304
  // which matches a request against a stored record, then looked for a
1269
1305
  // record whose own \`id\` was that joined string and found none.
1270
1306
  delete data.id
1271
- ` : !keyed ? '' : `
1307
+ ` : !keyed ? '' : hasCreate && !hasUpdate && true !== e.rkoncreate ? `
1308
+ // The API assigns a ${e.name}'s \`${rk}\`, and a create names no record, so
1309
+ // Seneca's \`id\` is not sent: the id is read back from the response.
1310
+ const key: any = null
1311
+ ${dropPark} delete data.id
1312
+ ` : `
1272
1313
  // This API keys a ${e.name} by \`${rk}\`; Seneca carries it as \`id\`.
1273
1314
  // The key goes on the body under the API's own name, where the route
1274
1315
  // reads it, and into the match, which the SDK consults first.
@@ -1369,9 +1410,10 @@ ${provider.authActive ? `
1369
1410
  // the SDK was constructed with NO credential at all — the request went
1370
1411
  // out unauthenticated and failed much later as a 401 or a 404 on
1371
1412
  // anything private, with nothing at startup to point at the cause.
1372
- // \`apikey\` wins when both are set, so a config that has migrated is
1373
- // unaffected.
1374
- const apikey = res?.keymap?.apikey?.value ?? res?.keymap?.api?.value
1413
+ // \`apikey\` wins when both are set and it is not empty, so a config that
1414
+ // has migrated is unaffected.
1415
+ const apikey = [res?.keymap?.apikey?.value, res?.keymap?.api?.value]
1416
+ .find((value: any) => null != value && '' !== value)
1375
1417
 
1376
1418
  // Hand the credential to the SDK as \`apikey\`, NOT as an authorization
1377
1419
  // HEADER. The SDK's own auth stage owns that header: it reads
@@ -0,0 +1,216 @@
1
+ import {
2
+ Content,
3
+ File,
4
+ cmp,
5
+ } from '@voxgig/sdkgen'
6
+
7
+
8
+ const HEAD = `# GENERATED by @voxgig/sdkgen. Do not edit.
9
+
10
+ .PHONY: all build test clean reset sdk-src sdk-clean regen
11
+
12
+ all: build test
13
+
14
+ build:
15
+ \tnpm run build
16
+
17
+ test:
18
+ \tnpm test
19
+
20
+ clean:
21
+ \trm -rf dist dist-test .tsbuildinfo
22
+
23
+ reset:
24
+ \tnpm run reset
25
+
26
+ `
27
+
28
+
29
+ const INTRO_SDK = `# ---------------------------------------------------------------------------
30
+ # THIS REPOSITORY IS GENERATED, AND CAN REGENERATE ITSELF.
31
+ #
32
+ # Everything below fetches the SDK named in sdk-pin.json and runs the
33
+ # generator from it. Nothing here needs a sibling checkout or a particular
34
+ # directory layout: the pin names a repository and a TAG, and the checkout
35
+ # lands inside this repo at a fixed path.
36
+ #
37
+ # NOT a git submodule, deliberately. A submodule pins a commit in git's own
38
+ # plumbing, where a stale one is invisible in a normal diff and updating it is
39
+ # a second repository operation. sdk-pin.json is an ordinary committed file,
40
+ # regenerated from the model with everything else, so it moves with the SDK
41
+ # version it belongs to and shows up in review.
42
+ # ---------------------------------------------------------------------------`
43
+
44
+
45
+ // A provider whose builder is its own repository's .sdk/ (`output: root`).
46
+ const INTRO_SELF = `# ---------------------------------------------------------------------------
47
+ # THIS REPOSITORY IS GENERATED, BY ITS OWN BUILDER IN .sdk/.
48
+ #
49
+ # The builder generates this package and nothing else. Its API definition
50
+ # and guide are the SDK's: everything below fetches the SDK named in
51
+ # sdk-pin.json, and \`make regen\` replaces them with its own before
52
+ # generating, so the package is built from the same model as the SDK version
53
+ # it depends on.
54
+ #
55
+ # NOT a git submodule, deliberately. A submodule pins a commit in git's own
56
+ # plumbing, where a stale one is invisible in a normal diff and updating it is
57
+ # a second repository operation. sdk-pin.json is an ordinary committed file,
58
+ # regenerated from the model with everything else, so it moves with the SDK
59
+ # version it belongs to and shows up in review.
60
+ # ---------------------------------------------------------------------------`
61
+
62
+
63
+ const FETCH = `
64
+ SDK_PIN := sdk-pin.json
65
+ SDK_REPO := $(shell node -p "require('./$(SDK_PIN)').repo" 2>/dev/null)
66
+ SDK_TAG := $(shell node -p "require('./$(SDK_PIN)').tag" 2>/dev/null)
67
+ SDK_DIR := $(shell node -p "require('./$(SDK_PIN)').dir" 2>/dev/null)
68
+
69
+ # Point at a checkout you already have instead of fetching one:
70
+ # make sdk-src SDK_SRC_FROM=../path/to/sdk
71
+ SDK_SRC_FROM ?=
72
+
73
+ # Fetch (or update) the SDK source at the pinned tag.
74
+ #
75
+ # A TAG, not a branch: the point is the exact revision this repository was
76
+ # generated from, so that regenerating reproduces what is committed here
77
+ # rather than whatever main happens to say today.
78
+ sdk-src:
79
+ \t@set -e; \\
80
+ \ttest -n "$(SDK_REPO)" || { echo "sdk-src: no repo in $(SDK_PIN)"; exit 1; }; \\
81
+ \ttest -n "$(SDK_TAG)" || { echo "sdk-src: no tag in $(SDK_PIN)"; exit 1; }; \\
82
+ \tif [ -n "$(SDK_SRC_FROM)" ]; then \\
83
+ \t echo "sdk-src: using $(SDK_SRC_FROM) (not fetching)"; \\
84
+ \t rm -rf "$(SDK_DIR)"; mkdir -p "$$(dirname "$(SDK_DIR)")"; \\
85
+ \t ln -s "$$(cd "$(SDK_SRC_FROM)" && pwd)" "$(SDK_DIR)"; \\
86
+ \t exit 0; \\
87
+ \tfi; \\
88
+ \tif [ -L "$(SDK_DIR)" ]; then \\
89
+ \t echo "sdk-src: $(SDK_DIR) links to $$(readlink "$(SDK_DIR)"), which is left as it is;"; \\
90
+ \t echo " make sdk-clean first to fetch $(SDK_TAG) instead"; \\
91
+ \t exit 0; \\
92
+ \tfi; \\
93
+ \tif [ -d "$(SDK_DIR)/.git" ]; then \\
94
+ \t echo "sdk-src: fetching $(SDK_TAG) in $(SDK_DIR)"; \\
95
+ \t git -C "$(SDK_DIR)" fetch --depth 1 origin "refs/tags/$(SDK_TAG):refs/tags/$(SDK_TAG)" 2>/dev/null || \\
96
+ \t git -C "$(SDK_DIR)" fetch origin "refs/tags/$(SDK_TAG):refs/tags/$(SDK_TAG)"; \\
97
+ \t git -C "$(SDK_DIR)" checkout -q "refs/tags/$(SDK_TAG)"; \\
98
+ \telse \\
99
+ \t echo "sdk-src: cloning $(SDK_REPO) at $(SDK_TAG) into $(SDK_DIR)"; \\
100
+ \t rm -rf "$(SDK_DIR)"; mkdir -p "$$(dirname "$(SDK_DIR)")"; \\
101
+ \t git clone --depth 1 --branch "$(SDK_TAG)" "$(SDK_REPO).git" "$(SDK_DIR)"; \\
102
+ \tfi; \\
103
+ \techo "sdk-src: $(SDK_DIR) is at $(SDK_TAG)"
104
+
105
+ # Removes the fetched checkout AND the folder that held it. \`rm -rf\` on the
106
+ # checkout alone left an empty \`.sdksrc/\` behind — gitignored, so harmless,
107
+ # but it makes \`sdk-clean\` look like it did not finish. rmdir rather than
108
+ # another rm -rf: if anything else is in there, someone put it there, and
109
+ # removing it is not this target's business.
110
+ sdk-clean:
111
+ \trm -rf "$(SDK_DIR)"
112
+ \t@rmdir "$$(dirname "$(SDK_DIR)")" 2>/dev/null || true
113
+ `
114
+
115
+
116
+ const REGEN_SDK = `# Regenerate this repository from the pinned SDK.
117
+ #
118
+ # The SDK's model says where its provider goes, and that value is a fact about
119
+ # ONE developer's checkout layout — it cannot describe being cloned in here.
120
+ # So the destination is given to the generator AT RUN TIME instead:
121
+ #
122
+ # path ../.. — from the SDK checkout up to this repository root
123
+ # enclosing the output folder CONTAINS the SDK project, which the
124
+ # generator refuses unless asked, because one \`..\` too many
125
+ # writes a package over an unrelated repo
126
+ #
127
+ # See @voxgig/sdkgen docs/explanation/out-of-tree-targets.md.
128
+ # The generate-time output override arrived in @voxgig/sdkgen 4.16.0, and
129
+ # older versions IGNORE it SILENTLY: generation falls back to the destination
130
+ # in the SDK's own model, which from in here resolves to a folder that does
131
+ # not exist, gets skipped, and reports success having written nothing. That is
132
+ # not hypothetical — it is what this target did on its first end-to-end run,
133
+ # against a pin whose floor was set one release too low.
134
+ #
135
+ # So the floor is the version that actually ships the feature, not the version
136
+ # that happened to be current when it was written.
137
+ SDKGEN_MIN := 4.16.0
138
+
139
+ regen: sdk-src
140
+ \t@set -e; \\
141
+ \tif [ -L "$(SDK_DIR)" ]; then \\
142
+ \t echo "regen: $(SDK_DIR) is a symlink, and regenerating through one writes"; \\
143
+ \t echo " the package somewhere else. Node resolves a module's own"; \\
144
+ \t echo " directory through symlinks, so the generator computes its"; \\
145
+ \t echo " destination from where the checkout REALLY is — not from"; \\
146
+ \t echo " here — and \\\`enclosing\\\` then permits the result."; \\
147
+ \t echo " SDK_SRC_FROM is for building and linking against a local"; \\
148
+ \t echo " SDK (make sdk-src). To regenerate, use a fetched checkout:"; \\
149
+ \t echo " make sdk-clean && make regen"; \\
150
+ \t exit 1; \\
151
+ \tfi; \\
152
+ \techo "regen: building the generator in $(SDK_DIR)/.sdk"; \\
153
+ \tnpm --prefix "$(SDK_DIR)/.sdk" install; \\
154
+ \thave=$$(node -e "console.log(JSON.parse(require('fs').readFileSync('$(SDK_DIR)/.sdk/node_modules/@voxgig/sdkgen/package.json','utf8')).version)"); \\
155
+ \tnode -e 'const a=process.argv[1].split(".").map(Number),b=process.argv[2].split(".").map(Number); \\
156
+ \t for(let i=0;i<3;i++){if(a[i]>b[i])process.exit(0);if(a[i]<b[i])process.exit(1)} process.exit(0)' \\
157
+ \t "$$have" "$(SDKGEN_MIN)" || { \\
158
+ \t echo "regen: $(SDK_TAG) uses @voxgig/sdkgen $$have, and regenerating from"; \\
159
+ \t echo " inside this repo needs >= $(SDKGEN_MIN) (the generate-time output"; \\
160
+ \t echo " override). Older versions IGNORE it and silently write nothing."; \\
161
+ \t echo " Pin a newer SDK tag, or regenerate from a checkout of the SDK."; \\
162
+ \t exit 1; }; \\
163
+ \techo "regen: generating"; \\
164
+ \tcd "$(SDK_DIR)/.sdk" && \\
165
+ \t SDKGEN_EXTERNAL='{"seneca-provider":{"path":"../..","enclosing":true}}' \\
166
+ \t npm run generate
167
+ \t@echo "regen: done — review the diff in this repository"
168
+ `
169
+
170
+
171
+ const REGEN_SELF = `# Regenerate this repository with its own builder.
172
+ #
173
+ # The API definition and guide are replaced by the SDK source's first, so the
174
+ # builder generates from the SDK's model at the pinned tag, and generation
175
+ # checks the result against that source before writing. A linked checkout
176
+ # (SDK_SRC_FROM) is read as it is, and only this repository is written.
177
+ #
178
+ # To move to a newer SDK, set its version in .sdk/model/project.aontu and
179
+ # fetch that tag as you regenerate:
180
+ #
181
+ # make regen SDK_TAG=v1.2.3
182
+ regen: sdk-src
183
+ \t@set -e; \\
184
+ \ttest -f .sdk/package.json || { echo "regen: no builder at .sdk/"; exit 1; }; \\
185
+ \tfor d in def model/guide; do \\
186
+ \t test -d "$(SDK_DIR)/.sdk/$$d" || { echo "regen: no $(SDK_DIR)/.sdk/$$d"; exit 1; }; \\
187
+ \tdone; \\
188
+ \techo "regen: replacing the API definition and guide with those in $(SDK_DIR)"; \\
189
+ \tfor d in def model/guide; do \\
190
+ \t rm -rf ".sdk/$$d"; mkdir -p ".sdk/$$d"; cp -R "$(SDK_DIR)/.sdk/$$d/." ".sdk/$$d/"; \\
191
+ \tdone; \\
192
+ \techo "regen: installing the builder"; \\
193
+ \tcd .sdk && npm install && \\
194
+ \techo "regen: generating" && \\
195
+ \tnpm run generate
196
+ \t@echo "regen: done — review the diff in this repository"
197
+ `
198
+
199
+
200
+ const Makefile = cmp(function Makefile(props: any) {
201
+ const { provider } = props
202
+
203
+ File({ name: 'Makefile' }, () => {
204
+ Content([
205
+ HEAD,
206
+ provider.standalone ? INTRO_SELF : INTRO_SDK,
207
+ FETCH,
208
+ provider.standalone ? REGEN_SELF : REGEN_SDK,
209
+ ].join('\n'))
210
+ })
211
+ })
212
+
213
+
214
+ export {
215
+ Makefile,
216
+ }
@@ -0,0 +1,201 @@
1
+ import Path from 'node:path'
2
+
3
+ import {
4
+ packageName, packageVersion,
5
+ SdkGenError,
6
+ } from '@voxgig/sdkgen'
7
+
8
+ import {
9
+ KIT,
10
+ } from '@voxgig/apidef'
11
+
12
+
13
+ // `output: root: true` means this repository's own `.sdk/` is the builder:
14
+ // the SDK is released elsewhere, and is not a target of this project.
15
+ function standaloneBuilder(target: any): boolean {
16
+ return true === target?.output?.root
17
+ }
18
+
19
+
20
+ function sdkIdentity(model: any, target: any):
21
+ { sdkPkg: string, sdkVersion: string } {
22
+ const sdk = target?.sdk || {}
23
+ const pkg = String(sdk.package || '')
24
+ const version = String(sdk.version || '')
25
+ const hasTs = null != model?.main?.[KIT]?.target?.ts
26
+
27
+ if (!standaloneBuilder(target)) {
28
+ if ('' !== pkg || '' !== version) {
29
+ throw new SdkGenError(
30
+ target.name + ': `sdk.package` and `sdk.version` apply only to a ' +
31
+ 'provider generated at the root of its own repository (`output: ' +
32
+ 'root: true`). Generated from the SDK project, the SDK is its `ts` ' +
33
+ 'target: set `main.' + KIT + '.target.ts.publish.version` instead.')
34
+ }
35
+ return { sdkPkg: packageName(model, 'npm'), sdkVersion: packageVersion(model, 'ts') }
36
+ }
37
+
38
+ if ('' === version && !hasTs) {
39
+ throw new SdkGenError(
40
+ target.name + ': name the SDK version this provider depends on, as ' +
41
+ '`main.' + KIT + '.target.' + target.name + '.sdk.version`. Its ' +
42
+ 'builder has no `ts` target to read it from, and a default would pin ' +
43
+ 'whatever version it happens to be.')
44
+ }
45
+
46
+ return {
47
+ sdkPkg: '' !== pkg ? pkg : packageName(model, 'npm'),
48
+ sdkVersion: '' !== version ? version : packageVersion(model, 'ts'),
49
+ }
50
+ }
51
+
52
+
53
+ // What the SDK was generated from and the provider reads: titles,
54
+ // descriptions and contract metadata are left out.
55
+ const FIELD_KEYS = ['n', 't', 'r', 'a', 'ro', 'wo', 'fo']
56
+ const POINT_KEYS = ['a', 'k', 'm', 'o', 's', 'r', 't', 'g', 'q', 'gq']
57
+
58
+ function pick(obj: any, keys: string[]): Record<string, any> {
59
+ const out: Record<string, any> = {}
60
+ for (const key of keys) {
61
+ if (undefined !== obj?.[key]) {
62
+ out[key] = obj[key]
63
+ }
64
+ }
65
+ return out
66
+ }
67
+
68
+
69
+ // Keyed by path under `main.kit`, so a difference names where to look.
70
+ function apiSurface(model: any): Record<string, any> {
71
+ const kit = model?.main?.[KIT] || {}
72
+ const out: Record<string, any> = {
73
+ info: {
74
+ auth: kit.info?.auth,
75
+ security: kit.info?.security,
76
+ servers: (kit.info?.servers || []).map((server: any) => pick(server, ['url'])),
77
+ },
78
+ config: { auth: kit.config?.auth },
79
+ }
80
+
81
+ const coll = kit.entity || {}
82
+ for (const name of Object.keys(coll).sort()) {
83
+ const ent = coll[name]
84
+ if (null != ent && false !== ent.active) {
85
+ out['entity.' + name] = {
86
+ id: ent.id,
87
+ alias: ent.alias,
88
+ relations: { ancestors: ent.relations?.ancestors },
89
+ fields: Object.fromEntries(Object.entries(ent.fields || {})
90
+ .map(([key, field]) => [key, pick(field, FIELD_KEYS)])),
91
+ op: Object.fromEntries(Object.entries(ent.op || {})
92
+ .map(([key, op]: [string, any]) => [key, {
93
+ input: op?.input,
94
+ points: (op?.points || []).map((point: any) => pick(point, POINT_KEYS)),
95
+ }])),
96
+ }
97
+ }
98
+ }
99
+
100
+ return out
101
+ }
102
+
103
+
104
+ function brief(value: any): string {
105
+ const text = undefined === value ? 'absent' : JSON.stringify(value)
106
+ return 60 < text.length ? text.slice(0, 57) + '...' : text
107
+ }
108
+
109
+
110
+ // Keys ending in `$` are the generator's marks, present only on a live model.
111
+ function differences(here: any, there: any, path: string, out: string[]): void {
112
+ const node = (value: any) => null != value && 'object' === typeof value
113
+ if (node(here) && node(there) && Array.isArray(here) === Array.isArray(there)) {
114
+ const keys = Array.isArray(here) ?
115
+ Array.from({ length: Math.max(here.length, there.length) }, (_, i) => i) :
116
+ [...new Set([...Object.keys(here), ...Object.keys(there)])]
117
+ .filter((key) => !key.endsWith('$')).sort()
118
+ for (const key of keys) {
119
+ differences(here[key], there[key], Array.isArray(here) ?
120
+ path + '[' + key + ']' : path + '.' + key, out)
121
+ }
122
+ }
123
+ else if (JSON.stringify(here) !== JSON.stringify(there)) {
124
+ out.push(path + ': here ' + brief(here) + ', SDK ' + brief(there))
125
+ }
126
+ }
127
+
128
+
129
+ // The builder carries its own copy of the SDK's API definition, right only
130
+ // while it matches the SDK at the version depended on. `make sdk-src` fetches
131
+ // that SDK, whose compiled model is the comparison.
132
+ function checkSdkSource(ctx$: any, provider: any, model: any): void {
133
+ const fs = ctx$.fs()
134
+ const rel = provider.sdkSrc + '/.sdk/model/sdk.json'
135
+ const file = Path.join(ctx$.folder || '.', rel)
136
+
137
+ if (!fs.existsSync(file)) {
138
+ ctx$.log?.warn?.({
139
+ point: 'sdk-source-unchecked', file: rel,
140
+ note: 'seneca-provider: ' + rel + ' is absent, so this model was NOT ' +
141
+ 'checked against ' + provider.sdkPkg + ' ' + provider.sdkVersion +
142
+ '. `make sdk-src` fetches it.'
143
+ })
144
+ return
145
+ }
146
+
147
+ const sdk = JSON.parse(String(fs.readFileSync(file, 'utf8')))
148
+ const problems: string[] = []
149
+
150
+ // The SDK's own manifest, when fetched: this builder's naming rule need not
151
+ // be the one that built the SDK.
152
+ const manifestFile = Path.join(ctx$.folder || '.', provider.sdkSrc, 'ts', 'package.json')
153
+ const manifest = fs.existsSync(manifestFile) ?
154
+ JSON.parse(String(fs.readFileSync(manifestFile, 'utf8'))) : {}
155
+
156
+ const sdkVersion = String(manifest.version || packageVersion(sdk, 'ts'))
157
+ if (sdkVersion !== provider.sdkVersion) {
158
+ problems.push('version: this provider depends on ' + provider.sdkVersion +
159
+ ', the fetched SDK is ' + sdkVersion +
160
+ ' (make regen SDK_TAG=v' + provider.sdkVersion + ')')
161
+ }
162
+
163
+ const sdkPkg = String(manifest.name || packageName(sdk, 'npm'))
164
+ if (sdkPkg !== provider.sdkPkg) {
165
+ problems.push('package: this provider depends on ' + provider.sdkPkg +
166
+ ', the fetched SDK is ' + sdkPkg)
167
+ }
168
+
169
+ const here = apiSurface(model)
170
+ const there = apiSurface(sdk)
171
+
172
+ for (const part of [...new Set([...Object.keys(here), ...Object.keys(there)])].sort()) {
173
+ const found: string[] = []
174
+ differences(here[part], there[part], part, found)
175
+ problems.push(...found.slice(0, 5))
176
+ if (5 < found.length) {
177
+ problems.push(part + ': and ' + (found.length - 5) + ' more')
178
+ }
179
+ }
180
+
181
+ if (0 < problems.length) {
182
+ throw new SdkGenError(
183
+ 'seneca-provider: this builder\'s model does not match the SDK it ' +
184
+ 'depends on, as fetched to ' + provider.sdkSrc + ' (paths under main.' +
185
+ KIT + '):\n ' +
186
+ problems.join('\n ') +
187
+ '\n`make regen` replaces the API definition and guide in .sdk/ with the ' +
188
+ 'SDK\'s before generating. A difference that remains comes from a decision in ' +
189
+ 'the SDK project\'s own model, to be declared the same way in ' +
190
+ '.sdk/model/project.aontu, or from an @voxgig/apidef version other ' +
191
+ 'than the one in the SDK\'s .sdk/package.json.')
192
+ }
193
+ }
194
+
195
+
196
+ export {
197
+ standaloneBuilder,
198
+ sdkIdentity,
199
+ apiSurface,
200
+ checkSdkSource,
201
+ }
package/README.md CHANGED
@@ -27,7 +27,9 @@ sdkgen repository.
27
27
 
28
28
  These are consumer targets: they switch every standard generation phase off and
29
29
  emit their whole package from `Main`, and each one fails without the target it
30
- wraps, deliberately. Add `ts` to your project before `seneca-provider`.
30
+ wraps, deliberately. Add `ts` to your project before `seneca-provider`. The
31
+ one exception is a provider that carries its own builder (below), which names
32
+ the SDK it wraps instead.
31
33
 
32
34
  There is no manifest field for that requirement. Nothing in
33
35
  `sdkgen-package.json` says "this target needs that one", so this paragraph and
@@ -84,6 +86,75 @@ first look at the output.
84
86
  Set that in `model/project.aontu`, not in the target's own file — `target add`
85
87
  overwrites the latter.
86
88
 
89
+ ## Or the provider carries its own builder
90
+
91
+ The provider repository can instead hold a `.sdk/` of its own, which generates
92
+ the provider and nothing else, and leaves the SDK repository untouched. Its
93
+ `model/project.aontu` says so, and the standard `Root` that create-sdkgen
94
+ scaffolds reads it:
95
+
96
+ ```
97
+ main: kit: phase: top: active: false
98
+ main: kit: phase: build: active: false
99
+ main: kit: doc: active: false
100
+ main: kit: target: 'seneca-provider': output: root: true
101
+ main: kit: target: 'seneca-provider': sdk: version: '0.0.1'
102
+ ```
103
+
104
+ `output: root: true` generates the provider at the repository root, and is how
105
+ this target knows the builder is the provider's own. That builder has no `ts`
106
+ target, so it names the SDK itself: `sdk.version` is required, and
107
+ `sdk.package` overrides the derived package name. Both are refused in an SDK
108
+ project, where the `ts` target is the SDK.
109
+
110
+ The builder carries a copy of the SDK's API definition and guide. `make regen`
111
+ replaces them with those of the SDK fetched at the tag in `sdk-pin.json` before
112
+ generating. Generation then refuses to write when the builder's model and that
113
+ SDK's compiled model differ in what the SDK was generated from: an entity, an
114
+ operation's routes, parameters and actions, a field's type or requiredness, the
115
+ identity, the ancestors, the authentication or the servers. Titles and
116
+ descriptions are not compared. It also refuses when the package name or version
117
+ differ from the SDK's own `ts/package.json`, which is read rather than the name
118
+ re-derived, because the SDK's generator may name packages by a rule this
119
+ builder's does not share. Generated without the SDK fetched, it warns that the
120
+ check did not run.
121
+
122
+ A symlinked SDK checkout, from `make sdk-src SDK_SRC_FROM=<path>`, is read as
123
+ it is: `make sdk-src` never fetches or checks out a tag inside one.
124
+
125
+ To move to a newer SDK, set `sdk.version` and fetch its tag as you regenerate:
126
+
127
+ ```bash
128
+ make regen SDK_TAG=v0.0.2
129
+ ```
130
+
131
+ ## An SDK that is not on npm yet
132
+
133
+ The generated `package.json` depends on the SDK by version range, which
134
+ installs only once the SDK is published. Until then, `sdk.dep` points it
135
+ elsewhere:
136
+
137
+ | `sdk.dep` | The dependency |
138
+ |---|---|
139
+ | `kind: 'release', ref: 'v1.0.0'` | the `npm pack` tarball attached to that GitHub release |
140
+ | `kind: 'git', ref: '<tag>'` | `github:<owner>/<repo>#<tag>`, installed from the root of that tag's tree |
141
+ | `spec: '<anything>'` | the value, verbatim |
142
+
143
+ npm cannot install a package from a subfolder of a git repository, and an
144
+ sdkgen SDK keeps its TypeScript package in `ts/`. A git dependency therefore
145
+ needs a tag whose tree is that folder, cut in the SDK repository:
146
+
147
+ ```bash
148
+ git tag ts-v1.0.0 $(git commit-tree HEAD:ts -p HEAD -m "ts/ at v1.0.0")
149
+ git push origin ts-v1.0.0
150
+ ```
151
+
152
+ and then `sdk: dep: { kind: 'git', ref: 'ts-v1.0.0' }`. The tag carries what
153
+ the folder holds, so an SDK that commits `ts/dist` needs nothing built on
154
+ install. The generated CI passes `--allow-git=all`, which npm 12 needs for a
155
+ git dependency, and so would anyone installing a published provider that
156
+ depends on one: publish the SDK to npm before the provider.
157
+
87
158
  ## Parity
88
159
 
89
160
  Every target here declares `CONSUMER`. That is not a coverage tier alongside
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/sdkgen-infrapack",
3
- "version": "0.0.15",
3
+ "version": "0.0.17",
4
4
  "description": "Infrastructure-provider targets for the Voxgig SDK Generator: Seneca provider.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",
@@ -19,7 +19,8 @@
19
19
  },
20
20
  "files": [
21
21
  ".sdk",
22
- "sdkgen-package.json"
22
+ "sdkgen-package.json",
23
+ "README.md"
23
24
  ],
24
25
  "scripts": {
25
26
  "test": "node tools/comment-gate.cjs && node tools/dep-gate.cjs && node --test tools/dep-gate.test.cjs && node --enable-source-maps --test test/*.test.js",
@@ -105,11 +106,11 @@
105
106
  ],
106
107
  "peerDependencies": {
107
108
  "@voxgig/apidef": ">=8.17.0",
108
- "@voxgig/sdkgen": ">=4.25.0"
109
+ "@voxgig/sdkgen": ">=4.30.0"
109
110
  },
110
111
  "devDependencies": {
111
112
  "@voxgig/apidef": ">=8.17.0",
112
- "@voxgig/sdkgen": ">=4.25.0",
113
+ "@voxgig/sdkgen": ">=4.30.0",
113
114
  "aontu": ">=0.75.0",
114
115
  "memfs": "4.68.1",
115
116
  "typescript": "7.0.2",
@@ -3,9 +3,9 @@
3
3
  "package": 1
4
4
  },
5
5
  "name": "@voxgig/sdkgen-infrapack",
6
- "version": "0.0.15",
6
+ "version": "0.0.17",
7
7
  "engines": {
8
- "sdkgen": ">=4.25.0"
8
+ "sdkgen": ">=4.30.0"
9
9
  },
10
10
  "provides": {
11
11
  "target": [
@@ -1,131 +0,0 @@
1
- # GENERATED by @voxgig/sdkgen. Do not edit.
2
-
3
- .PHONY: all build test clean reset sdk-src sdk-clean regen
4
-
5
- all: build test
6
-
7
- build:
8
- npm run build
9
-
10
- test:
11
- npm test
12
-
13
- clean:
14
- rm -rf dist dist-test .tsbuildinfo
15
-
16
- reset:
17
- npm run reset
18
-
19
-
20
- # ---------------------------------------------------------------------------
21
- # THIS REPOSITORY IS GENERATED, AND CAN REGENERATE ITSELF.
22
- #
23
- # Everything below fetches the SDK named in sdk-pin.json and runs the
24
- # generator from it. Nothing here needs a sibling checkout or a particular
25
- # directory layout: the pin names a repository and a TAG, and the checkout
26
- # lands inside this repo at a fixed path.
27
- #
28
- # NOT a git submodule, deliberately. A submodule pins a commit in git's own
29
- # plumbing, where a stale one is invisible in a normal diff and updating it is
30
- # a second repository operation. sdk-pin.json is an ordinary committed file,
31
- # regenerated from the model with everything else, so it moves with the SDK
32
- # version it belongs to and shows up in review.
33
- # ---------------------------------------------------------------------------
34
-
35
- SDK_PIN := sdk-pin.json
36
- SDK_REPO := $(shell node -p "require('./$(SDK_PIN)').repo" 2>/dev/null)
37
- SDK_TAG := $(shell node -p "require('./$(SDK_PIN)').tag" 2>/dev/null)
38
- SDK_DIR := $(shell node -p "require('./$(SDK_PIN)').dir" 2>/dev/null)
39
-
40
- # Point at a checkout you already have instead of fetching one:
41
- # make sdk-src SDK_SRC_FROM=../path/to/sdk
42
- SDK_SRC_FROM ?=
43
-
44
- # Fetch (or update) the SDK source at the pinned tag.
45
- #
46
- # A TAG, not a branch: the point is the exact revision this repository was
47
- # generated from, so that regenerating reproduces what is committed here
48
- # rather than whatever main happens to say today.
49
- sdk-src:
50
- @set -e; \
51
- test -n "$(SDK_REPO)" || { echo "sdk-src: no repo in $(SDK_PIN)"; exit 1; }; \
52
- test -n "$(SDK_TAG)" || { echo "sdk-src: no tag in $(SDK_PIN)"; exit 1; }; \
53
- if [ -n "$(SDK_SRC_FROM)" ]; then \
54
- echo "sdk-src: using $(SDK_SRC_FROM) (not fetching)"; \
55
- rm -rf "$(SDK_DIR)"; mkdir -p "$$(dirname "$(SDK_DIR)")"; \
56
- ln -s "$$(cd "$(SDK_SRC_FROM)" && pwd)" "$(SDK_DIR)"; \
57
- exit 0; \
58
- fi; \
59
- if [ -d "$(SDK_DIR)/.git" ]; then \
60
- echo "sdk-src: fetching $(SDK_TAG) in $(SDK_DIR)"; \
61
- git -C "$(SDK_DIR)" fetch --depth 1 origin "refs/tags/$(SDK_TAG):refs/tags/$(SDK_TAG)" 2>/dev/null || \
62
- git -C "$(SDK_DIR)" fetch origin "refs/tags/$(SDK_TAG):refs/tags/$(SDK_TAG)"; \
63
- git -C "$(SDK_DIR)" checkout -q "refs/tags/$(SDK_TAG)"; \
64
- else \
65
- echo "sdk-src: cloning $(SDK_REPO) at $(SDK_TAG) into $(SDK_DIR)"; \
66
- rm -rf "$(SDK_DIR)"; mkdir -p "$$(dirname "$(SDK_DIR)")"; \
67
- git clone --depth 1 --branch "$(SDK_TAG)" "$(SDK_REPO).git" "$(SDK_DIR)"; \
68
- fi; \
69
- echo "sdk-src: $(SDK_DIR) is at $(SDK_TAG)"
70
-
71
- # Removes the fetched checkout AND the folder that held it. `rm -rf` on the
72
- # checkout alone left an empty `.sdksrc/` behind — gitignored, so harmless,
73
- # but it makes `sdk-clean` look like it did not finish. rmdir rather than
74
- # another rm -rf: if anything else is in there, someone put it there, and
75
- # removing it is not this target's business.
76
- sdk-clean:
77
- rm -rf "$(SDK_DIR)"
78
- @rmdir "$$(dirname "$(SDK_DIR)")" 2>/dev/null || true
79
-
80
- # Regenerate this repository from the pinned SDK.
81
- #
82
- # The SDK's model says where its provider goes, and that value is a fact about
83
- # ONE developer's checkout layout — it cannot describe being cloned in here.
84
- # So the destination is given to the generator AT RUN TIME instead:
85
- #
86
- # path ../.. — from the SDK checkout up to this repository root
87
- # enclosing the output folder CONTAINS the SDK project, which the
88
- # generator refuses unless asked, because one `..` too many
89
- # writes a package over an unrelated repo
90
- #
91
- # See @voxgig/sdkgen docs/explanation/out-of-tree-targets.md.
92
- # The generate-time output override arrived in @voxgig/sdkgen 4.16.0, and
93
- # older versions IGNORE it SILENTLY: generation falls back to the destination
94
- # in the SDK's own model, which from in here resolves to a folder that does
95
- # not exist, gets skipped, and reports success having written nothing. That is
96
- # not hypothetical — it is what this target did on its first end-to-end run,
97
- # against a pin whose floor was set one release too low.
98
- #
99
- # So the floor is the version that actually ships the feature, not the version
100
- # that happened to be current when it was written.
101
- SDKGEN_MIN := 4.16.0
102
-
103
- regen: sdk-src
104
- @set -e; \
105
- if [ -L "$(SDK_DIR)" ]; then \
106
- echo "regen: $(SDK_DIR) is a symlink, and regenerating through one writes"; \
107
- echo " the package somewhere else. Node resolves a module's own"; \
108
- echo " directory through symlinks, so the generator computes its"; \
109
- echo " destination from where the checkout REALLY is — not from"; \
110
- echo " here — and \`enclosing\` then permits the result."; \
111
- echo " SDK_SRC_FROM is for building and linking against a local"; \
112
- echo " SDK (make sdk-src). To regenerate, use a fetched checkout:"; \
113
- echo " make sdk-clean && make regen"; \
114
- exit 1; \
115
- fi; \
116
- echo "regen: building the generator in $(SDK_DIR)/.sdk"; \
117
- npm --prefix "$(SDK_DIR)/.sdk" install; \
118
- have=$$(node -e "console.log(JSON.parse(require('fs').readFileSync('$(SDK_DIR)/.sdk/node_modules/@voxgig/sdkgen/package.json','utf8')).version)"); \
119
- node -e 'const a=process.argv[1].split(".").map(Number),b=process.argv[2].split(".").map(Number); \
120
- for(let i=0;i<3;i++){if(a[i]>b[i])process.exit(0);if(a[i]<b[i])process.exit(1)} process.exit(0)' \
121
- "$$have" "$(SDKGEN_MIN)" || { \
122
- echo "regen: $(SDK_TAG) uses @voxgig/sdkgen $$have, and regenerating from"; \
123
- echo " inside this repo needs >= $(SDKGEN_MIN) (the generate-time output"; \
124
- echo " override). Older versions IGNORE it and silently write nothing."; \
125
- echo " Pin a newer SDK tag, or regenerate from a checkout of the SDK."; \
126
- exit 1; }; \
127
- echo "regen: generating"; \
128
- cd "$(SDK_DIR)/.sdk" && \
129
- SDKGEN_EXTERNAL='{"seneca-provider":{"path":"../..","enclosing":true}}' \
130
- npm run generate
131
- @echo "regen: done — review the diff in this repository"