@voxgig/sdkgen-infrapack 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -156,6 +156,42 @@ function requiredKeys(ent: any, opname: string): string[] {
156
156
  }
157
157
 
158
158
 
159
+ // The keys a single-record call actually sends: the op's required keys, plus
160
+ // the record's own key when the match declares it at all. One definition,
161
+ // because the handler emitter and the test emitter disagreeing about it is
162
+ // how a test comes to assert something the generated code never does.
163
+ function addressKeys(ent: any, opname: string): string[] {
164
+ const keys = requiredKeys(ent, opname)
165
+ const parts = idParts(ent)
166
+
167
+ if (0 < parts.length) {
168
+ // EVERY PART, required or not — for the same reason a single record key
169
+ // always travels. github's api_insights_summary_stat is keyed
170
+ // `actor_type/actor_id` and declares both OPTIONAL, because the op also
171
+ // covers routes that take neither; only `min_timestamp` came through as
172
+ // required. So the call sent the timestamp alone, the split id went
173
+ // nowhere, and every read answered with the same record — the composite
174
+ // half of the defect fixed below for single keys.
175
+ const shape = opRequestShape(ent, opname).items
176
+ for (const part of parts) {
177
+ if (!keys.includes(part) &&
178
+ shape.some((it: any) => it.name === part)) {
179
+ keys.push(part)
180
+ }
181
+ }
182
+ return keys
183
+ }
184
+
185
+ const rk = recordKey(ent)
186
+ if (null != rk && '' !== rk && !keys.includes(rk) &&
187
+ opRequestShape(ent, opname).items.some((it: any) => it.name === rk)) {
188
+ keys.push(rk)
189
+ }
190
+
191
+ return keys
192
+ }
193
+
194
+
159
195
  // The key that addresses ONE record.
160
196
  //
161
197
  // `entityIdField` answers whenever the model declares one, which apidef does
