@voxgig/sdkgen-infrapack 0.0.1 → 0.0.4

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,
@@ -699,7 +860,16 @@ const PackageJson = cmp(function PackageJson(props: any) {
699
860
  },
700
861
  // What actually ships. Without `files`, `npm publish` packs the test
701
862
  // suite and build output into the tarball.
702
- files: ['dist', 'src/**/*.ts', 'LICENSE'],
863
+ //
864
+ // `doc` IS PART OF THE PACKAGE. The generated README links to
865
+ // doc/tutorial.md, doc/how-to.md, doc/reference.md and
866
+ // doc/explanation.md with relative paths, so omitting it published a
867
+ // README whose every documentation link 404s for anyone reading the
868
+ // installed package rather than the repository. Either the links become
869
+ // absolute repository URLs or the files ship; they ship, because the
870
+ // docs describe the exact version installed and a URL would drift to
871
+ // whatever main says later.
872
+ files: ['dist', 'doc', 'src/**/*.ts', 'LICENSE'],
703
873
  engines: { node: '>=24' },
704
874
  dependencies: {
705
875
  // The SDK this plugin wraps, by its PUBLISHED name and version.
@@ -892,21 +1062,136 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
892
1062
  // needs translating in both directions, or `load$` requests a record
893
1063
  // keyed `undefined` and every entity handed back has no id at all — one
894
1064
  // 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)}
1065
+ // ID TRANSLATION, ONE ALGORITHM AND A TABLE.
1066
+ //
1067
+ // Seneca entities carry exactly one `id`. Plenty of APIs do not: they
1068
+ // key a record by a differently-named field (`repo`, `number`), or by
1069
+ // SEVERAL path parameters at once with no single one that is the id
1070
+ // (github's `/repos/{owner}/{repo}`). Both need translating in both
1071
+ // directions, or `load$` asks for a record keyed `undefined` and every
1072
+ // record handed back has no id to save or remove it by.
1073
+ //
1074
+ // The LOGIC is the same for every API; only which parameters, which
1075
+ // separator and where they live in a response differ, and those are
1076
+ // model data. So this emits one `splitid`, one `joinid` and a table —
1077
+ // not a bespoke pair of functions per entity, which is what it used to
1078
+ // do and which duplicated the same algorithm N times over.
1079
+ const translated = provider.entities.filter((e: any) => null != idSpec(e.ent))
1080
+
1081
+ if (0 < translated.length) {
1082
+ const rows = translated.map((e: any) => {
1083
+ const spec: any = idSpec(e.ent)
1084
+ const from = null == spec.from ? '' :
1085
+ `, from: { ${Object.keys(spec.from).sort()
1086
+ .map((k: string) => `${jsKey(k)}: '${spec.from[k]}'`).join(', ')} }`
1087
+ return ` ${jsKey(e.name)}: { parts: [${
1088
+ spec.parts.map((p: string) => `'${p}'`).join(', ')}], sep: '${spec.sep}'${from} },`
1089
+ }).join('\n')
1090
+
1091
+ Content(` // HOW EACH ENTITY'S id MAPS TO THE API'S OWN KEYS, from the model.
1092
+ //
1093
+ // \`parts\` the path parameters that address one record, in path order.
1094
+ // One part is the ordinary case: the API just calls its key
1095
+ // something other than \`id\`. Two or more is a compound key,
1096
+ // where no single parameter names the record.
1097
+ // \`sep\` joins the parts into the one id a Seneca entity carries. A
1098
+ // slash cannot occur inside a path segment, so the join is
1099
+ // unambiguous and the split cannot over-split.
1100
+ // \`from\` where each part's value lives in a RESPONSE, as a dotted
1101
+ // path. A path parameter's name is not generally a response
1102
+ // field's name: github returns a repo's owner as an OBJECT
1103
+ // (\`owner.login\`) and its name as \`name\`, never \`repo\`.
1104
+ // A part missing here cannot be read back off a response.
1105
+ const ID_SPEC: Record<string, { parts: string[], sep: string, from?: Record<string, string> }> = {
1106
+ ${rows}
1107
+ }
1108
+
1109
+
1110
+ // Read a dotted path out of a record. \`from\` maps a path parameter to
1111
+ // wherever the response actually carries it, and that is sometimes inside
1112
+ // a nested object.
1113
+ function idread(data: any, path: string) {
1114
+ let node: any = data
1115
+ for (const key of path.split('.')) {
1116
+ if (null == node) {
1117
+ return undefined
1118
+ }
1119
+ node = node[key]
1120
+ }
1121
+ return node
1122
+ }
1123
+
1124
+
1125
+ // The Seneca id, split back into the parameters the API addresses a record
1126
+ // with. Refuses a wrong part count rather than sending a URL built from
1127
+ // whatever the id happened to contain — that would address a different
1128
+ // record, or none, and the 404 would name nothing useful.
1129
+ function splitid(name: string, id: any, what: string) {
1130
+ const spec = ID_SPEC[name]
1131
+ const text = null == id ? '' : String(id)
1132
+ const got = 1 === spec.parts.length ? [text] : text.split(spec.sep)
1133
+
1134
+ if (spec.parts.length !== got.length || got.some((p: string) => '' === p)) {
1135
+ throw new Error(
1136
+ '${provider.pkgName}: ' + name + ' ' + what +
1137
+ ": id must be '" + spec.parts.join(spec.sep) + "', got: " + JSON.stringify(id))
903
1138
  }
1139
+
1140
+ const out: Record<string, any> = {}
1141
+ spec.parts.forEach((p: string, i: number) => { out[p] = got[i] })
1142
+ return out
1143
+ }
1144
+
1145
+
1146
+ // The id for a record the API returned.
1147
+ //
1148
+ // \`vals\` are the parameters THIS request addressed it with, and they win:
1149
+ // a response does not always repeat them. Otherwise the parts are read out
1150
+ // of the response through \`from\`, which is what makes a created or listed
1151
+ // record identifiable at all.
1152
+ //
1153
+ // THE ADDRESSING KEY WINS over an \`id\` the response already carries. A
1154
+ // response often has both — github's pull has a global database \`id\` and
1155
+ // a repo-scoped \`number\` — and the unrelated one is no use for addressing
1156
+ // anything. It is kept as \`${provider.lower}_id\` rather than dropped.
1157
+ function joinid(name: string, data: any, vals?: any) {
1158
+ const spec = ID_SPEC[name]
1159
+ if (null == data) {
1160
+ return data
1161
+ }
1162
+
1163
+ let id = null
1164
+
1165
+ if (null != vals) {
1166
+ const got = spec.parts.map((p: string) => vals[p])
1167
+ if (got.every((v: any) => null != v && '' !== String(v))) {
1168
+ id = got.join(spec.sep)
1169
+ }
1170
+ }
1171
+
1172
+ if (null == id) {
1173
+ const got = spec.parts.map((p: string) =>
1174
+ idread(data, (spec.from || {})[p] || p))
1175
+ if (got.every((v: any) =>
1176
+ null != v && 'object' !== typeof v && '' !== String(v))) {
1177
+ id = got.join(spec.sep)
1178
+ }
1179
+ }
1180
+
1181
+ if (null != id) {
1182
+ if (null != data.id && String(data.id) !== id &&
1183
+ null == ${jsProp('data', provider.lower + '_id')}) {
1184
+ ${jsProp('data', provider.lower + '_id')} = data.id
1185
+ }
1186
+ data.id = id
1187
+ }
1188
+
904
1189
  return data
905
1190
  }
906
1191
 
907
1192
 
908
1193
  `)
909
- })
1194
+ }
910
1195
 
911
1196
  // WHICH SDK OP SERVES EACH `action$`, per entity and per cmd.
912
1197
  //
@@ -958,6 +1243,37 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
958
1243
  // That is the silent drop in another hat: the caller named something the
959
1244
  // entity does not have and was not told. An empty map inherits them all,
960
1245
  // so a cmd with no actions was the most exposed.
1246
+ // THE CMD THAT WOULD WRITE TO THE WRONG RESOURCE.
1247
+ //
1248
+ // Some entities gather their ops from unrelated routes, and then a cmd's
1249
+ // only route addresses something that is not this record: \`migration\`'s
1250
+ // remove deletes a repository's migration ARCHIVE, \`user\`'s deletes a GPG
1251
+ // KEY, \`pull\`'s deletes a review COMMENT. The id the caller passed is not
1252
+ // in the request at all.
1253
+ //
1254
+ // Such a cmd is refused rather than sent. A caller asking to remove one
1255
+ // record must not have a different resource deleted instead, and a
1256
+ // successful-looking reply is the worst possible answer. Where the real
1257
+ // operation exists it is reachable by name, through \`action$\`.
1258
+ function misaddressed(
1259
+ entname: string, cmd: string, key: string, addresses: string[]
1260
+ ) {
1261
+ const own = Object.prototype.hasOwnProperty
1262
+ const ents: any = own.call(ACTIONS, entname) ? ACTIONS[entname] : {}
1263
+ const acts = Object.keys(own.call(ents, cmd) ? ents[cmd] : {}).sort()
1264
+
1265
+ throw new Error(
1266
+ '${provider.pkgName}: ' + entname + ' ' + cmd +
1267
+ ': this API has no ' + cmd + ' route for one ' + entname +
1268
+ '. Its only ' + cmd + ' route addresses ' + addresses.join(', ') +
1269
+ ', not ' + key + ', so the id would be ignored and a different record ' +
1270
+ 'changed. ' +
1271
+ (0 < acts.length ?
1272
+ 'Name the operation with action\$ instead: ' + acts.join(', ') :
1273
+ 'No action\$ of this cmd is available either'))
1274
+ }
1275
+
1276
+
961
1277
  function actionop(name: string, entname: string, cmd: string) {
962
1278
  const own = Object.prototype.hasOwnProperty
963
1279
  const ents: any = own.call(ACTIONS, entname) ? ACTIONS[entname] : {}
@@ -1008,33 +1324,96 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
1008
1324
  // `save` guards the union of create's and update's keys: one action
1009
1325
  // serves both and dispatches at runtime, so it cannot know which set
1010
1326
  // applies until it has the data.
1327
+ // A COMPOSITE-KEY ENTITY GUARDS NOTHING SEPARATELY. Its parent keys
1328
+ // travel INSIDE the id, so demanding `q.owner` as well would reject
1329
+ // `load$('octocat/hello-world')` — the very call the composite id
1330
+ // exists to allow. splitid() does the checking instead, and refuses
1331
+ // a wrong part count by name.
1332
+ const eparts = idParts(e.ent)
1333
+ // The whole id description for this entity, or null when none is
1334
+ // needed. `out` branches on it rather than on the record key.
1335
+ const espec = idSpec(e.ent)
1011
1336
  const guard = (cmd: string, src: string) => {
1012
1337
  const keys = 'save' === cmd ?
1013
1338
  [...new Set([...(e.opParents.create || []), ...(e.opParents.update || [])])].sort() :
1014
1339
  (e.opParents[cmd] || [])
1015
1340
 
1016
1341
  return keys
1342
+ .filter((k: string) => !eparts.includes(k))
1017
1343
  .map((k: string) =>
1018
1344
  ` ${guardName(e, k)}(${jsProp(src, k)}, '${cmd}')\n`)
1019
1345
  .join('')
1020
1346
  }
1021
1347
 
1348
+ // THE REFUSAL LINE, for a cmd whose only route addresses a
1349
+ // different resource. Placed AFTER the action branch — an action
1350
+ // route is named explicitly and is exactly how the real operation is
1351
+ // reached — and BEFORE the guards, so the caller is told the cmd
1352
+ // does not exist for this entity rather than being asked for a
1353
+ // parameter that would not have helped.
1354
+ const refuse = (cmd: string) => {
1355
+ if (true !== (e.idmisaddressed || {})[cmd]) {
1356
+ return ''
1357
+ }
1358
+ const key = 0 < eparts.length ? eparts.join(String(e.idsep || '/')) :
1359
+ recordKey(e.ent)
1360
+ const addresses = addressKeys(e.ent, cmd)
1361
+
1362
+ return ` misaddressed('${e.name}', '${cmd}', '${key}', ` +
1363
+ `[${addresses.map((k: string) => `'${k}'`).join(', ')}])
1364
+ `
1365
+ }
1366
+
1022
1367
  // Reading the record's own key off the Seneca query, which always
1023
1368
  // spells it `id`, and every other required key off its own name.
1024
1369
  const rk = recordKey(e.ent)
1370
+
1371
+ // For a composite key the parameters come out of the split, which the
1372
+ // handler binds to `key` before the call. Any required key the id
1373
+ // does NOT carry still comes off the query.
1374
+ // THE RECORD'S OWN KEY IS ALWAYS SENT, whether or not the match
1375
+ // declares it required.
1376
+ //
1377
+ // An op gathers several routes, and a parameter only one of them
1378
+ // uses comes through OPTIONAL: github's IssueLoadMatch has `owner`
1379
+ // and `repo` required but `id` optional, because the op also covers
1380
+ // `/repos/{owner}/{repo}/issues/comments/{comment_id}`. Sending only
1381
+ // the required keys called `Issue().load({owner, repo})` — the id
1382
+ // the caller passed to `load$` went nowhere, so every read of any
1383
+ // issue in that repo answered with the same record and
1384
+ // `load$('no-such-issue')` returned one. Ten entities failed their
1385
+ // not-found test on it, and the ones that "passed" were passing for
1386
+ // the wrong reason. addressKeys is what the test emitter reads too.
1025
1387
  const sdkArg = (opname: string) => {
1026
- const keys = requiredKeys(e.ent, opname)
1388
+ const keys = addressKeys(e.ent, opname)
1027
1389
  if (0 === keys.length) {
1028
1390
  return '{}'
1029
1391
  }
1392
+ if (0 < eparts.length) {
1393
+ return `{ ${keys.map((k: string) => eparts.includes(k) ?
1394
+ `${jsKey(k)}: ${jsProp('key', k)}` :
1395
+ `${jsKey(k)}: ${jsProp('q', k)}`).join(', ')} }`
1396
+ }
1030
1397
  return `{ ${keys.map((k: string) =>
1031
1398
  `${jsKey(k)}: ${jsProp('q', k === rk ? 'id' : k)}`).join(', ')} }`
1032
1399
  }
1033
1400
 
1401
+ // The line that splits the Seneca id into the API's parameters,
1402
+ // emitted only where there is one record to address. `list` has none.
1403
+ // The entity-options argument that carries the path parameters for a
1404
+ // write. Empty for an ordinary entity, which needs no such channel.
1405
+ const entArg = 0 === eparts.length ? '' :
1406
+ `null == key ? undefined : { match: key }`
1407
+
1408
+ const splitLine = (cmd: string) => 0 === eparts.length ? '' :
1409
+ ` const key = splitid('${e.name}', ${'save' === cmd ? 'data.id' : 'q.id'}, '${cmd}')\n`
1410
+
1034
1411
  // 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}))`
1412
+ // something other than `id`. A composite entity passes the addressing
1413
+ // values through, because the response may not repeat them.
1414
+ const out = (expr: string, vals?: string) =>
1415
+ null == espec ? `plain(${expr})` :
1416
+ `joinid('${e.name}', plain(${expr})${null == vals ? '' : ', ' + vals})`
1038
1417
 
1039
1418
  // The action branch, emitted for every cmd whether or not this entity
1040
1419
  // has actions. `actionop` is what refuses an unknown name, so leaving
@@ -1084,8 +1463,8 @@ ${actionBranch('list',
1084
1463
  ${actionBranch('load',
1085
1464
  ` const hit = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1086
1465
  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')})
1466
+ `)}${guard('load', 'q')}${splitLine('load')} const res = await ornull(() => this.shared.sdk.${e.acc}().load(${sdkArg('load')}))
1467
+ return null == res ? null : entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
1089
1468
  }
1090
1469
 
1091
1470
  `)
@@ -1099,22 +1478,94 @@ ${actionBranch('load',
1099
1478
  // entity's data, so its id lives at `id` whatever the API calls it —
1100
1479
  // dispatching on the API's key sent every save to `create`, leaving
1101
1480
  // update unreachable.
1481
+ // A MISADDRESSED UPDATE IS REFUSED, BUT ONLY ON THE UPDATE LEG. A
1482
+ // create needs no record key — the API assigns one — so an entity
1483
+ // whose update route addresses a different resource can still be
1484
+ // created. Dispatch is on `data.id`, so the refusal goes exactly
1485
+ // where the update would have.
1486
+ const refuseUpdate = true === (e.idmisaddressed || {}).update ?
1487
+ refuse('update') : ''
1488
+
1102
1489
  const body = hasCreate && hasUpdate
1103
- ? ` const res = null == data.id
1104
- ? await sdk.${e.acc}().create(data)
1105
- : await sdk.${e.acc}().update(data)`
1490
+ ? (('' === refuseUpdate) ? ` const res = null == data.id
1491
+ ? await sdk.${e.acc}(${entArg}).create(data)
1492
+ : await sdk.${e.acc}(${entArg}).update(data)` : ` if (null != data.id) {
1493
+ ${refuseUpdate.replace(/\n$/, '')}
1494
+ }
1495
+
1496
+ const res = await sdk.${e.acc}(${entArg}).create(data)`)
1106
1497
  : hasCreate
1107
- ? ` const res = await sdk.${e.acc}().create(data)`
1108
- : ` const res = await sdk.${e.acc}().update(data)`
1498
+ ? ` const res = await sdk.${e.acc}(${entArg}).create(data)`
1499
+ : `${refuseUpdate} const res = await sdk.${e.acc}(${entArg}).update(data)`
1109
1500
 
1110
1501
  // ... and hand the API back its own key, which Seneca does not know
1111
1502
  // to send.
1112
- const alias = 'id' === rk ? '' :
1503
+ //
1504
+ // A COMPOSITE KEY IS UNPACKED ONTO THE DATA. The write goes out as
1505
+ // a body plus path parameters, and the SDK reads those parameters
1506
+ // off the same object — so the parts have to be present under
1507
+ // their own names, not fused into `id`. On a create there is no id
1508
+ // yet and the caller supplies the parts directly, which is why this
1509
+ // only runs when an id is there.
1510
+ // THE COMPOSITE SPLIT CANNOT LIVE HERE, before the action branch,
1511
+ // even though that is where the single-key alias sits. The alias
1512
+ // only ever assigns; the split THROWS on an id that is not all its
1513
+ // parts, and an `action$` call is entitled to an id shaped however
1514
+ // that action's own route wants. Running it first turned
1515
+ // `action$: 'no_such_action'` into an id complaint, hiding the
1516
+ // error the caller needed. It is emitted after the action branch
1517
+ // instead — see compositeSave below, spliced where the parent
1518
+ // guards go, which is exactly the position the component already
1519
+ // documents as "after the action branch".
1520
+ const compositeSave = 0 < eparts.length ? `
1521
+ // Seneca carries this ${e.name}'s key as one \`id\`; the API addresses
1522
+ // the record by ${eparts.map((p: string) => '`' + p + '`').join(' and ')}.
1523
+ //
1524
+ // THE PARTS GO IN THE ENTITY MATCH, NOT ONTO THE DATA. They are path
1525
+ // parameters, and the data is the request body. Writing them onto the
1526
+ // data is how the flat \`${eparts[0]}\` the URL needs came to displace
1527
+ // whatever the response carries under that name — for github's repo an
1528
+ // \`owner\` OBJECT, so a saved record lost the field that identifies
1529
+ // it. The SDK resolves a path parameter from the match ahead of the
1530
+ // body, so passing it here leaves the body exactly as the caller meant
1531
+ // it.
1532
+ //
1533
+ // \`key\` STAYS NULL ON A CREATE: there is no id yet, the API assigns
1534
+ // the record, and the id is rebuilt from the response instead.
1535
+ let key = null
1536
+ if (null != data.id) {
1537
+ key = splitid('${e.name}', data.id, 'save')
1538
+ }
1539
+
1540
+ // \`${provider.lower}_id\` is this provider's own bookkeeping — the
1541
+ // API's unrelated \`id\`, parked by joinid() so it is not lost. It is
1542
+ // not a field of the API's write schema, so it must not travel in the
1543
+ // request body.
1544
+ delete ${jsProp('data', provider.lower + '_id')}
1545
+
1546
+ // AND NEITHER DOES THE JOINED \`id\`. It is Seneca's key for this
1547
+ // record, not the API's: a composite ${e.name} is addressed by
1548
+ // \`${eparts.join('\` and \`')}\`, which travel as path parameters in
1549
+ // the match above. Leaving it on the body sent \`owner0/repo0\` as a
1550
+ // field the write schema has no place for — and the offline transport,
1551
+ // which matches a request against a stored record, then looked for a
1552
+ // record whose own \`id\` was that joined string and found none.
1553
+ delete data.id
1554
+ ` : ''
1555
+
1556
+ const alias = 0 < eparts.length ? '' : 'id' === rk ? '' :
1113
1557
  `
1114
1558
  // This API keys a ${e.name} by \`${rk}\`; Seneca carries it as \`id\`.
1115
1559
  if (null == ${jsProp('data', rk)} && null != data.id) {
1116
1560
  ${jsProp('data', rk)} = data.id
1117
1561
  }
1562
+
1563
+ // \`${provider.lower}_id\` is this provider's own bookkeeping — the
1564
+ // API's unrelated \`id\`, parked by joinid() so it is not lost. It
1565
+ // is not a field of the API's write schema, so it must not travel in
1566
+ // the request body: a strict API rejects an unknown property, and a
1567
+ // lax one may persist it.
1568
+ delete ${jsProp('data', provider.lower + '_id')}
1118
1569
  `
1119
1570
 
1120
1571
  Content(`
@@ -1130,9 +1581,9 @@ ${actionBranch('save',
1130
1581
  data.$action = action$
1131
1582
  const done = await sdk.${e.acc}()[op$](data)
1132
1583
  return entize(${out('done')})
1133
- `)}${guard('save', 'data')}${body}
1584
+ `)}${guard('save', 'data')}${compositeSave}${body}
1134
1585
 
1135
- return entize(${out('res')})
1586
+ return entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
1136
1587
  }
1137
1588
 
1138
1589
  `)
@@ -1160,8 +1611,9 @@ ${actionBranch('save',
1160
1611
  ${actionBranch('remove',
1161
1612
  ` const gone = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1162
1613
  return null == gone ? null : entize(${out('gone')})
1163
- `)}${guard('remove', 'q')} await ornull(() => this.shared.sdk.${e.acc}().remove(${sdkArg('remove')}))
1164
- return null
1614
+ `)}${'' !== refuse('remove') ? refuse('remove') :
1615
+ `${guard('remove', 'q')}${splitLine('remove')} await ornull(() => this.shared.sdk.${e.acc}().remove(${sdkArg('remove')}))
1616
+ `} return null
1165
1617
  }