@@ -176,6 +212,60 @@ function requiredKeys(ent: any, opname: string): string[] {
176
212
  // record (base_id, table_id, record_id) alphabetizes with table_id last,
177
213
  // so the old `params[params.length - 1]` picked the wrong parent as the
178
214
  // record's own key. The point's own `parts` still has the true order.
215
+ // The path parameters that TOGETHER name one record, when no single one does.
216
+ //
217
+ // apidef sets `id.parts` for an API that addresses a record by several
218
+ // adjacent path parameters — github needs {owner} AND {repo} to name a
219
+ // repository — and `id.sep` (a slash) joins them into the ONE id a Seneca
220
+ // entity carries. Empty for the ordinary single-key entity, which is most of
221
+ // them, so every caller can branch on `0 < parts.length`.
222
+ function idParts(ent: any): string[] {
223
+ const parts = ent?.id?.parts
224
+ return Array.isArray(parts) && 1 < parts.length ?
225
+ parts.map((p: any) => String(p)) : []
226
+ }
227
+
228
+
229
+ function idSep(ent: any): string {
230
+ const sep = ent?.id?.sep
231
+ return null != sep && '' !== String(sep) ? String(sep) : '/'
232
+ }
233
+
234
+
235
+ // THE ONE DESCRIPTION OF AN ENTITY'S ID, or null when Seneca's `id` and the
236
+ // API's key already agree and nothing needs translating.
237
+ //
238
+ // It unifies the two cases that used to be handled by separate emitters. A
239
+ // compound key (`{owner}/{repo}`) has several parts; an API that simply calls
240
+ // its key something else (`repo`, `number`) has exactly one. Neither needs a
241
+ // different algorithm — a one-part split is the identity, and a one-part join
242
+ // is the old carry-across — so both come through the same table and the same
243
+ // two functions.
244
+ //
245
+ // `from` is passed through as the model states it: which response field
246
+ // carries each part. apidef derives it and guide.aon can correct it.
247
+ function idSpec(ent: any): { parts: string[], sep: string, from?: Record<string, string> } | null {
248
+ const parts = idParts(ent)
249
+ if (0 < parts.length) {
250
+ const from = ent?.id?.from
251
+ return {
252
+ parts,
253
+ sep: idSep(ent),
254
+ ...(null != from && 'object' === typeof from ? { from } : {}),
255
+ }
256
+ }
257
+
258
+ const rk = recordKey(ent)
259
+ if ('id' === rk) {
260
+ return null
261
+ }
262
+
263
+ // A single-key entity whose key is not `id`. `from` defaults to the key's
264
+ // own name, which is what the old carry-across read.
265
+ return { parts: [rk], sep: idSep(ent) }
266
+ }
267
+
268
+
179
269
  function recordKey(ent: any): string {
180
270
  const idf = entityIdField(ent)
181
271
  if (null != idf && '' !== idf) {
@@ -434,6 +524,77 @@ const Main = cmp(function Main(props: any) {
434
524
  cls: entityClassName(ent, entityColl),
435
525
  ops: entityOps(ent),
436
526
  idf: entityIdField(ent),
527
+ // The field this API's routes actually ADDRESS a record by, which is
528
+ // not always `id` and is not always what entityIdField answers (that
529
+ // returns null when the load match has no `id`, leaving recordKey to
530
+ // read the route's last variable segment). The doc and test emitters
531
+ // in Extras need the same answer the handler emitters use, or the
532
+ // seed they build is keyed by a field the routes never look at.
533
+ rk: recordKey(ent),
534
+ // The composite key, when this API addresses a record by several
535
+ // path params at once. The doc and test emitters in Extras need the
536
+ // same answer the handler emitters use: a test that addresses a
537
+ // composite entity the single-key way builds a query its own handler
538
+ // rejects.
539
+ idparts: idParts(ent),
540
+ idsep: idSep(ent),
541
+ // DOES EACH SINGLE-RECORD OP ACTUALLY CARRY THE RECORD'S KEY?
542
+ //
543
+ // Only then can a wrong id miss on a LOAD: github's `interaction`
544
+ // reads `/user/interaction-limits` — a singleton, called as
545
+ // `load({})` — so `load$('no-such-id')` correctly returns the one
546
+ // record there is, and a not-found test against it asserts the
547
+ // opposite of the truth.
548
+ //
549
+ // And only then can a REMOVE delete what a create just made. A
550
+ // tag-bucket entity can have ops addressing different resources
551
+ // entirely: github's `action` is keyed `archive_format` from its
552
+ // download route while its remove takes `hosted_runner_id` and
553
+ // `org_id`, so the remove addressed by parent scope alone — it
554
+ // deleted whichever record the store happened to yield first, which
555
+ // was usually a SEEDED one, and the round-trip failed on the record
556
+ // it had created surviving. Intermittently: the created record's id
557
+ // is random, so where it falls in iteration order decides.
558
+ //
559
+ // Parent keys alone do not distinguish records, which is why this
560
+ // asks for the record's key or every composite part rather than
561
+ // merely for "the route has a parameter".
562
+ // WHICH SINGLE-RECORD OPS ADDRESS A DIFFERENT RESOURCE ENTIRELY.
563
+ //
564
+ // Not merely "cannot address the record": an op that addresses
565
+ // NOTHING is a singleton read, and github's `interaction`
566
+ // (`/user/interaction-limits`) is a real one. This is the other case
567
+ // — the op addresses something, and it is not this record. Every one
568
+ // is a tag-derived entity whose ops were gathered from unrelated
569
+ // routes: `migration`'s remove takes `owner` and `repo` and deletes
570
+ // a repository's migration archive, `user`'s deletes a GPG KEY, and
571
+ // `pull`'s deletes a review COMMENT. The id the caller passed is
572
+ // dropped, and the request goes anyway.
573
+ //
574
+ // Reads are untouched across the whole of github — this is 7 removes
575
+ // and 5 updates, and both are writes.
576
+ idmisaddressed: ['remove', 'update'].reduce(
577
+ (acc: Record<string, boolean>, opname: string) => {
578
+ acc[opname] = null != (ent.op || {})[opname] &&
579
+ 0 < addressKeys(ent, opname).length &&
580
+ !(0 < idParts(ent).length ?
581
+ idParts(ent).every((p: string) =>
582
+ addressKeys(ent, opname).includes(p)) :
583
+ addressKeys(ent, opname).includes(recordKey(ent)))
584
+ return acc
585
+ }, {}),
586
+ idaddressed: ['load', 'remove', 'update'].reduce(
587
+ (acc: Record<string, boolean>, opname: string) => {
588
+ const keys = addressKeys(ent, opname)
589
+ const parts = idParts(ent)
590
+ acc[opname] = 0 < parts.length ?
591
+ parts.every((p: string) => keys.includes(p)) :
592
+ keys.includes(recordKey(ent))
593
+ return acc
594
+ }, {}),
595
+ // Where each part is carried in a response. The test emitter needs
596
+ // it to know whether a created record's id can be rebuilt at all.
597
+ idfrom: (ent?.id?.from) || {},
437
598
  parents,
438
599
  parentOf,
439
600
  opParents,
@@ -550,6 +711,11 @@ const Main = cmp(function Main(props: any) {
550
711
  const provider = {
551
712
  Name, lower, ENV, sdkClass, pluginName, fileBase,
552
713
  sdkPkg, sdkVersion, entities,
714
+ // Whether the SDK dependency is a git tag rather than a registry
715
+ // package. The generated CI note says which, because "npm install is all
716
+ // you need" stops being true the moment git is in the path.
717
+ sdkGit: sdkDependency(model, target, { sdkVersion, sdkRepoUrl: repoInfo(model).repoUrl })
718
+ .startsWith('github:'),
553
719
  repoUrl: repo.url,
554
720
  // The SDK's own repo, for pointing at the companion test server which is
555
721
  // only distributed in source.
@@ -611,6 +777,74 @@ const Main = cmp(function Main(props: any) {
611
777
  })
612
778
 
613
779
 
780
+ // HOW THE PROVIDER DEPENDS ON THE SDK IT WRAPS, as one dependency value.
781
+ //
782
+ // Default: the PUBLISHED package pinned to the version the `ts` target
783
+ // publishes, so the two can never disagree. Right whenever the SDK is on a
784
+ // registry — and wrong when it is not. An SDK for a private API, or one not
785
+ // published yet, leaves the provider unable to `npm install` at all: the
786
+ // dependency 404s, so the package cannot be built, tested or released. That
787
+ // is not hypothetical; it is why @seneca/github-provider could not be
788
+ // regenerated and released for weeks.
789
+ //
790
+ // `kind: 'git'` points at a GIT TAG instead, which needs no registry.
791
+ //
792
+ // NPM RESOLVES A GIT DEPENDENCY AGAINST THE REPOSITORY ROOT, and sdkgen
793
+ // generates the TypeScript SDK into `ts/` — so the bare
794
+ // `github:owner/repo#ref` every example shows would install a directory with
795
+ // no package.json in it. npm spells the subdirectory `#<ref>::path:<sub>`
796
+ // (npm-package-arg resolves that to gitSubdir), and `path` therefore
797
+ // defaults to `ts` rather than to nothing: the default has to match the
798
+ // layout this toolchain actually produces, or the shorthand is a trap. `.`
799
+ // means the package IS the repository root.
800
+ //
801
+ // `spec` still wins over all of it, for anything the shorthand cannot say.
802
+ function sdkDependency(model: any, target: any, provider: any): string {
803
+ const dep = model?.main?.[KIT]?.target?.[target.name]?.sdk?.dep || {}
804
+
805
+ const spec = String(dep.spec || '').trim()
806
+ if ('' !== spec) {
807
+ return spec
808
+ }
809
+
810
+ if ('git' !== String(dep.kind || 'npm')) {
811
+ return `^${provider.sdkVersion}`
812
+ }
813
+
814
+ // `owner/repo`, from the SDK's own repository unless the project says
815
+ // otherwise. Accepts a full URL and reduces it, so a project can paste
816
+ // what its remote prints.
817
+ const repo = String(dep.repo || provider.sdkRepoUrl || '')
818
+ .replace(/^git\+/, '')
819
+ .replace(/^(https?:\/\/)?(www\.)?github\.com[/:]/, '')
820
+ .replace(/\.git$/, '')
821
+ .replace(/\/+$/, '')
822
+
823
+ if ('' === repo) {
824
+ throw new SdkGenError(
825
+ 'seneca-provider: sdk.dep.kind is "git" but no repository is known. ' +
826
+ 'Set `main.' + KIT + '.target.' + target.name +
827
+ '.sdk.dep.repo` to `owner/repo`, or state the whole dependency with ' +
828
+ '`sdk.dep.spec`.')
829
+ }
830
+
831
+ const ref = String(dep.ref || '').trim()
832
+ if ('' === ref) {
833
+ throw new SdkGenError(
834
+ 'seneca-provider: sdk.dep.kind is "git" but no `ref` is set. A git ' +
835
+ 'dependency with no ref follows the default branch, so an install ' +
836
+ 'today and an install tomorrow can differ — name the TAG to depend ' +
837
+ 'on, e.g. `sdk.dep.ref: "v' + provider.sdkVersion + '"`.')
838
+ }
839
+
840
+ // `.` (and empty) mean the repository root, which needs no path segment.
841
+ const sub = String(dep.path ?? 'ts').trim().replace(/^\/+|\/+$/g, '')
842
+
843
+ return `github:${repo}#${ref}` +
844
+ ('' === sub || '.' === sub ? '' : `::path:${sub}`)
845
+ }
846
+
847
+
614
848
  // --- package.json -----------------------------------------------------------
615
849
 
616
850
  const PackageJson = cmp(function PackageJson(props: any) {
@@ -699,11 +933,22 @@ const PackageJson = cmp(function PackageJson(props: any) {
699
933
  },
700
934
  // What actually ships. Without `files`, `npm publish` packs the test
701
935
  // suite and build output into the tarball.
702
- files: ['dist', 'src/**/*.ts', 'LICENSE'],
936
+ //
937
+ // `doc` IS PART OF THE PACKAGE. The generated README links to
938
+ // doc/tutorial.md, doc/how-to.md, doc/reference.md and
939
+ // doc/explanation.md with relative paths, so omitting it published a
940
+ // README whose every documentation link 404s for anyone reading the
941
+ // installed package rather than the repository. Either the links become
942
+ // absolute repository URLs or the files ship; they ship, because the
943
+ // docs describe the exact version installed and a URL would drift to
944
+ // whatever main says later.
945
+ files: ['dist', 'doc', 'src/**/*.ts', 'LICENSE'],
703
946
  engines: { node: '>=24' },
704
947
  dependencies: {
705
- // The SDK this plugin wraps, by its PUBLISHED name and version.
706
- [provider.sdkPkg]: `^${provider.sdkVersion}`,
948
+ // The SDK this plugin wraps. Published-and-pinned by default; a git
949
+ // tag when the project says so, because an unpublished SDK otherwise
950
+ // leaves this package unable to install at all. See sdkDependency.
951
+ [provider.sdkPkg]: sdkDependency(model, target, provider),
707
952
  ...dep('prod'),
708
953
  },
709
954
  peerDependencies: dep('peer'),
@@ -892,21 +1137,136 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
892
1137
  // needs translating in both directions, or `load$` requests a record
893
1138
  // keyed `undefined` and every entity handed back has no id at all — one
894
1139
  // that cannot then be saved or removed.
895
- const aliased = provider.entities.filter((e: any) => 'id' !== recordKey(e.ent))
896
- each(aliased, (e: any) => {
897
- const rk = recordKey(e.ent)
898
- Content(` // This API keys a ${e.name} by \`${rk}\`, Seneca by \`id\`. Carry the
899
- // API's key across so the Seneca entity has one.
900
- function id_${e.name}(data: any) {
901
- if (null != data && null == data.id) {
902
- data.id = ${jsProp('data', rk)}
1140
+ // ID TRANSLATION, ONE ALGORITHM AND A TABLE.
1141
+ //
1142
+ // Seneca entities carry exactly one `id`. Plenty of APIs do not: they
1143
+ // key a record by a differently-named field (`repo`, `number`), or by
1144
+ // SEVERAL path parameters at once with no single one that is the id
1145
+ // (github's `/repos/{owner}/{repo}`). Both need translating in both
1146
+ // directions, or `load$` asks for a record keyed `undefined` and every
1147
+ // record handed back has no id to save or remove it by.
1148
+ //
1149
+ // The LOGIC is the same for every API; only which parameters, which
1150
+ // separator and where they live in a response differ, and those are
1151
+ // model data. So this emits one `splitid`, one `joinid` and a table —
1152
+ // not a bespoke pair of functions per entity, which is what it used to
1153
+ // do and which duplicated the same algorithm N times over.
1154
+ const translated = provider.entities.filter((e: any) => null != idSpec(e.ent))
1155
+
1156
+ if (0 < translated.length) {
1157
+ const rows = translated.map((e: any) => {
1158
+ const spec: any = idSpec(e.ent)
1159
+ const from = null == spec.from ? '' :
1160
+ `, from: { ${Object.keys(spec.from).sort()
1161
+ .map((k: string) => `${jsKey(k)}: '${spec.from[k]}'`).join(', ')} }`
1162
+ return ` ${jsKey(e.name)}: { parts: [${
1163
+ spec.parts.map((p: string) => `'${p}'`).join(', ')}], sep: '${spec.sep}'${from} },`
1164
+ }).join('\n')
1165
+
1166
+ Content(` // HOW EACH ENTITY'S id MAPS TO THE API'S OWN KEYS, from the model.
1167
+ //
1168
+ // \`parts\` the path parameters that address one record, in path order.
1169
+ // One part is the ordinary case: the API just calls its key
1170
+ // something other than \`id\`. Two or more is a compound key,
1171
+ // where no single parameter names the record.
1172
+ // \`sep\` joins the parts into the one id a Seneca entity carries. A
1173
+ // slash cannot occur inside a path segment, so the join is
1174
+ // unambiguous and the split cannot over-split.
1175
+ // \`from\` where each part's value lives in a RESPONSE, as a dotted
1176
+ // path. A path parameter's name is not generally a response
1177
+ // field's name: github returns a repo's owner as an OBJECT
1178
+ // (\`owner.login\`) and its name as \`name\`, never \`repo\`.
1179
+ // A part missing here cannot be read back off a response.
1180
+ const ID_SPEC: Record<string, { parts: string[], sep: string, from?: Record<string, string> }> = {
1181
+ ${rows}
1182
+ }
1183
+
1184
+
1185
+ // Read a dotted path out of a record. \`from\` maps a path parameter to
1186
+ // wherever the response actually carries it, and that is sometimes inside
1187
+ // a nested object.
1188
+ function idread(data: any, path: string) {
1189
+ let node: any = data
1190
+ for (const key of path.split('.')) {
1191
+ if (null == node) {
1192
+ return undefined
1193
+ }
1194
+ node = node[key]
1195
+ }
1196
+ return node
1197
+ }
1198
+
1199
+
1200
+ // The Seneca id, split back into the parameters the API addresses a record
1201
+ // with. Refuses a wrong part count rather than sending a URL built from
1202
+ // whatever the id happened to contain — that would address a different
1203
+ // record, or none, and the 404 would name nothing useful.
1204
+ function splitid(name: string, id: any, what: string) {
1205
+ const spec = ID_SPEC[name]
1206
+ const text = null == id ? '' : String(id)
1207
+ const got = 1 === spec.parts.length ? [text] : text.split(spec.sep)
1208
+
1209
+ if (spec.parts.length !== got.length || got.some((p: string) => '' === p)) {
1210
+ throw new Error(
1211
+ '${provider.pkgName}: ' + name + ' ' + what +
1212
+ ": id must be '" + spec.parts.join(spec.sep) + "', got: " + JSON.stringify(id))
903
1213
  }
1214
+
1215
+ const out: Record<string, any> = {}
1216
+ spec.parts.forEach((p: string, i: number) => { out[p] = got[i] })
1217
+ return out
1218
+ }
1219
+
1220
+
1221
+ // The id for a record the API returned.
1222
+ //
1223
+ // \`vals\` are the parameters THIS request addressed it with, and they win:
1224
+ // a response does not always repeat them. Otherwise the parts are read out
1225
+ // of the response through \`from\`, which is what makes a created or listed
1226
+ // record identifiable at all.
1227
+ //
1228
+ // THE ADDRESSING KEY WINS over an \`id\` the response already carries. A
1229
+ // response often has both — github's pull has a global database \`id\` and
1230
+ // a repo-scoped \`number\` — and the unrelated one is no use for addressing
1231
+ // anything. It is kept as \`${provider.lower}_id\` rather than dropped.
1232
+ function joinid(name: string, data: any, vals?: any) {
1233
+ const spec = ID_SPEC[name]
1234
+ if (null == data) {
1235
+ return data
1236
+ }
1237
+
1238
+ let id = null
1239
+
1240
+ if (null != vals) {
1241
+ const got = spec.parts.map((p: string) => vals[p])
1242
+ if (got.every((v: any) => null != v && '' !== String(v))) {
1243
+ id = got.join(spec.sep)
1244
+ }
1245
+ }
1246
+
1247
+ if (null == id) {
1248
+ const got = spec.parts.map((p: string) =>
1249
+ idread(data, (spec.from || {})[p] || p))
1250
+ if (got.every((v: any) =>
1251
+ null != v && 'object' !== typeof v && '' !== String(v))) {
1252
+ id = got.join(spec.sep)
1253
+ }
1254
+ }
1255
+
1256
+ if (null != id) {
1257
+ if (null != data.id && String(data.id) !== id &&
1258
+ null == ${jsProp('data', provider.lower + '_id')}) {
1259
+ ${jsProp('data', provider.lower + '_id')} = data.id
1260
+ }
1261
+ data.id = id
1262
+ }
1263
+
904
1264
  return data
905
1265
  }
906
1266
 
907
1267
 
908
1268
  `)
909
- })
1269
+ }
910
1270
 
911
1271
  // WHICH SDK OP SERVES EACH `action$`, per entity and per cmd.
912
1272
  //
@@ -958,6 +1318,37 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
958
1318
  // That is the silent drop in another hat: the caller named something the
959
1319
  // entity does not have and was not told. An empty map inherits them all,
960
1320
  // so a cmd with no actions was the most exposed.
1321
+ // THE CMD THAT WOULD WRITE TO THE WRONG RESOURCE.
1322
+ //
1323
+ // Some entities gather their ops from unrelated routes, and then a cmd's
1324
+ // only route addresses something that is not this record: \`migration\`'s
1325
+ // remove deletes a repository's migration ARCHIVE, \`user\`'s deletes a GPG
1326
+ // KEY, \`pull\`'s deletes a review COMMENT. The id the caller passed is not
1327
+ // in the request at all.
1328
+ //
1329
+ // Such a cmd is refused rather than sent. A caller asking to remove one
1330
+ // record must not have a different resource deleted instead, and a
1331
+ // successful-looking reply is the worst possible answer. Where the real
1332
+ // operation exists it is reachable by name, through \`action$\`.
1333
+ function misaddressed(
1334
+ entname: string, cmd: string, key: string, addresses: string[]
1335
+ ) {
1336
+ const own = Object.prototype.hasOwnProperty
1337
+ const ents: any = own.call(ACTIONS, entname) ? ACTIONS[entname] : {}
1338
+ const acts = Object.keys(own.call(ents, cmd) ? ents[cmd] : {}).sort()
1339
+
1340
+ throw new Error(
1341
+ '${provider.pkgName}: ' + entname + ' ' + cmd +
1342
+ ': this API has no ' + cmd + ' route for one ' + entname +
1343
+ '. Its only ' + cmd + ' route addresses ' + addresses.join(', ') +
1344
+ ', not ' + key + ', so the id would be ignored and a different record ' +
1345
+ 'changed. ' +
1346
+ (0 < acts.length ?
1347
+ 'Name the operation with action\$ instead: ' + acts.join(', ') :
1348
+ 'No action\$ of this cmd is available either'))
1349
+ }
1350
+
1351
+
961
1352
  function actionop(name: string, entname: string, cmd: string) {
962
1353
  const own = Object.prototype.hasOwnProperty
963
1354
  const ents: any = own.call(ACTIONS, entname) ? ACTIONS[entname] : {}
@@ -1008,33 +1399,96 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
1008
1399
  // `save` guards the union of create's and update's keys: one action
1009
1400
  // serves both and dispatches at runtime, so it cannot know which set
1010
1401
  // applies until it has the data.
1402
+ // A COMPOSITE-KEY ENTITY GUARDS NOTHING SEPARATELY. Its parent keys
1403
+ // travel INSIDE the id, so demanding `q.owner` as well would reject
1404
+ // `load$('octocat/hello-world')` — the very call the composite id
1405
+ // exists to allow. splitid() does the checking instead, and refuses
1406
+ // a wrong part count by name.
1407
+ const eparts = idParts(e.ent)
1408
+ // The whole id description for this entity, or null when none is
1409
+ // needed. `out` branches on it rather than on the record key.
1410
+ const espec = idSpec(e.ent)
1011
1411
  const guard = (cmd: string, src: string) => {
1012
1412
  const keys = 'save' === cmd ?
1013
1413
  [...new Set([...(e.opParents.create || []), ...(e.opParents.update || [])])].sort() :
1014
1414
  (e.opParents[cmd] || [])
1015
1415
 
1016
1416
  return keys
1417
+ .filter((k: string) => !eparts.includes(k))
1017
1418
  .map((k: string) =>
1018
1419
  ` ${guardName(e, k)}(${jsProp(src, k)}, '${cmd}')\n`)
1019
1420
  .join('')
1020
1421
  }
1021
1422
 
1423
+ // THE REFUSAL LINE, for a cmd whose only route addresses a
1424
+ // different resource. Placed AFTER the action branch — an action
1425
+ // route is named explicitly and is exactly how the real operation is
1426
+ // reached — and BEFORE the guards, so the caller is told the cmd
1427
+ // does not exist for this entity rather than being asked for a
1428
+ // parameter that would not have helped.
1429
+ const refuse = (cmd: string) => {
1430
+ if (true !== (e.idmisaddressed || {})[cmd]) {
1431
+ return ''
1432
+ }
1433
+ const key = 0 < eparts.length ? eparts.join(String(e.idsep || '/')) :
1434
+ recordKey(e.ent)
1435
+ const addresses = addressKeys(e.ent, cmd)
1436
+
1437
+ return ` misaddressed('${e.name}', '${cmd}', '${key}', ` +
1438
+ `[${addresses.map((k: string) => `'${k}'`).join(', ')}])
1439
+ `
1440
+ }
1441
+
1022
1442
  // Reading the record's own key off the Seneca query, which always
1023
1443
  // spells it `id`, and every other required key off its own name.
1024
1444
  const rk = recordKey(e.ent)
1445
+
1446
+ // For a composite key the parameters come out of the split, which the
1447
+ // handler binds to `key` before the call. Any required key the id
1448
+ // does NOT carry still comes off the query.
1449
+ // THE RECORD'S OWN KEY IS ALWAYS SENT, whether or not the match
1450
+ // declares it required.
1451
+ //
1452
+ // An op gathers several routes, and a parameter only one of them
1453
+ // uses comes through OPTIONAL: github's IssueLoadMatch has `owner`
1454
+ // and `repo` required but `id` optional, because the op also covers
1455
+ // `/repos/{owner}/{repo}/issues/comments/{comment_id}`. Sending only
1456
+ // the required keys called `Issue().load({owner, repo})` — the id
1457
+ // the caller passed to `load$` went nowhere, so every read of any
1458
+ // issue in that repo answered with the same record and
1459
+ // `load$('no-such-issue')` returned one. Ten entities failed their
1460
+ // not-found test on it, and the ones that "passed" were passing for
1461
+ // the wrong reason. addressKeys is what the test emitter reads too.
1025
1462
  const sdkArg = (opname: string) => {
1026
- const keys = requiredKeys(e.ent, opname)
1463
+ const keys = addressKeys(e.ent, opname)
1027
1464
  if (0 === keys.length) {
1028
1465
  return '{}'
1029
1466
  }
1467
+ if (0 < eparts.length) {
1468
+ return `{ ${keys.map((k: string) => eparts.includes(k) ?
1469
+ `${jsKey(k)}: ${jsProp('key', k)}` :
1470
+ `${jsKey(k)}: ${jsProp('q', k)}`).join(', ')} }`
1471
+ }
1030
1472
  return `{ ${keys.map((k: string) =>
1031
1473
  `${jsKey(k)}: ${jsProp('q', k === rk ? 'id' : k)}`).join(', ')} }`
1032
1474
  }
1033
1475
 
1476
+ // The line that splits the Seneca id into the API's parameters,
1477
+ // emitted only where there is one record to address. `list` has none.
1478
+ // The entity-options argument that carries the path parameters for a
1479
+ // write. Empty for an ordinary entity, which needs no such channel.
1480
+ const entArg = 0 === eparts.length ? '' :
1481
+ `null == key ? undefined : { match: key }`
1482
+
1483
+ const splitLine = (cmd: string) => 0 === eparts.length ? '' :
1484
+ ` const key = splitid('${e.name}', ${'save' === cmd ? 'data.id' : 'q.id'}, '${cmd}')\n`
1485
+
1034
1486
  // The data hop, plus the id alias when the API keys the record by
1035
- // something other than `id`.
1036
- const out = (expr: string) =>
1037
- 'id' === rk ? `plain(${expr})` : `id_${e.name}(plain(${expr}))`
1487
+ // something other than `id`. A composite entity passes the addressing
1488
+ // values through, because the response may not repeat them.
1489
+ const out = (expr: string, vals?: string) =>
1490
+ null == espec ? `plain(${expr})` :
1491
+ `joinid('${e.name}', plain(${expr})${null == vals ? '' : ', ' + vals})`
1038
1492
 
1039
1493
  // The action branch, emitted for every cmd whether or not this entity
1040
1494
  // has actions. `actionop` is what refuses an unknown name, so leaving
@@ -1084,8 +1538,8 @@ ${actionBranch('list',
1084
1538
  ${actionBranch('load',
1085
1539
  ` const hit = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1086
1540
  return null == hit ? null : entize(${out('hit')})
1087
- `)}${guard('load', 'q')} const res = await ornull(() => this.shared.sdk.${e.acc}().load(${sdkArg('load')}))
1088
- return null == res ? null : entize(${out('res')})
1541
+ `)}${guard('load', 'q')}${splitLine('load')} const res = await ornull(() => this.shared.sdk.${e.acc}().load(${sdkArg('load')}))
1542
+ return null == res ? null : entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
1089
1543
  }
1090
1544
 
1091
1545
  `)
@@ -1099,22 +1553,94 @@ ${actionBranch('load',
1099
1553
  // entity's data, so its id lives at `id` whatever the API calls it —
1100
1554
  // dispatching on the API's key sent every save to `create`, leaving
1101
1555
  // update unreachable.
1556
+ // A MISADDRESSED UPDATE IS REFUSED, BUT ONLY ON THE UPDATE LEG. A
1557
+ // create needs no record key — the API assigns one — so an entity
1558
+ // whose update route addresses a different resource can still be
1559
+ // created. Dispatch is on `data.id`, so the refusal goes exactly
1560
+ // where the update would have.
1561
+ const refuseUpdate = true === (e.idmisaddressed || {}).update ?
1562
+ refuse('update') : ''
1563
+
1102
1564
  const body = hasCreate && hasUpdate
1103
- ? ` const res = null == data.id
1104
- ? await sdk.${e.acc}().create(data)
1105
- : await sdk.${e.acc}().update(data)`
1565
+ ? (('' === refuseUpdate) ? ` const res = null == data.id
1566
+ ? await sdk.${e.acc}(${entArg}).create(data)
1567
+ : await sdk.${e.acc}(${entArg}).update(data)` : ` if (null != data.id) {
1568
+ ${refuseUpdate.replace(/\n$/, '')}
1569
+ }
1570
+
1571
+ const res = await sdk.${e.acc}(${entArg}).create(data)`)
1106
1572
  : hasCreate
1107
- ? ` const res = await sdk.${e.acc}().create(data)`
1108
- : ` const res = await sdk.${e.acc}().update(data)`
1573
+ ? ` const res = await sdk.${e.acc}(${entArg}).create(data)`
1574
+ : `${refuseUpdate} const res = await sdk.${e.acc}(${entArg}).update(data)`
1109
1575
 
1110
1576
  // ... and hand the API back its own key, which Seneca does not know
1111
1577
  // to send.
1112
- const alias = 'id' === rk ? '' :
1578
+ //
1579
+ // A COMPOSITE KEY IS UNPACKED ONTO THE DATA. The write goes out as
1580
+ // a body plus path parameters, and the SDK reads those parameters
1581
+ // off the same object — so the parts have to be present under
1582
+ // their own names, not fused into `id`. On a create there is no id
1583
+ // yet and the caller supplies the parts directly, which is why this
1584
+ // only runs when an id is there.
1585
+ // THE COMPOSITE SPLIT CANNOT LIVE HERE, before the action branch,
1586
+ // even though that is where the single-key alias sits. The alias
1587
+ // only ever assigns; the split THROWS on an id that is not all its
1588
+ // parts, and an `action$` call is entitled to an id shaped however
1589
+ // that action's own route wants. Running it first turned
1590
+ // `action$: 'no_such_action'` into an id complaint, hiding the
1591
+ // error the caller needed. It is emitted after the action branch
1592
+ // instead — see compositeSave below, spliced where the parent
1593
+ // guards go, which is exactly the position the component already
1594
+ // documents as "after the action branch".
1595
+ const compositeSave = 0 < eparts.length ? `
1596
+ // Seneca carries this ${e.name}'s key as one \`id\`; the API addresses
1597
+ // the record by ${eparts.map((p: string) => '`' + p + '`').join(' and ')}.
1598
+ //
1599
+ // THE PARTS GO IN THE ENTITY MATCH, NOT ONTO THE DATA. They are path
1600
+ // parameters, and the data is the request body. Writing them onto the
1601
+ // data is how the flat \`${eparts[0]}\` the URL needs came to displace
1602
+ // whatever the response carries under that name — for github's repo an
1603
+ // \`owner\` OBJECT, so a saved record lost the field that identifies
1604
+ // it. The SDK resolves a path parameter from the match ahead of the
1605
+ // body, so passing it here leaves the body exactly as the caller meant
1606
+ // it.
1607
+ //
1608
+ // \`key\` STAYS NULL ON A CREATE: there is no id yet, the API assigns
1609
+ // the record, and the id is rebuilt from the response instead.
1610
+ let key = null
1611
+ if (null != data.id) {
1612
+ key = splitid('${e.name}', data.id, 'save')
1613
+ }
1614
+
1615
+ // \`${provider.lower}_id\` is this provider's own bookkeeping — the
1616
+ // API's unrelated \`id\`, parked by joinid() so it is not lost. It is
1617
+ // not a field of the API's write schema, so it must not travel in the
1618
+ // request body.
1619
+ delete ${jsProp('data', provider.lower + '_id')}
1620
+
1621
+ // AND NEITHER DOES THE JOINED \`id\`. It is Seneca's key for this
1622
+ // record, not the API's: a composite ${e.name} is addressed by
1623
+ // \`${eparts.join('\` and \`')}\`, which travel as path parameters in
1624
+ // the match above. Leaving it on the body sent \`owner0/repo0\` as a
1625
+ // field the write schema has no place for — and the offline transport,
1626
+ // which matches a request against a stored record, then looked for a
1627
+ // record whose own \`id\` was that joined string and found none.
1628
+ delete data.id
1629
+ ` : ''
1630
+
1631
+ const alias = 0 < eparts.length ? '' : 'id' === rk ? '' :
1113
1632
  `
1114
1633
  // This API keys a ${e.name} by \`${rk}\`; Seneca carries it as \`id\`.
1115
1634
  if (null == ${jsProp('data', rk)} && null != data.id) {
1116
1635
  ${jsProp('data', rk)} = data.id
1117
1636
  }
1637
+
1638
+ // \`${provider.lower}_id\` is this provider's own bookkeeping — the
1639
+ // API's unrelated \`id\`, parked by joinid() so it is not lost. It
1640
+ // is not a field of the API's write schema, so it must not travel in
1641
+ // the request body: a strict API rejects an unknown property, and a
1642
+ // lax one may persist it.
1643
+ delete ${jsProp('data', provider.lower + '_id')}
1118
1644
  `
1119
1645
 
1120
1646
  Content(`
@@ -1130,9 +1656,9 @@ ${actionBranch('save',
1130
1656
  data.$action = action$
1131
1657
  const done = await sdk.${e.acc}()[op$](data)
1132
1658
  return entize(${out('done')})
1133
- `)}${guard('save', 'data')}${body}
1659
+ `)}${guard('save', 'data')}${compositeSave}${body}
1134
1660
 
1135
- return entize(${out('res')})
1661
+ return entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
1136
1662
  }
1137
1663
 
1138
1664
  `)
@@ -1160,8 +1686,9 @@ ${actionBranch('save',
1160
1686
  ${actionBranch('remove',
1161
1687
  ` const gone = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1162
1688
  return null == gone ? null : entize(${out('gone')})
1163
- `)}${guard('remove', 'q')} await ornull(() => this.shared.sdk.${e.acc}().remove(${sdkArg('remove')}))
1164
- return null
1689
+ `)}${'' !== refuse('remove') ? refuse('remove') :
1690
+ `${guard('remove', 'q')}${splitLine('remove')} await ornull(() => this.shared.sdk.${e.acc}().remove(${sdkArg('remove')}))
1691
+ `} return null
1165
1692
  }
1166
1693
 
1167
1694
  `)
@@ -1181,9 +1708,20 @@ ${actionBranch('remove',
1181
1708
  const sdkopts: any = Object.assign({}, options.sdk)
1182
1709
  ${provider.authActive ? `
1183
1710
  // The provider convention carries credentials, so honour an \`apikey\`
1184
- // when one is configured and stay quiet when it is not.
1711
+ // when one is configured.
1185
1712
  const res = await this.post('sys:provider,get:keymap,provider:${provider.lower}')
1186
- const apikey = res?.keymap?.apikey?.value
1713
+
1714
+ // ACCEPT \`api\` AS WELL AS \`apikey\`. The older provider convention
1715
+ // named this key \`api\` and read it with
1716
+ // \`sys:provider,get:key,...,key:api\`; the keymap message replaced that,
1717
+ // and the rename was silent. An application still configured as
1718
+ // \`keys: { api: { value: ... } }\` therefore resolved to undefined and
1719
+ // the SDK was constructed with NO credential at all — the request went
1720
+ // out unauthenticated and failed much later as a 401 or a 404 on
1721
+ // anything private, with nothing at startup to point at the cause.
1722
+ // \`apikey\` wins when both are set, so a config that has migrated is
1723
+ // unaffected.
1724
+ const apikey = res?.keymap?.apikey?.value ?? res?.keymap?.api?.value
1187
1725
 
1188
1726
  // Hand the credential to the SDK as \`apikey\`, NOT as an authorization
1189
1727
  // HEADER. The SDK's own auth stage owns that header: it reads
@@ -1196,6 +1734,23 @@ ${provider.authActive ? `
1196
1734
  if (null != apikey && '' !== apikey) {
1197
1735
  sdkopts.apikey = apikey
1198
1736
  }
1737
+
1738
+ // AN UNRESOLVED CREDENTIAL IS SAID OUT LOUD. This API declares
1739
+ // authentication, so reaching here with nothing configured means every
1740
+ // call goes out unauthenticated. That is not always wrong — public
1741
+ // read-only endpoints work, at a much lower rate limit — so this warns
1742
+ // rather than throwing, and names both accepted key spellings so a
1743
+ // misnamed key is obvious from one line of log. Silence here is what
1744
+ // made the \`api\` -> \`apikey\` rename above cost a debugging session
1745
+ // instead of a glance.
1746
+ else {
1747
+ this.log.warn({
1748
+ fix: 'unauthenticated',
1749
+ note: 'no ${provider.lower} credential resolved from the keymap ' +
1750
+ '(looked for keys.apikey then keys.api); requests will be sent ' +
1751
+ 'unauthenticated and will fail on anything non-public',
1752
+ })
1753
+ }
1199
1754
  ${provider.authBasic ? `
1200
1755
  // Genuine HTTP Basic Auth needs a SECOND credential (the SDK sends
1201
1756
  // \`Authorization: Basic base64(apikey:secret)\`) — without it the SDK's