1166
1618
 
1167
1619
  `)
@@ -1181,9 +1633,20 @@ ${actionBranch('remove',
1181
1633
  const sdkopts: any = Object.assign({}, options.sdk)
1182
1634
  ${provider.authActive ? `
1183
1635
  // The provider convention carries credentials, so honour an \`apikey\`
1184
- // when one is configured and stay quiet when it is not.
1636
+ // when one is configured.
1185
1637
  const res = await this.post('sys:provider,get:keymap,provider:${provider.lower}')
1186
- const apikey = res?.keymap?.apikey?.value
1638
+
1639
+ // ACCEPT \`api\` AS WELL AS \`apikey\`. The older provider convention
1640
+ // named this key \`api\` and read it with
1641
+ // \`sys:provider,get:key,...,key:api\`; the keymap message replaced that,
1642
+ // and the rename was silent. An application still configured as
1643
+ // \`keys: { api: { value: ... } }\` therefore resolved to undefined and
1644
+ // the SDK was constructed with NO credential at all — the request went
1645
+ // out unauthenticated and failed much later as a 401 or a 404 on
1646
+ // anything private, with nothing at startup to point at the cause.
1647
+ // \`apikey\` wins when both are set, so a config that has migrated is
1648
+ // unaffected.
1649
+ const apikey = res?.keymap?.apikey?.value ?? res?.keymap?.api?.value
1187
1650
 
1188
1651
  // Hand the credential to the SDK as \`apikey\`, NOT as an authorization
1189
1652
  // HEADER. The SDK's own auth stage owns that header: it reads
@@ -1196,6 +1659,23 @@ ${provider.authActive ? `
1196
1659
  if (null != apikey && '' !== apikey) {
1197
1660
  sdkopts.apikey = apikey
1198
1661
  }
1662
+
1663
+ // AN UNRESOLVED CREDENTIAL IS SAID OUT LOUD. This API declares
1664
+ // authentication, so reaching here with nothing configured means every
1665
+ // call goes out unauthenticated. That is not always wrong — public
1666
+ // read-only endpoints work, at a much lower rate limit — so this warns
1667
+ // rather than throwing, and names both accepted key spellings so a
1668
+ // misnamed key is obvious from one line of log. Silence here is what
1669
+ // made the \`api\` -> \`apikey\` rename above cost a debugging session
1670
+ // instead of a glance.
1671
+ else {
1672
+ this.log.warn({
1673
+ fix: 'unauthenticated',
1674
+ note: 'no ${provider.lower} credential resolved from the keymap ' +
1675
+ '(looked for keys.apikey then keys.api); requests will be sent ' +
1676
+ 'unauthenticated and will fail on anything non-public',
1677
+ })
1678
+ }
1199
1679
  ${provider.authBasic ? `
1200
1680
  // Genuine HTTP Basic Auth needs a SECOND credential (the SDK sends
1201
1681
  // \`Authorization: Basic base64(apikey:secret)\`) — without it the SDK's