@voxgig/sdkgen-infrapack 0.0.12 → 0.0.14

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.
@@ -6,6 +6,7 @@ import {
6
6
  opRequestShape, opParams, ownPoint, entityPath,
7
7
  collectDeps, repoInfo, packageName, packageVersion, apiName, envName,
8
8
  authorInfo, contributorList, isAuthActive, isHttpBasicAuth, jsKey, jsProp,
9
+ resolveAuthIn, resolveAuthName, resolveAuthPrefix,
9
10
  SdkGenError,
10
11
  PUBLISHER, PUBLISHER_URL,
11
12
  pointSegments,
@@ -21,36 +22,8 @@ import {
21
22
  import { Gitignore } from './Gitignore_seneca-provider'
22
23
 
23
24
 
24
- // The `seneca-provider` target: a Seneca plugin exposing this API's entities
25
- // as Seneca entities (`provider/<name>/<entity>`), layered on the sibling
26
- // `ts` SDK.
27
- //
28
- // A consumer target in the go-cli / py-data mould — every standard phase is
29
- // off in model/target/seneca-provider.aon and this component emits the
30
- // whole package. It differs from those in one way that shapes everything
31
- // here: it generates into ITS OWN REPO (`output: path`), depends on the SDK
32
- // as a PUBLISHED npm package rather than by path, and therefore carries a
33
- // repo's worth of furniture rather than a subfolder's.
34
- //
35
- // SHAPE OF THE MAPPING
36
- //
37
- // Seneca's store commands are list / load / save / remove. The SDK's are
38
- // list / load / create / update / remove. `save` is the one that is not
39
- // one-to-one: Seneca's convention is that an entity carrying an id is an
40
- // update and one without is a create, so `save` dispatches on `data.id`.
41
- //
42
- // Everything else follows from the model:
43
- // - which cmds exist at all, from the entity's declared ops;
44
- // - the required path params of each op, which become argument guards (a
45
- // nested entity like `moon` under `/planet/{planet_id}/moon` cannot build
46
- // its URL without the parent id, and an opaque 404 from a half-built URL
47
- // is a bad error message);
48
- // - the SDK accessor and entity class names, from the same helpers the ts
49
- // target uses, so the two cannot drift.
50
-
51
-
52
- // Seneca store cmd -> the SDK ops it needs. `save` needs BOTH create and
53
- // update; it is emitted when either is present and dispatches on the id.
25
+
26
+
54
27
  const CMD_OPS: Record<string, string[]> = {
55
28
  list: ['list'],
56
29
  load: ['load'],
@@ -59,28 +32,6 @@ const CMD_OPS: Record<string, string[]> = {
59
32
  }
60
33
 
61
34
 
62
- // The custom ACTIONS one Seneca cmd can reach, as action name -> SDK op.
63
- //
64
- // apidef folds a non-CRUD verb into an ordinary op as an extra point marked
65
- // `select.$action`: GitHub's `PUT /repos/{owner}/{repo}/pulls/{n}/merge` is a
66
- // second point of `pull.update`, beside the canonical `PATCH`. The SDK
67
- // selects one with `$action` in the call's argument; without this map the
68
- // provider has no way to name one at all, and `merge` is simply unreachable
69
- // through a generated plugin.
70
- //
71
- // KEYED BY CMD, NOT BY OP, and that is the whole point of the function.
72
- // `save` covers create AND update, so an action folded into `create` arrives
73
- // through `save$` exactly as one folded into `update` does — assuming
74
- // `update` would send `upload_image` (petstore's
75
- // `POST /pet/{petId}/uploadImage`, a create point) to the wrong endpoint, and
76
- // the SDK would then refuse it as an invalid action on an operation the
77
- // caller never named.
78
- //
79
- // A name claimed by an earlier op WINS: `entityActions` walks the op map in
80
- // sorted-key order, so for `save` that is create before update. Two ops of
81
- // one entity sharing an action name is not something apidef produces from a
82
- // spec — the name comes from the route — and if it ever does, a stable choice
83
- // beats a last-writer-wins one.
84
35
  function cmdActions(ent: any, cmd: string): Record<string, string> {
85
36
  const ops = CMD_OPS[cmd] || []
86
37
 
@@ -107,21 +58,13 @@ function cmdActions(ent: any, cmd: string): Record<string, string> {
107
58
  }
108
59
 
109
60
 
110
- // The ops that have a route of their OWN, as opposed to nothing but folded-in
111
- // actions. An op whose every point is an action point has no plain call: a
112
- // `save$` naming no action still reaches the SDK's `update`, but the SDK finds
113
- // one point and takes it, so the "canonical" update IS the action's route.
114
- //
115
- // Which is what the generated tests need to know before writing a plain,
116
- // id-bearing save: without this they were emitted for an entity that has no
117
- // such call to make.
118
61
  function canonicalOps(ent: any): string[] {
119
62
  return entityOps(ent).filter((opname: string) => {
120
63
  const op = (ent.op || {})[opname]
121
64
  const points: any[] = (op && op.points) || []
122
65
 
123
66
  return points.some((pt: any) =>
124
- null == (pt && pt.select && pt.select['$action']))
67
+ null == (pt && pt.q && pt.q['$action']))
125
68
  })
126
69
  }
127
70
 
@@ -146,9 +89,6 @@ function entityActionList(ent: any):
146
89
  }
147
90
 
148
91
 
149
- // The required (non-optional) request keys of an op, id first. These are what
150
- // the SDK needs to build the path, so they are what the provider must have
151
- // before it calls.
152
92
  function requiredKeys(ent: any, opname: string): string[] {
153
93
  const idf = entityIdField(ent)
154
94
  return opRequestShape(ent, opname).items
@@ -167,13 +107,6 @@ function addressKeys(ent: any, opname: string): string[] {
167
107
  const parts = idParts(ent)
168
108
 
169
109
  if (0 < parts.length) {
170
- // EVERY PART, required or not — for the same reason a single record key
171
- // always travels. github's api_insights_summary_stat is keyed
172
- // `actor_type/actor_id` and declares both OPTIONAL, because the op also
173
- // covers routes that take neither; only `min_timestamp` came through as
174
- // required. So the call sent the timestamp alone, the split id went
175
- // nowhere, and every read answered with the same record — the composite
176
- // half of the defect fixed below for single keys.
177
110
  const shape = opRequestShape(ent, opname).items
178
111
  for (const part of parts) {
179
112
  if (!keys.includes(part) &&
@@ -194,33 +127,6 @@ function addressKeys(ent: any, opname: string): string[] {
194
127
  }
195
128
 
196
129
 
197
- // The key that addresses ONE record.
198
- //
199
- // `entityIdField` answers whenever the model declares one, which apidef does
200
- // for any entity carrying a field literally named `id` — and it renames an
201
- // `<entity>_id` path param to `id` besides, which is why most APIs never reach
202
- // the fallback.
203
- //
204
- // When it does NOT answer, the record key is the LAST path param of the
205
- // op's own point, in PATH order — a route addresses parents first and the
206
- // record last, by construction. Without this the key was simply unknown,
207
- // so a param named `code` failed the `!== idf` test in opParentKeys and
208
- // was classified as a PARENT — `load$('SAVE20')` threw "coupon load: code
209
- // is required" instead of loading anything, and the entity was treated as
210
- // nested throughout.
211
- //
212
- // opParams(op) is NOT the source here: it alphabetizes params for output
213
- // stability, which loses path order on a 3+-param route — Airtable's
214
- // record (base_id, table_id, record_id) alphabetizes with table_id last,
215
- // so the old `params[params.length - 1]` picked the wrong parent as the
216
- // record's own key. The point's own `parts` still has the true order.
217
- // The path parameters that TOGETHER name one record, when no single one does.
218
- //
219
- // apidef sets `id.parts` for an API that addresses a record by several
220
- // adjacent path parameters — github needs {owner} AND {repo} to name a
221
- // repository — and `id.sep` (a slash) joins them into the ONE id a Seneca
222
- // entity carries. Empty for the ordinary single-key entity, which is most of
223
- // them, so every caller can branch on `0 < parts.length`.
224
130
  function idParts(ent: any): string[] {
225
131
  const parts = ent?.id?.parts
226
132
  return Array.isArray(parts) && 1 < parts.length ?
@@ -234,18 +140,6 @@ function idSep(ent: any): string {
234
140
  }
235
141
 
236
142
 
237
- // THE ONE DESCRIPTION OF AN ENTITY'S ID, or null when Seneca's `id` and the
238
- // API's key already agree and nothing needs translating.
239
- //
240
- // It unifies the two cases that used to be handled by separate emitters. A
241
- // compound key (`{owner}/{repo}`) has several parts; an API that simply calls
242
- // its key something else (`repo`, `number`) has exactly one. Neither needs a
243
- // different algorithm — a one-part split is the identity, and a one-part join
244
- // is the old carry-across — so both come through the same table and the same
245
- // two functions.
246
- //
247
- // `from` is passed through as the model states it: which response field
248
- // carries each part. apidef derives it and guide.aon can correct it.
249
143
  function idSpec(ent: any): { parts: string[], sep: string, from?: Record<string, string> } | null {
250
144
  const parts = idParts(ent)
251
145
  if (0 < parts.length) {
@@ -262,18 +156,27 @@ function idSpec(ent: any): { parts: string[], sep: string, from?: Record<string,
262
156
  return null
263
157
  }
264
158
 
265
- // A single-key entity whose key is not `id`. `from` defaults to the key's
266
- // own name, which is what the old carry-across read.
267
- return { parts: [rk], sep: idSep(ent) }
159
+ const from = ent?.id?.from
160
+ const path = null != from && 'object' === typeof from ? from[rk] : null
161
+ return {
162
+ parts: [rk],
163
+ sep: idSep(ent),
164
+ ...(null != path && '' !== String(path) ? { from: { [rk]: String(path) } } : {}),
165
+ }
268
166
  }
269
167
 
270
168
 
169
+ // The model's identifier when the load match carries it, else the terminal
170
+ // path parameter, else a lone required query parameter: the rule the SDK's
171
+ // offline transport keys its store by.
271
172
  function recordKey(ent: any): string {
272
173
  const idf = entityIdField(ent)
273
174
  if (null != idf && '' !== idf) {
274
175
  return String(idf)
275
176
  }
276
177
 
178
+ let fallback = ''
179
+
277
180
  for (const opname of ['load', 'remove', 'update']) {
278
181
  const op = (ent.op || {})[opname]
279
182
  if (null == op || 0 === (op.points || []).length) {
@@ -281,50 +184,66 @@ function recordKey(ent: any): string {
281
184
  }
282
185
 
283
186
  const canonical = op.points.filter((pt: any) =>
284
- null == (pt && pt.select && pt.select['$action']))
187
+ null == (pt && pt.q && pt.q['$action']))
285
188
  const point = ownPoint(0 < canonical.length ? canonical : op.points)
286
- // The LAST variable segment names the record's key. apidef states which
287
- // segments are variables (its ADR-003), so this reads the name off the
288
- // vector rather than finding a `{` and slicing the braces back off.
289
- const vars = pointSegments(point)
290
- .filter((seg: any) => null != seg.var)
291
-
292
- if (0 < vars.length) {
293
- return String(vars[vars.length - 1].var)
189
+ const segs = pointSegments(point)
190
+ const last = segs[segs.length - 1]
191
+
192
+ if (null != last && null != last.var) {
193
+ return String(last.var)
294
194
  }
295
195
 
296
- // No path param at all (e.g. GET /scan/async/result?trace_id=...): the
297
- // record's own key can still be a single required QUERY param.
298
- const query = (point && point.args && point.args.query) || []
299
- const reqdQuery = query.filter((q: any) => false !== q.reqd)
196
+ const query = (point && point.g && point.g.query) || []
197
+ const reqdQuery = query.filter((q: any) => false !== q.r)
300
198
  if (1 === reqdQuery.length) {
301
- return String(reqdQuery[0].name)
199
+ return String(reqdQuery[0].n)
200
+ }
201
+
202
+ // A literal-terminal route names a facet of a record: a last resort.
203
+ if ('' === fallback) {
204
+ const vars = segs.filter((seg: any) => null != seg.var)
205
+ if (0 < vars.length) {
206
+ fallback = String(vars[vars.length - 1].var)
207
+ }
302
208
  }
303
209
  }
304
210
 
305
- return 'id'
211
+ return '' !== fallback ? fallback : 'id'
212
+ }
213
+
214
+
215
+ // Does a create have to SEND the record key, or does the API assign it?
216
+ function rkOnCreate(ent: any): boolean {
217
+ const rk = recordKey(ent)
218
+
219
+ if ('id' === rk || 0 < idParts(ent).length || null == (ent.op || {}).create) {
220
+ return false
221
+ }
222
+
223
+ return opRequestShape(ent, 'create').items
224
+ .some((it: any) => it.name === rk && !it.optional)
225
+ }
226
+
227
+
228
+ // Is `<provider>_id` free for this entity, or a name it already uses itself?
229
+ function parkFree(ent: any, parked: string): boolean {
230
+ const taken = new Set<string>([
231
+ recordKey(ent),
232
+ ...idParts(ent),
233
+ ...parentKeys(ent),
234
+ ...Object.values(ent.fields || {}).map((f: any) => String(f.n)),
235
+ ])
236
+
237
+ return !taken.has(parked)
306
238
  }
307
239
 
308
240
 
309
- // The guard function's name. Spec-derived param names are not constrained to
310
- // identifiers — Evervault's `/payments/3ds-sessions/{3ds_session_id}` is the
311
- // standing example — and a name is a DECLARATION here, so it cannot be
312
- // bracket-quoted the way a property access can. Non-identifier characters are
313
- // replaced rather than dropped, so `a-b` and `a_b` cannot collide.
314
241
  function guardName(e: any, key: string): string {
315
242
  return `need_${e.name}_${String(key).replace(/[^A-Za-z0-9_$]/g, '_')}`
316
243
  .replace(/^need_(\d)/, 'need__$1')
317
244
  }
318
245
 
319
246
 
320
- // The parent PATH params of ONE op: `moon`'s load under
321
- // `/planet/{planet_id}/moon/{id}` yields ['planet_id']. These are the keys a
322
- // caller can forget, so each gets a guard.
323
- //
324
- // From opParams — the op's declared path params — NOT from opRequestShape.
325
- // For create/update the latter also returns the request BODY fields, so
326
- // reading it here generated a "planet name is required" guard for every
327
- // writable field on every entity.
328
247
  function opParentKeys(ent: any, opname: string): string[] {
329
248
  const rk = recordKey(ent)
330
249
  const op = (ent.op || {})[opname]
@@ -336,8 +255,8 @@ function opParentKeys(ent: any, opname: string): string[] {
336
255
  const seen = new Set<string>()
337
256
 
338
257
  for (const p of opParams(op)) {
339
- const name = String((p as any).name)
340
- if (false !== (p as any).reqd && name !== rk && name !== 'id') {
258
+ const name = String((p as any).n)
259
+ if (false !== (p as any).r && name !== rk && name !== 'id') {
341
260
  seen.add(name)
342
261
  }
343
262
  }
@@ -346,13 +265,6 @@ function opParentKeys(ent: any, opname: string): string[] {
346
265
  }
347
266
 
348
267
 
349
- // Every parent key the entity has, across its ACTIVE ops — for the seed data
350
- // and the docs, which describe the entity rather than one call.
351
- //
352
- // `entityOps` and not `Object.keys(ent.op)`: an op the model marks
353
- // `active: false` generates no SDK method, so letting it contribute a key
354
- // here put a mandatory guard for a parameter of a call that does not exist
355
- // onto every cmd that does.
356
268
  function parentKeys(ent: any): string[] {
357
269
  const seen = new Set<string>()
358
270
 
@@ -366,20 +278,6 @@ function parentKeys(ent: any): string[] {
366
278
  }
367
279
 
368
280
 
369
- // A model field's broad shape, for generating seed data that reads as data.
370
- // The model carries canon strings (`\`$STRING\``), not JS types.
371
- //
372
- // ORDER MATTERS and the tests are substring tests, so the container kinds are
373
- // checked FIRST: a multi-type field's sentinel is the ARRAY
374
- // `['`$ONE`', [members...]]`, and String() flattens it to a comma-joined
375
- // string — so a `$ONE` of string|number matched `includes('NUMBER')` and was
376
- // seeded as a bare number. A union is not a number; it is whatever its first
377
- // member is, and falling back to a string is the safe answer.
378
- //
379
- // `$ARRAY` and `$OBJECT` are in the sentinel vocabulary (see
380
- // helpers/canonType.ts) and used to fall through to 'string', which put
381
- // `tags: 'quick-tags'` into test/quick.js — a type-incorrect body that a
382
- // validating server rejects.
383
281
  function fieldKind(type: any): string {
384
282
  if (Array.isArray(type)) {
385
283
  return 'string'
@@ -396,21 +294,12 @@ function fieldKind(type: any): string {
396
294
  }
397
295
 
398
296
 
399
- // Which entity a parent path param refers to: `planet_id` -> `planet`, but
400
- // only when an entity of that name actually exists. A key that names no
401
- // entity gets no cross-reference, and the seed falls back to a plain string.
402
297
  function parentEntityOf(key: string, names: string[]): string {
403
298
  const stem = key.replace(/_id$/, '')
404
299
  return names.includes(stem) ? stem : ''
405
300
  }
406
301
 
407
302
 
408
- // The repo this provider is released from — NOT the SDK's. A provider is its
409
- // own package in its own repo, so its manifest's homepage/repository must
410
- // point there; deriving them from `main: kit: repo` sends every link in the
411
- // published package to the SDK instead.
412
- //
413
- // Order: the project's `output: repo`, else the Seneca convention.
414
303
  function providerRepo(model: any, lower: string, tname: string):
415
304
  { url: string, path: string } {
416
305
  const host = model?.main?.[KIT]?.repo?.host || 'github.com'
@@ -437,9 +326,6 @@ const Main = cmp(function Main(props: any) {
437
326
  const { target, ctx$ } = props
438
327
  const { model } = ctx$
439
328
 
440
- // HARD REQUIREMENT: this plugin imports the TypeScript SDK. Generating it
441
- // without `ts` produces a package whose every import fails, so fail at
442
- // GENERATE time with an actionable message instead.
443
329
  const targets = model.main[KIT].target || {}
444
330
  if (null == targets.ts) {
445
331
  throw new SdkGenError(
@@ -449,12 +335,12 @@ const Main = cmp(function Main(props: any) {
449
335
  'then regenerate.')
450
336
  }
451
337
 
452
- const Name = model.const.Name // Solardemo
453
- const lower = String(model.const.name) // solardemo
454
- const ENV = envName(model) // SOLARDEMO
455
- const sdkClass = `${Name}SDK` // SolardemoSDK
456
- const pluginName = `${Name}Provider` // SolardemoProvider
457
- const fileBase = `${lower}-provider` // solardemo-provider
338
+ const Name = model.const.Name
339
+ const lower = String(model.const.name)
340
+ const ENV = envName(model)
341
+ const sdkClass = `${Name}SDK`
342
+ const pluginName = `${Name}Provider`
343
+ const fileBase = `${lower}-provider`
458
344
 
459
345
  // The SDK is a PUBLISHED dependency, not a path: this package lives in its
460
346
  // own repo. Its name is whatever the ts target publishes under, pin
@@ -464,45 +350,23 @@ const Main = cmp(function Main(props: any) {
464
350
  const sdkPkg = packageName(model, 'npm')
465
351
  const sdkVersion = packageVersion(model, 'ts')
466
352
 
467
- // UNFILTERED by design — see the AGENTS.md sharp edge: entityCollection is
468
- // the resolver every component must use (getModelPath rebuilds its container
469
- // per call, defeating the class-name memo), and it deliberately includes
470
- // inactive entities because the typed-model emitters need them.
471
- //
472
- // Which makes filtering `active` the CALLER's job, and this component was
473
- // the one consumer target that skipped it — go-cli, go-mcp and py-data all
474
- // re-filter. The cost: MainEntity_ts emits an accessor only for an ACTIVE
475
- // entity, so an inactive one produced `this.shared.sdk.Ghost()` in the
476
- // provider and an `assert.equal(typeof sdk.Ghost, 'function')` in its tests,
477
- // against a method the SDK does not have.
478
353
  const entityColl = entityCollection(model)
479
354
 
480
355
  const activeEntities = Object.keys(entityColl).sort()
481
356
  .map((key: string) => entityColl[key])
482
357
  .filter((ent: any) => false !== ent.active)
483
358
 
484
- // Entities this provider can serve: those with at least one op that maps to
485
- // a Seneca store cmd. An entity with no such op would produce an empty cmd
486
- // map, which seneca-entity treats as a store that answers nothing.
487
359
  const entityNames = activeEntities.map((ent: any) => ent.name)
488
360
 
489
361
  const entities = activeEntities
490
362
  .map((ent: any) => {
491
363
  const parents = parentKeys(ent)
492
364
 
493
- // The parent entity PER KEY. Deriving it from `parents[0]` alone left an
494
- // entity nested two levels deep with no cross-reference for its outer
495
- // parents, so their seed values came out as the literal '0'.
496
365
  const parentOf: Record<string, string> = {}
497
366
  for (const key of parents) {
498
367
  parentOf[key] = parentEntityOf(key, entityNames)
499
368
  }
500
369
 
501
- // The parent keys of each op SEPARATELY. The guards used to be the union
502
- // across all ops, applied to every cmd alike, while the argument handed
503
- // to the SDK was computed per op — so an entity whose routes are not
504
- // uniformly nested (a flat `load`, a nested `create`) demanded a
505
- // parameter its own call would never use.
506
370
  const opParents: Record<string, string[]> = {}
507
371
  for (const opname of entityOps(ent)) {
508
372
  opParents[opname] = opParentKeys(ent, opname)
@@ -511,28 +375,14 @@ const Main = cmp(function Main(props: any) {
511
375
  return {
512
376
  ent,
513
377
  name: ent.name,
514
- // The SDK ACCESSOR on the client (`client.Moon()`), which is the
515
- // entity's PascalCase name — NOT entityClassName, which is the
516
- // collision-safe CLASS name the accessor constructs (`MoonEntity`).
517
- // Calling the class name reads plausibly and fails at runtime with
518
- // "sdk.MoonEntity is not a function". MainEntity_ts is the authority
519
- // for this: it declares the method as `${entity.Name}()`.
520
378
  acc: ent.Name,
521
- // The entity's canonical route, used to probe the live server for
522
- // liveness. Path params are left in place only if the route has
523
- // them — a collection route (the `list` op's) has none, which is why
524
- // entityPath prefers it.
525
379
  path: entityPath(ent),
526
380
  cls: entityClassName(ent, entityColl),
527
381
  ops: entityOps(ent),
528
382
  idf: entityIdField(ent),
529
- // The field this API's routes actually ADDRESS a record by, which is
530
- // not always `id` and is not always what entityIdField answers (that
531
- // returns null when the load match has no `id`, leaving recordKey to
532
- // read the route's last variable segment). The doc and test emitters
533
- // in Extras need the same answer the handler emitters use, or the
534
- // seed they build is keyed by a field the routes never look at.
535
383
  rk: recordKey(ent),
384
+ rkoncreate: rkOnCreate(ent),
385
+ parkfree: parkFree(ent, lower + '_id'),
536
386
  // The composite key, when this API addresses a record by several
537
387
  // path params at once. The doc and test emitters in Extras need the
538
388
  // same answer the handler emitters use: a test that addresses a
@@ -540,41 +390,6 @@ const Main = cmp(function Main(props: any) {
540
390
  // rejects.
541
391
  idparts: idParts(ent),
542
392
  idsep: idSep(ent),
543
- // DOES EACH SINGLE-RECORD OP ACTUALLY CARRY THE RECORD'S KEY?
544
- //
545
- // Only then can a wrong id miss on a LOAD: github's `interaction`
546
- // reads `/user/interaction-limits` — a singleton, called as
547
- // `load({})` — so `load$('no-such-id')` correctly returns the one
548
- // record there is, and a not-found test against it asserts the
549
- // opposite of the truth.
550
- //
551
- // And only then can a REMOVE delete what a create just made. A
552
- // tag-bucket entity can have ops addressing different resources
553
- // entirely: github's `action` is keyed `archive_format` from its
554
- // download route while its remove takes `hosted_runner_id` and
555
- // `org_id`, so the remove addressed by parent scope alone — it
556
- // deleted whichever record the store happened to yield first, which
557
- // was usually a SEEDED one, and the round-trip failed on the record
558
- // it had created surviving. Intermittently: the created record's id
559
- // is random, so where it falls in iteration order decides.
560
- //
561
- // Parent keys alone do not distinguish records, which is why this
562
- // asks for the record's key or every composite part rather than
563
- // merely for "the route has a parameter".
564
- // WHICH SINGLE-RECORD OPS ADDRESS A DIFFERENT RESOURCE ENTIRELY.
565
- //
566
- // Not merely "cannot address the record": an op that addresses
567
- // NOTHING is a singleton read, and github's `interaction`
568
- // (`/user/interaction-limits`) is a real one. This is the other case
569
- // — the op addresses something, and it is not this record. Every one
570
- // is a tag-derived entity whose ops were gathered from unrelated
571
- // routes: `migration`'s remove takes `owner` and `repo` and deletes
572
- // a repository's migration archive, `user`'s deletes a GPG KEY, and
573
- // `pull`'s deletes a review COMMENT. The id the caller passed is
574
- // dropped, and the request goes anyway.
575
- //
576
- // Reads are untouched across the whole of github — this is 7 removes
577
- // and 5 updates, and both are writes.
578
393
  idmisaddressed: ['remove', 'update'].reduce(
579
394
  (acc: Record<string, boolean>, opname: string) => {
580
395
  acc[opname] = null != (ent.op || {})[opname] &&
@@ -594,8 +409,6 @@ const Main = cmp(function Main(props: any) {
594
409
  keys.includes(recordKey(ent))
595
410
  return acc
596
411
  }, {}),
597
- // Where each part is carried in a response. The test emitter needs
598
- // it to know whether a created record's id can be rebuilt at all.
599
412
  idfrom: (ent?.id?.from) || {},
600
413
  parents,
601
414
  parentOf,
@@ -613,30 +426,15 @@ const Main = cmp(function Main(props: any) {
613
426
  acc[cmd] = cmdActions(ent, cmd)
614
427
  return acc
615
428
  }, {}),
616
- // The same information flattened, for the README and the generated
617
- // tests: [{ cmd, op, action, path }].
618
429
  actionList: entityActionList(ent),
619
- // The ops with a route of their own. `ops` minus those that are
620
- // nothing but folded-in actions — what the generated tests consult
621
- // before assuming a plain call exists.
622
430
  canonicalOps: canonicalOps(ent),
623
- // Required fields only: a seed record has to satisfy the shape the
624
- // SDK will hand back, and optional noise makes the assertions
625
- // harder to read.
626
- //
627
- // A parent path param is then FORCED IN even when the entity's own
628
- // schema omits it or marks it optional. A path param is a routing key,
629
- // not necessarily a response field: when the child's schema left it
630
- // out the seeded record had no link back to its parent, and the mock's
631
- // match found nothing — which the nested `load` test reported as a
632
- // TypeError and the nested `list` test reported as a pass.
633
431
  fields: (() => {
634
- const req = (ent.fields || [])
635
- .filter((f: any) => false !== f.req)
432
+ const req = (Object.values(ent.fields || {}))
433
+ .filter((f: any) => false !== f.r)
636
434
  .map((f: any) => ({
637
- name: f.name,
638
- kind: fieldKind(f.type),
639
- parentEntity: parentEntityOf(f.name, entityNames),
435
+ name: f.n,
436
+ kind: fieldKind(f.t),
437
+ parentEntity: parentEntityOf(f.n, entityNames),
640
438
  }))
641
439
 
642
440
  const have = new Set(req.map((f: any) => f.name))
@@ -677,27 +475,6 @@ const Main = cmp(function Main(props: any) {
677
475
  // once by the external pass — see cmp/ExternalTarget.
678
476
  const sdkrel = ctx$.sdkrelpath || '..'
679
477
 
680
- // WHERE THE SDK SOURCE IS, AND WHY IT IS NOT `sdkrel`.
681
- //
682
- // `sdkrel` is a walk back through the FILESYSTEM, and it is committed here
683
- // — into the docs, the live-test headers and the develop-locally recipe.
684
- // That makes generated content depend on one machine's directory layout.
685
- // voxgig-solardemo-sdk committed '../../voxgig-sdk/voxgig-solardemo-sdk',
686
- // where `voxgig-sdk` is the name of a workspace directory on one laptop and
687
- // no part of any model; a second developer with both repos under a
688
- // differently named parent regenerates a diff in tracked files and follows
689
- // instructions that are wrong on one of the two machines.
690
- //
691
- // So the SDK is named by its PIN instead — repository and release tag, both
692
- // facts about the SDK rather than about anyone's disk — and fetched to a
693
- // fixed path INSIDE this repo. `make sdk-src` puts it there. The path is
694
- // then the same string on every machine and under every layout, which is
695
- // what lets this repo regenerate itself from a checkout it fetched (see
696
- // SdkPin and the Makefile).
697
- //
698
- // The tag is `v<version>`: the SDK's publish workflow cuts exactly that for
699
- // its primary npm target, so the pin needs no separate bookkeeping and
700
- // cannot drift from the dependency version.
701
478
  const sdkRepoUrl = String(repoInfo(model).repoUrl || '')
702
479
  const sdkTag = 'v' + sdkVersion
703
480
  const sdkRepoDir = sdkRepoUrl.replace(/[/]+$/, '').split('/').pop() || 'sdk'
@@ -708,22 +485,6 @@ const Main = cmp(function Main(props: any) {
708
485
  const sdkPinned = '' !== sdkRepoUrl
709
486
  const sdkSrc = sdkPinned ? SDK_SRC_DIR + '/' + sdkRepoDir : sdkrel
710
487
 
711
- // Where a live run points — and whether there is anything honest to point
712
- // it at.
713
- //
714
- // NOT simply `servers[0].url`. For an OpenAPI-derived model that is the
715
- // PRODUCTION host of a third-party API, and everything gated on it aims
716
- // there: the `describe('live')` block, which `npm test` runs on every CI
717
- // push on three operating systems; the `serverUp` probe in front of it; and
718
- // test/quick.js, whose header says "start the companion server first" while
719
- // BASE silently defaults to production and whose body is a create / update /
720
- // remove cycle. A live suite that reaches a stranger's API from CI is not a
721
- // live suite, it is traffic — and unauthenticated traffic at that.
722
- //
723
- // So a live base is taken only when it is unambiguously OURS: declared
724
- // outright by the project, or a loopback address, which no third party can
725
- // be behind. Anything else leaves live testing ungenerated, which is the
726
- // honest answer for an API nobody here runs.
727
488
  const live = model?.main?.[KIT]?.test?.live || {}
728
489
  const servers = (model?.main?.[KIT]?.info?.servers || [])
729
490
  const specBase = 0 < servers.length ? String(servers[0].url || '') : ''
@@ -748,9 +509,6 @@ const Main = cmp(function Main(props: any) {
748
509
  const provider = {
749
510
  Name, lower, ENV, sdkClass, pluginName, fileBase,
750
511
  sdkPkg, sdkVersion, entities,
751
- // The SDK dependency, resolved ONCE. PackageJson emits it and the CI
752
- // note describes it, and computing it twice is how the second caller
753
- // came to pass a half-built provider object into it.
754
512
  sdkDep,
755
513
  // Whether that dependency comes from outside a registry. The generated
756
514
  // CI note says so, because "npm install is all you need" stops being
@@ -761,61 +519,34 @@ const Main = cmp(function Main(props: any) {
761
519
  // that says the wrong one is worse than no note.
762
520
  sdkDepKind: sdkDep.startsWith('github:') ? 'git' :
763
521
  (sdkDep.startsWith('http') ? 'release' : 'npm'),
764
- // THE OPT-IN A NON-REGISTRY DEPENDENCY NEEDS, or ''.
765
- //
766
- // npm 12 defaults `allow-git` and `allow-remote` to "none" and REFUSES
767
- // such a dependency outright — EALLOWGIT / EALLOWREMOTE, before it
768
- // fetches anything. Older npm allowed both, so a workflow that passes
769
- // today fails the moment its runner image updates; this provider's own
770
- // publish run failed that way within hours of the build run passing,
771
- // because publishing upgrades npm first.
772
- //
773
- // Emitted ONLY where the project chose such a dependency: it is the
774
- // project opting into what it already declared, not a default weakened
775
- // for everyone. Publishing the SDK remains the durable answer, and this
776
- // flag exists so an unpublished one is workable meanwhile.
777
522
  sdkInstallFlag: sdkDep.startsWith('github:') ? ' --allow-git=all' :
778
523
  (sdkDep.startsWith('http') ? ' --allow-remote=all' : ''),
779
524
  repoUrl: repo.url,
780
- // The SDK's own repo, for pointing at the companion test server which is
781
- // only distributed in source.
782
525
  sdkRepoUrl,
783
526
  sdkTag,
784
527
  sdkRepoDir,
785
528
  sdkPinned,
786
- // Where the SDK SOURCE is reached, as every generated file spells it.
787
- // Layout-independent when the SDK has a repo to pin. See above.
788
529
  sdkSrc,
789
530
  api: apiName(model),
790
531
  version: packageVersion(model, target.name),
791
532
  liveBase,
792
533
  liveApp,
793
- // The sponsor line. @seneca/maintain's `content_readme` check requires
794
- // the publisher's name in the README, so this is load-bearing rather
795
- // than decorative — the generated `maintain` test fails without it.
796
534
  publisher: PUBLISHER,
797
535
  publisherUrl: PUBLISHER_URL,
798
- // A route with NO path params, so the probe is a plain GET. An entity
799
- // whose every route is parameterised gives none, and then the probe
800
- // falls back to the base URL itself.
801
536
  probePath: (entities.find((e: any) =>
802
537
  '' !== e.path && !e.path.includes('{')) || { path: '' }).path,
803
- // The provider's OWN published name. Not derived from the SDK slug the
804
- // way a language target's is — a provider is `@seneca/<name>-provider` —
805
- // so read the project's pin directly and default to that shape.
806
538
  pkgName: providerPackage(model, lower, target.name),
807
539
  // Whether this API authenticates at all. Decides if the plugin plumbs a
808
540
  // credential: the SDK's auth stage emits nothing for an auth-inactive
809
541
  // model and strips the authorization header regardless of options, so
810
542
  // plumbing one anyway produces a credential path that cannot work.
811
543
  authActive: isAuthActive(model),
812
- // Whether this API's scheme is genuine HTTP Basic Auth (two credentials,
813
- // base64-joined), matching the SDK's own auth.basic signal. Decides
814
- // whether the plugin also plumbs a `secret` alongside `apikey` — a
815
- // Basic-Auth SDK's own auth stage deletes the header when either is
816
- // missing, so a provider forwarding apikey alone could never
817
- // authenticate, the same class of gap `apikey`-only forwarding was.
818
544
  authBasic: isAuthActive(model) && isHttpBasicAuth(model),
545
+ // Where the credential goes, resolved as the SDK's auth stage does.
546
+ authIn: resolveAuthIn(model),
547
+ authName: 'header' === resolveAuthIn(model) ?
548
+ resolveAuthName(model).toLowerCase() : resolveAuthName(model),
549
+ authPrefix: resolveAuthPrefix(model),
819
550
  }
820
551
 
821
552
  // `.gitignore` is EMITTED rather than copied — npm strips that filename
@@ -824,8 +555,6 @@ const Main = cmp(function Main(props: any) {
824
555
  // target calls its own.
825
556
  Gitignore({})
826
557
 
827
- // Static furniture: LICENSE, CODE_OF_CONDUCT, Makefile, tsfmt.json and
828
- // both tsconfigs. Same for every provider.
829
558
  Copy({
830
559
  from: 'tm/' + target.name,
831
560
  replace: { ...ctx$.stdrep },
@@ -843,36 +572,6 @@ const Main = cmp(function Main(props: any) {
843
572
  })
844
573
 
845
574
 
846
- // HOW THE PROVIDER DEPENDS ON THE SDK IT WRAPS, as one dependency value.
847
- //
848
- // Default: the PUBLISHED package pinned to the version the `ts` target
849
- // publishes, so the two can never disagree. Right whenever the SDK is on a
850
- // registry — and wrong when it is not. An SDK for a private API, or one not
851
- // published yet, leaves the provider unable to `npm install` at all: the
852
- // dependency 404s, so the package cannot be built, tested or released. That
853
- // is not hypothetical; it is why @seneca/github-provider could not be
854
- // regenerated and released for weeks.
855
- //
856
- // `kind: 'git'` points at a GIT TAG instead, which needs no registry.
857
- //
858
- // NPM RESOLVES A GIT DEPENDENCY AGAINST THE REPOSITORY ROOT, and sdkgen
859
- // generates the TypeScript SDK into `ts/`. So `kind: 'git'` suits an SDK
860
- // whose package.json IS the repository root, and NOT the layout this
861
- // toolchain produces.
862
- //
863
- // THE `::path:` SUBDIRECTORY SYNTAX DOES NOT WORK, and it looks like it
864
- // does. npm-package-arg parses `#<ref>::path:ts` and reports
865
- // `gitSubdir: /ts`, so a spec built that way reads as correct — but the
866
- // INSTALLER ignores it: npm clones the repository and opens package.json at
867
- // the clone root, failing with ENOENT on linux, macOS and Windows alike.
868
- // That was measured, on all three, after the parser had said otherwise.
869
- //
870
- // `kind: 'release'` is the form that works for a package in a subdirectory:
871
- // a GitHub release asset, which is `npm pack` output attached to the tag.
872
- // npm installs an https tarball natively and never looks at the repository
873
- // layout at all.
874
- //
875
- // `spec` still wins over all of it, for anything the shorthand cannot say.
876
575
  function sdkDependency(model: any, target: any, provider: any): string {
877
576
  const dep = model?.main?.[KIT]?.target?.[target.name]?.sdk?.dep || {}
878
577
 
@@ -913,8 +612,6 @@ function sdkDependency(model: any, target: any, provider: any): string {
913
612
  }
914
613
 
915
614
  if ('release' === kind) {
916
- // The asset `npm pack` produces: scope and name flattened, then the
917
- // version. `asset` overrides it for a project that names its own.
918
615
  const asset = String(dep.asset || '').trim() || (
919
616
  provider.sdkPkg.replace(/^@/, '').replace(/\//g, '-') +
920
617
  '-' + provider.sdkVersion + '.tgz')
@@ -926,7 +623,6 @@ function sdkDependency(model: any, target: any, provider: any): string {
926
623
  }
927
624
 
928
625
 
929
- // --- package.json -----------------------------------------------------------
930
626
 
931
627
  const PackageJson = cmp(function PackageJson(props: any) {
932
628
  const { provider, target } = props
@@ -934,19 +630,6 @@ const PackageJson = cmp(function PackageJson(props: any) {
934
630
 
935
631
  const deps = collectDeps(model, target.name, target.deps, props.ctx$.log)
936
632
 
937
- // collectDeps returns { name, version, source, raw } — the DECLARED kind
938
- // (prod/peer/dev) is on `raw`, not hoisted. Reading `d.kind` instead
939
- // silently yields an empty manifest section, which is how the first cut of
940
- // this component shipped a package.json with no seneca peers at all.
941
- //
942
- // `kind` is a COMMA-SEPARATED LIST, so one dependency can land in more than
943
- // one manifest section. That is the Seneca plugin convention and not an
944
- // embellishment: `seneca` is a PEER (the plugin must run inside the host's
945
- // instance, never its own bundled copy) and also a DEV dependency (the test
946
- // suite does `require('seneca')` directly, so a bare `npm install` in a
947
- // clean checkout has to produce it). collectDeps deduplicates by package
948
- // name and the model's dep map is keyed by name, so the same package cannot
949
- // be declared twice — the kind has to carry the list instead.
950
633
  const dep = (kind: string) => {
951
634
  const out: Record<string, string> = {}
952
635
  const kinds = (d: any) => String((d.raw && d.raw.kind) || '')
@@ -978,9 +661,6 @@ const PackageJson = cmp(function PackageJson(props: any) {
978
661
  keywords: ['seneca', provider.lower, `${provider.lower}-provider`,
979
662
  provider.publisher.toLowerCase(), 'sdk'],
980
663
  author,
981
- // Omitted entirely when the project names none, rather than emitted as an
982
- // empty array — npm treats `"contributors": []` as a declaration that
983
- // there are none, which is a different claim from not having said.
984
664
  ...(0 < contributors.length ? { contributors } : {}),
985
665
  license: 'MIT',
986
666
  repository: { type: 'git', url: `git+${provider.repoUrl}.git` },
@@ -1012,17 +692,6 @@ const PackageJson = cmp(function PackageJson(props: any) {
1012
692
  'npm run build && npm run test && npm run repo-tag && ' +
1013
693
  'npm publish --access public --registry https://registry.npmjs.org',
1014
694
  },
1015
- // What actually ships. Without `files`, `npm publish` packs the test
1016
- // suite and build output into the tarball.
1017
- //
1018
- // `doc` IS PART OF THE PACKAGE. The generated README links to
1019
- // doc/tutorial.md, doc/how-to.md, doc/reference.md and
1020
- // doc/explanation.md with relative paths, so omitting it published a
1021
- // README whose every documentation link 404s for anyone reading the
1022
- // installed package rather than the repository. Either the links become
1023
- // absolute repository URLs or the files ship; they ship, because the
1024
- // docs describe the exact version installed and a URL would drift to
1025
- // whatever main says later.
1026
695
  files: ['dist', 'doc', 'src/**/*.ts', 'LICENSE'],
1027
696
  engines: { node: '>=24' },
1028
697
  dependencies: {
@@ -1042,11 +711,12 @@ const PackageJson = cmp(function PackageJson(props: any) {
1042
711
  })
1043
712
 
1044
713
 
1045
- // --- src/<name>-provider.ts -------------------------------------------------
1046
714
 
1047
715
  const ProviderSource = cmp(function ProviderSource(props: any) {
1048
716
  const { provider } = props
1049
717
 
718
+ const translated = provider.entities.filter((e: any) => null != idSpec(e.ent))
719
+
1050
720
  Folder({ name: 'src' }, () => {
1051
721
  File({ name: `${provider.fileBase}.ts` }, () => {
1052
722
  Content(`/* Generated by @voxgig/sdkgen. Do not edit. */
@@ -1145,8 +815,8 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
1145
815
 
1146
816
 
1147
817
  // The SDK argument for an ACTION on a read cmd: everything the caller sent
1148
- // minus Seneca's own directives, with the record key carried across, plus
1149
- // the \`$action\` selector the SDK dispatches on.
818
+ // minus Seneca's own directives, with the id translated into the API's own
819
+ // keys, plus the \`$action\` selector the SDK dispatches on.
1150
820
  //
1151
821
  // WIDER than the canonical argument on purpose. An action route has its own
1152
822
  // parameters — GitHub's merge takes commit_title and merge_method, which the
@@ -1154,14 +824,21 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
1154
824
  // required keys would strip the action's whole payload. The SDK still
1155
825
  // validates: an action whose own point cannot be built is refused with
1156
826
  // \`point_action_invalid\` rather than sent somewhere else.
1157
- function actionq(q: any, rk: string, action: string) {
827
+ function actionq(q: any, ${0 === translated.length ? '' : 'name: string, '}action: string) {
1158
828
  const out = cleanq(q)
829
+ ${0 === translated.length ? '' : `
830
+ // An action addresses the SAME record the canonical route does, so the id
831
+ // is translated the same way a write translates it: split into the path
832
+ // parameters the API names, under the names it names them by. Sending the
833
+ // joined id as one terminal parameter asks for \`owner0/repo0\` as a repo.
834
+ const own = Object.prototype.hasOwnProperty
835
+ const spec = own.call(ID_SPEC, name) ? ID_SPEC[name] : null
1159
836
 
1160
- if ('id' !== rk && null != out.id) {
1161
- out[rk] = out.id
837
+ if (null != spec && null != out.id) {
838
+ Object.assign(out, splitid(name, out.id, 'action'))
1162
839
  delete out.id
1163
840
  }
1164
-
841
+ `}
1165
842
  out.$action = action
1166
843
  return out
1167
844
  }
@@ -1203,6 +880,15 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
1203
880
  '${provider.pkgName}: ${e.name} ' + cmd + ': ${k} is required'
1204
881
  )
1205
882
  }
883
+
884
+ // A path parameter is one value. A loaded record can carry an object
885
+ // under the same name, and that must not be sent as a URL segment.
886
+ if ('object' === typeof value) {
887
+ throw new Error(
888
+ '${provider.pkgName}: ${e.name} ' + cmd + ': ${k} must be a single ' +
889
+ 'value to build the request path, not an object'
890
+ )
891
+ }
1206
892
  return value
1207
893
  }
1208
894
 
@@ -1212,36 +898,15 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
1212
898
  })
1213
899
  }
1214
900
 
1215
- // Seneca's own entity key is ALWAYS literally `id` — `load$('x')` sets
1216
- // `q = { id: 'x' }`, and the id of the entity it builds comes off
1217
- // `data.id`. An API that addresses a record by anything else therefore
1218
- // needs translating in both directions, or `load$` requests a record
1219
- // keyed `undefined` and every entity handed back has no id at all — one
1220
- // that cannot then be saved or removed.
1221
- // ID TRANSLATION, ONE ALGORITHM AND A TABLE.
1222
- //
1223
- // Seneca entities carry exactly one `id`. Plenty of APIs do not: they
1224
- // key a record by a differently-named field (`repo`, `number`), or by
1225
- // SEVERAL path parameters at once with no single one that is the id
1226
- // (github's `/repos/{owner}/{repo}`). Both need translating in both
1227
- // directions, or `load$` asks for a record keyed `undefined` and every
1228
- // record handed back has no id to save or remove it by.
1229
- //
1230
- // The LOGIC is the same for every API; only which parameters, which
1231
- // separator and where they live in a response differ, and those are
1232
- // model data. So this emits one `splitid`, one `joinid` and a table —
1233
- // not a bespoke pair of functions per entity, which is what it used to
1234
- // do and which duplicated the same algorithm N times over.
1235
- const translated = provider.entities.filter((e: any) => null != idSpec(e.ent))
1236
-
1237
901
  if (0 < translated.length) {
1238
902
  const rows = translated.map((e: any) => {
1239
903
  const spec: any = idSpec(e.ent)
1240
904
  const from = null == spec.from ? '' :
1241
905
  `, from: { ${Object.keys(spec.from).sort()
1242
906
  .map((k: string) => `${jsKey(k)}: '${spec.from[k]}'`).join(', ')} }`
907
+ const park = false === e.parkfree ? ', park: false' : ''
1243
908
  return ` ${jsKey(e.name)}: { parts: [${
1244
- spec.parts.map((p: string) => `'${p}'`).join(', ')}], sep: '${spec.sep}'${from} },`
909
+ spec.parts.map((p: string) => `'${p}'`).join(', ')}], sep: '${spec.sep}'${from}${park} },`
1245
910
  }).join('\n')
1246
911
 
1247
912
  Content(` // HOW EACH ENTITY'S id MAPS TO THE API'S OWN KEYS, from the model.
@@ -1258,7 +923,9 @@ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Option
1258
923
  // field's name: github returns a repo's owner as an OBJECT
1259
924
  // (\`owner.login\`) and its name as \`name\`, never \`repo\`.
1260
925
  // A part missing here cannot be read back off a response.
1261
- const ID_SPEC: Record<string, { parts: string[], sep: string, from?: Record<string, string> }> = {
926
+ // \`park\` false where \`${provider.lower}_id\` is a name this entity
927
+ // already uses, so the API's own id is left where it is.
928
+ const ID_SPEC: Record<string, { parts: string[], sep: string, from?: Record<string, string>, park?: boolean }> = {
1262
929
  ${rows}
1263
930
  }
1264
931
 
@@ -1309,7 +976,7 @@ ${rows}
1309
976
  // THE ADDRESSING KEY WINS over an \`id\` the response already carries. A
1310
977
  // response often has both — github's pull has a global database \`id\` and
1311
978
  // a repo-scoped \`number\` — and the unrelated one is no use for addressing
1312
- // anything. It is kept as \`${provider.lower}_id\` rather than dropped.
979
+ // anything. It is kept as \`${provider.lower}_id\`, unless \`park\` forbids.
1313
980
  function joinid(name: string, data: any, vals?: any) {
1314
981
  const spec = ID_SPEC[name]
1315
982
  if (null == data) {
@@ -1335,7 +1002,7 @@ ${rows}
1335
1002
  }
1336
1003
 
1337
1004
  if (null != id) {
1338
- if (null != data.id && String(data.id) !== id &&
1005
+ if (false !== spec.park && null != data.id && String(data.id) !== id &&
1339
1006
  null == ${jsProp('data', provider.lower + '_id')}) {
1340
1007
  ${jsProp('data', provider.lower + '_id')} = data.id
1341
1008
  }
@@ -1349,14 +1016,6 @@ ${rows}
1349
1016
  `)
1350
1017
  }
1351
1018
 
1352
- // WHICH SDK OP SERVES EACH `action$`, per entity and per cmd.
1353
- //
1354
- // Emitted for EVERY cmd, including the ones with no actions at all.
1355
- // That empty map is not waste: it is what lets `actionop` refuse an
1356
- // `action$` on an entity that has none, instead of ignoring the key
1357
- // and performing an ordinary call. Passing `action$` and getting a
1358
- // plain save is the failure this whole mechanism exists to prevent —
1359
- // it is how GitHub's `merge` silently became an "update".
1360
1019
  Content(` // The custom actions each cmd can reach, as action -> SDK op. An action
1361
1020
  // is an alternative POINT of an ordinary op (\`select.$action\` in the API
1362
1021
  // model), so \`save$\` routes by this map rather than assuming update: an
@@ -1370,12 +1029,6 @@ ${rows}
1370
1029
  const name = String(cmd.val$ ?? cmd)
1371
1030
  const map = e.actions[name] || {}
1372
1031
  const names = Object.keys(map).sort()
1373
- // COMPUTED KEYS, not `jsKey`. An action named `__proto__` written
1374
- // as a plain (or quoted) object-literal key SETS THE PROTOTYPE
1375
- // instead of creating a property, so the action would vanish from
1376
- // its own map and be unreachable. A computed key always defines an
1377
- // own property. apidef derives an action name from a route segment,
1378
- // and `__proto__` is a legal one.
1379
1032
  Content(` ${name}: {${names.map((a: string) =>
1380
1033
  ` [${JSON.stringify(a)}]: '${map[a]}'`).join(',')}${0 < names.length ? ' ' : ''}},
1381
1034
  `)
@@ -1450,8 +1103,6 @@ ${rows}
1450
1103
 
1451
1104
  `)
1452
1105
 
1453
- // The cmd map, declared up front so every action is attached to a
1454
- // shape seneca-entity can read before the actions are defined.
1455
1106
  Content(` const entity: any = {
1456
1107
  `)
1457
1108
  each(provider.entities, (e: any) => {
@@ -1472,42 +1123,20 @@ ${rows}
1472
1123
  `)
1473
1124
 
1474
1125
  each(provider.entities, (e: any) => {
1475
- // The guard set is PER OP, not the union across the entity's ops. An
1476
- // entity whose routes are not uniformly nested — a flat `load`, a
1477
- // nested `create` — used to demand the parent id on the flat call too,
1478
- // an argument its own SDK request would then never use.
1479
- //
1480
- // `save` guards the union of create's and update's keys: one action
1481
- // serves both and dispatches at runtime, so it cannot know which set
1482
- // applies until it has the data.
1483
- // A COMPOSITE-KEY ENTITY GUARDS NOTHING SEPARATELY. Its parent keys
1484
- // travel INSIDE the id, so demanding `q.owner` as well would reject
1485
- // `load$('octocat/hello-world')` — the very call the composite id
1486
- // exists to allow. splitid() does the checking instead, and refuses
1487
- // a wrong part count by name.
1488
1126
  const eparts = idParts(e.ent)
1489
- // The whole id description for this entity, or null when none is
1490
- // needed. `out` branches on it rather than on the record key.
1491
1127
  const espec = idSpec(e.ent)
1492
- const guard = (cmd: string, src: string) => {
1493
- const keys = 'save' === cmd ?
1494
- [...new Set([...(e.opParents.create || []), ...(e.opParents.update || [])])].sort() :
1495
- (e.opParents[cmd] || [])
1128
+ // Guards of ONE op: save's create and update branches guard their own.
1129
+ const guard = (opname: string, src: string, label = opname, ind = ' ') => {
1130
+ const keys = e.opParents[opname] || []
1496
1131
 
1497
1132
  return keys
1498
1133
  .filter((k: string) => !eparts.includes(k))
1499
1134
  .map((k: string) =>
1500
- ` ${guardName(e, k)}(${jsProp(src, k)}, '${cmd}')\n`)
1135
+ `${ind}${guardName(e, k)}(${jsProp(src, k)}, '${label}')\n`)
1501
1136
  .join('')
1502
1137
  }
1503
1138
 
1504
- // THE REFUSAL LINE, for a cmd whose only route addresses a
1505
- // different resource. Placed AFTER the action branch — an action
1506
- // route is named explicitly and is exactly how the real operation is
1507
- // reached — and BEFORE the guards, so the caller is told the cmd
1508
- // does not exist for this entity rather than being asked for a
1509
- // parameter that would not have helped.
1510
- const refuse = (cmd: string) => {
1139
+ const refuse = (cmd: string, ind = ' ') => {
1511
1140
  if (true !== (e.idmisaddressed || {})[cmd]) {
1512
1141
  return ''
1513
1142
  }
@@ -1515,31 +1144,17 @@ ${rows}
1515
1144
  recordKey(e.ent)
1516
1145
  const addresses = addressKeys(e.ent, cmd)
1517
1146
 
1518
- return ` misaddressed('${e.name}', '${cmd}', '${key}', ` +
1147
+ return `${ind}misaddressed('${e.name}', '${cmd}', '${key}', ` +
1519
1148
  `[${addresses.map((k: string) => `'${k}'`).join(', ')}])
1520
1149
  `
1521
1150
  }
1522
1151
 
1523
- // Reading the record's own key off the Seneca query, which always
1524
- // spells it `id`, and every other required key off its own name.
1525
1152
  const rk = recordKey(e.ent)
1526
1153
 
1527
- // For a composite key the parameters come out of the split, which the
1528
- // handler binds to `key` before the call. Any required key the id
1529
- // does NOT carry still comes off the query.
1530
- // THE RECORD'S OWN KEY IS ALWAYS SENT, whether or not the match
1531
- // declares it required.
1532
- //
1533
- // An op gathers several routes, and a parameter only one of them
1534
- // uses comes through OPTIONAL: github's IssueLoadMatch has `owner`
1535
- // and `repo` required but `id` optional, because the op also covers
1536
- // `/repos/{owner}/{repo}/issues/comments/{comment_id}`. Sending only
1537
- // the required keys called `Issue().load({owner, repo})` — the id
1538
- // the caller passed to `load$` went nowhere, so every read of any
1539
- // issue in that repo answered with the same record and
1540
- // `load$('no-such-issue')` returned one. Ten entities failed their
1541
- // not-found test on it, and the ones that "passed" were passing for
1542
- // the wrong reason. addressKeys is what the test emitter reads too.
1154
+ // Named only where there is a spec to translate the id through.
1155
+ const aq = `actionq(msg.q, ${0 === translated.length ? '' :
1156
+ `'${e.name}', `}action$)`
1157
+
1543
1158
  const sdkArg = (opname: string) => {
1544
1159
  const keys = addressKeys(e.ent, opname)
1545
1160
  if (0 === keys.length) {
@@ -1554,41 +1169,24 @@ ${rows}
1554
1169
  `${jsKey(k)}: ${jsProp('q', k === rk ? 'id' : k)}`).join(', ')} }`
1555
1170
  }
1556
1171
 
1557
- // The line that splits the Seneca id into the API's parameters,
1558
- // emitted only where there is one record to address. `list` has none.
1559
- // The entity-options argument that carries the path parameters for a
1560
- // write. Empty for an ordinary entity, which needs no such channel.
1561
- const entArg = 0 === eparts.length ? '' :
1562
- `null == key ? undefined : { match: key }`
1172
+ // Seneca's `id` and the API's key differ: composite, or not called id.
1173
+ const keyed = null != espec
1174
+
1175
+ // The entity-options argument carrying a write's addressing values.
1176
+ const entArg = !keyed ? '' : `null == key ? undefined : { match: key }`
1563
1177
 
1564
1178
  const splitLine = (cmd: string) => 0 === eparts.length ? '' :
1565
- ` const key = splitid('${e.name}', ${'save' === cmd ? 'data.id' : 'q.id'}, '${cmd}')\n`
1179
+ ` const key = splitid('${e.name}', q.id, '${cmd}')\n`
1566
1180
 
1567
- // The data hop, plus the id alias when the API keys the record by
1568
- // something other than `id`. A composite entity passes the addressing
1569
- // values through, because the response may not repeat them.
1181
+ // The data hop, plus the id from the values this request addressed the
1182
+ // record with, which the response need not repeat.
1570
1183
  const out = (expr: string, vals?: string) =>
1571
- null == espec ? `plain(${expr})` :
1184
+ !keyed ? `plain(${expr})` :
1572
1185
  `joinid('${e.name}', plain(${expr})${null == vals ? '' : ', ' + vals})`
1573
1186
 
1574
- // The action branch, emitted for every cmd whether or not this entity
1575
- // has actions. `actionop` is what refuses an unknown name, so leaving
1576
- // it out where the map is empty would restore the silent drop for
1577
- // exactly the entities most likely to be typed at by mistake.
1578
- //
1579
- // IT COMES BEFORE THE PARENT GUARDS, and that ordering is the whole
1580
- // of its correctness. The guards describe the CANONICAL route —
1581
- // opParams drops action points when computing them — and an action
1582
- // route need not be nested the same way: Zoom's canonical update is
1583
- // `/user/{user_id}/meeting/{id}` while its status action hangs off
1584
- // `/meeting/{id}`. Guarding first rejected that action for want of a
1585
- // `user_id` its own URL has no segment for.
1586
- //
1587
- // The action path is not left unguarded, it is guarded by the RIGHT
1588
- // thing: the SDK builds the action's own point and refuses an
1589
- // unbuildable one with `point_action_invalid`, naming the op and the
1590
- // action. A call naming no action falls through to the guards exactly
1591
- // as before.
1187
+ const loadVals = 0 < eparts.length ? 'key' :
1188
+ !keyed ? undefined : `{ ${jsKey(rk)}: q.id }`
1189
+
1592
1190
  const actionBranch = (cmd: string, call: string) => ` const action$ = actionOf(msg)
1593
1191
  if (null != action$) {
1594
1192
  const op$ = actionop(action$, '${e.name}', '${cmd}')
@@ -1602,7 +1200,7 @@ ${call} }
1602
1200
  async function list_${e.name}(this: any, entize: any, msg: any) {
1603
1201
  const q = cleanq(msg.q)
1604
1202
  ${actionBranch('list',
1605
- ` const found = await this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$))
1203
+ ` const found = await this.shared.sdk.${e.acc}()[op$](${aq})
1606
1204
  return found.map((data: any) => entize(${out('data')}))
1607
1205
  `)}${guard('list', 'q')} const list = await this.shared.sdk.${e.acc}().list(q)
1608
1206
  return list.map((data: any) => entize(${out('data')}))
@@ -1617,10 +1215,10 @@ ${actionBranch('list',
1617
1215
  async function load_${e.name}(this: any, entize: any, msg: any) {
1618
1216
  const q = cleanq(msg.q)
1619
1217
  ${actionBranch('load',
1620
- ` const hit = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1218
+ ` const hit = await ornull(() => this.shared.sdk.${e.acc}()[op$](${aq}))
1621
1219
  return null == hit ? null : entize(${out('hit')})
1622
1220
  `)}${guard('load', 'q')}${splitLine('load')} const res = await ornull(() => this.shared.sdk.${e.acc}().load(${sdkArg('load')}))
1623
- return null == res ? null : entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
1221
+ return null == res ? null : entize(${out('res', loadVals)})
1624
1222
  }
1625
1223
 
1626
1224
  `)
@@ -1630,50 +1228,19 @@ ${actionBranch('load',
1630
1228
  const hasCreate = e.ops.includes('create')
1631
1229
  const hasUpdate = e.ops.includes('update')
1632
1230
 
1633
- // Dispatch on the SENECA key. The record arriving here is a Seneca
1634
- // entity's data, so its id lives at `id` whatever the API calls it —
1635
- // dispatching on the API's key sent every save to `create`, leaving
1636
- // update unreachable.
1637
- // A MISADDRESSED UPDATE IS REFUSED, BUT ONLY ON THE UPDATE LEG. A
1638
- // create needs no record key — the API assigns one — so an entity
1639
- // whose update route addresses a different resource can still be
1640
- // created. Dispatch is on `data.id`, so the refusal goes exactly
1641
- // where the update would have.
1642
- const refuseUpdate = true === (e.idmisaddressed || {}).update ?
1643
- refuse('update') : ''
1231
+ const refuseUpdate = true === (e.idmisaddressed || {}).update
1644
1232
 
1645
- const body = hasCreate && hasUpdate
1646
- ? (('' === refuseUpdate) ? ` const res = null == data.id
1647
- ? await sdk.${e.acc}(${entArg}).create(data)
1648
- : await sdk.${e.acc}(${entArg}).update(data)` : ` if (null != data.id) {
1649
- ${refuseUpdate.replace(/\n$/, '')}
1650
- }
1233
+ const isUpdate = keyed ? 'null != key' : 'null != data.id'
1651
1234
 
1652
- const res = await sdk.${e.acc}(${entArg}).create(data)`)
1653
- : hasCreate
1654
- ? ` const res = await sdk.${e.acc}(${entArg}).create(data)`
1655
- : `${refuseUpdate} const res = await sdk.${e.acc}(${entArg}).update(data)`
1656
-
1657
- // ... and hand the API back its own key, which Seneca does not know
1658
- // to send.
1659
- //
1660
- // A COMPOSITE KEY IS UNPACKED ONTO THE DATA. The write goes out as
1661
- // a body plus path parameters, and the SDK reads those parameters
1662
- // off the same object — so the parts have to be present under
1663
- // their own names, not fused into `id`. On a create there is no id
1664
- // yet and the caller supplies the parts directly, which is why this
1665
- // only runs when an id is there.
1666
- // THE COMPOSITE SPLIT CANNOT LIVE HERE, before the action branch,
1667
- // even though that is where the single-key alias sits. The alias
1668
- // only ever assigns; the split THROWS on an id that is not all its
1669
- // parts, and an `action$` call is entitled to an id shaped however
1670
- // that action's own route wants. Running it first turned
1671
- // `action$: 'no_such_action'` into an id complaint, hiding the
1672
- // error the caller needed. It is emitted after the action branch
1673
- // instead — see compositeSave below, spliced where the parent
1674
- // guards go, which is exactly the position the component already
1675
- // documents as "after the action branch".
1676
- const compositeSave = 0 < eparts.length ? `
1235
+ // Deleting a name the entity owns strips a value the caller supplied.
1236
+ const dropPark = false === e.parkfree ? '' : `
1237
+ // \`${provider.lower}_id\` is this provider's own bookkeeping — the
1238
+ // API's unrelated \`id\`, parked by joinid(). It is not a field of the
1239
+ // API's write schema, so it must not travel in the request body.
1240
+ delete ${jsProp('data', provider.lower + '_id')}
1241
+ `
1242
+
1243
+ const keyBlock = 0 < eparts.length ? `
1677
1244
  // Seneca carries this ${e.name}'s key as one \`id\`; the API addresses
1678
1245
  // the record by ${eparts.map((p: string) => '`' + p + '`').join(' and ')}.
1679
1246
  //
@@ -1692,13 +1259,7 @@ ${actionBranch('load',
1692
1259
  if (null != data.id) {
1693
1260
  key = splitid('${e.name}', data.id, 'save')
1694
1261
  }
1695
-
1696
- // \`${provider.lower}_id\` is this provider's own bookkeeping — the
1697
- // API's unrelated \`id\`, parked by joinid() so it is not lost. It is
1698
- // not a field of the API's write schema, so it must not travel in the
1699
- // request body.
1700
- delete ${jsProp('data', provider.lower + '_id')}
1701
-
1262
+ ${dropPark}
1702
1263
  // AND NEITHER DOES THE JOINED \`id\`. It is Seneca's key for this
1703
1264
  // record, not the API's: a composite ${e.name} is addressed by
1704
1265
  // \`${eparts.join('\` and \`')}\`, which travel as path parameters in
@@ -1707,65 +1268,73 @@ ${actionBranch('load',
1707
1268
  // which matches a request against a stored record, then looked for a
1708
1269
  // record whose own \`id\` was that joined string and found none.
1709
1270
  delete data.id
1710
- ` : ''
1711
-
1712
- const alias = 0 < eparts.length ? '' : 'id' === rk ? '' :
1713
- `
1271
+ ` : !keyed ? '' : `
1714
1272
  // This API keys a ${e.name} by \`${rk}\`; Seneca carries it as \`id\`.
1715
- if (null == ${jsProp('data', rk)} && null != data.id) {
1716
- ${jsProp('data', rk)} = data.id
1273
+ // The key goes on the body under the API's own name, where the route
1274
+ // reads it, and into the match, which the SDK consults first.
1275
+ let key = null
1276
+ if (null != data.id) {
1277
+ key = { ${jsKey(rk)}: data.id }
1278
+ if (null == ${jsProp('data', rk)}) {
1279
+ ${jsProp('data', rk)} = data.id
1280
+ }
1717
1281
  }
1718
-
1719
- // \`${provider.lower}_id\` is this provider's own bookkeeping — the
1720
- // API's unrelated \`id\`, parked by joinid() so it is not lost. It
1721
- // is not a field of the API's write schema, so it must not travel in
1722
- // the request body: a strict API rejects an unknown property, and a
1723
- // lax one may persist it.
1724
- delete ${jsProp('data', provider.lower + '_id')}
1282
+ ${dropPark}
1283
+ // NOR DOES SENECA'S \`id\`. It holds the \`${rk}\` this API addresses
1284
+ // the record by, under a name this API's ${e.name} does not have: sent
1285
+ // on the body it is at best an unknown property, and to a store that
1286
+ // matches a write against the record it names the wrong one.
1287
+ delete data.id
1725
1288
  `
1726
1289
 
1290
+ const saveVals = keyed ? 'key' : undefined
1291
+
1292
+ const call = (opname: string) =>
1293
+ `await sdk.${e.acc}(${entArg}).${opname}(data)`
1294
+
1295
+ const body = hasCreate && hasUpdate
1296
+ ? (!refuseUpdate ? ` let res
1297
+ if (${isUpdate}) {
1298
+ ${guard('update', 'data', 'save', ' ')} res = ${call('update')}
1299
+ }
1300
+ else {
1301
+ ${guard('create', 'data', 'save', ' ')} res = ${call('create')}
1302
+ }` : ` if (${isUpdate}) {
1303
+ ${refuse('update', ' ')} }
1304
+
1305
+ ${guard('create', 'data', 'save')} const res = ${call('create')}`)
1306
+ : hasCreate
1307
+ ? `${guard('create', 'data', 'save')} const res = ${call('create')}`
1308
+ : `${refuse('update')}${guard('update', 'data', 'save')} const res = ${call('update')}`
1309
+
1727
1310
  Content(`
1728
1311
  ${jsProp('entity', e.name)}.cmd.save.action =
1729
1312
  async function save_${e.name}(this: any, entize: any, msg: any) {
1730
1313
  const data = msg.ent.data$(false)
1731
- ${alias} const sdk = this.shared.sdk
1314
+ ${keyBlock} const sdk = this.shared.sdk
1732
1315
 
1733
1316
  ${actionBranch('save',
1734
1317
  ` // The action's OWN payload is the entity's own fields — data$(false)
1735
1318
  // has already dropped every trailing-\`$\` key, \`action$\` included,
1736
1319
  // so \`$action\` is the only thing added here.
1737
1320
  data.$action = action$
1738
- const done = await sdk.${e.acc}()[op$](data)
1739
- return entize(${out('done')})
1740
- `)}${guard('save', 'data')}${compositeSave}${body}
1321
+ const done = await sdk.${e.acc}(${entArg})[op$](data)
1322
+ return entize(${out('done', saveVals)})
1323
+ `)}${body}
1741
1324
 
1742
- return entize(${out('res', 0 < eparts.length ? 'key' : undefined)})
1325
+ return entize(${out('res', saveVals)})
1743
1326
  }
1744
1327
 
1745
1328
  `)
1746
1329
  }
1747
1330
 
1748
1331
  if (e.cmds.includes('remove')) {
1749
- // A REMOVE ACTION ANSWERS FOR ITSELF. The canonical remove has
1750
- // nothing to hand back — the record is gone — so its handler
1751
- // returns null and takes no `entize`. An action folded into
1752
- // `remove` is a different endpoint with a response of its own
1753
- // (`/meeting/{id}/archive` answers with the archive record), and
1754
- // the other three cmds all pass an action's response through.
1755
- // Applying the canonical "return null" to it threw that away, so
1756
- // the action appeared to succeed and yielded nothing.
1757
- //
1758
- // `entize` is therefore named, never `_entize`. Naming it
1759
- // conditionally on the entity HAVING a remove action does not work
1760
- // and the type-check says so: the action branch is emitted for
1761
- // every entity — that is what refuses an unknown `action$` on one
1762
- // with no actions — so the parameter is always referenced.
1763
1332
  Content(`
1764
1333
  ${jsProp('entity', e.name)}.cmd.remove.action =
1765
1334
  async function remove_${e.name}(this: any, entize: any, msg: any) {
1766
1335
  const q = cleanq(msg.q)
1767
1336
  ${actionBranch('remove',
1768
- ` const gone = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1337
+ ` const gone = await ornull(() => this.shared.sdk.${e.acc}()[op$](${aq}))
1769
1338
  return null == gone ? null : entize(${out('gone')})
1770
1339
  `)}${'' !== refuse('remove') ? refuse('remove') :
1771
1340
  `${guard('remove', 'q')}${splitLine('remove')} await ornull(() => this.shared.sdk.${e.acc}().remove(${sdkArg('remove')}))
@@ -1883,7 +1452,6 @@ if ('undefined' !== typeof module) {
1883
1452
  })
1884
1453
 
1885
1454
 
1886
- // --- src/<Name>Provider-doc.ts ----------------------------------------------
1887
1455
 
1888
1456
  const ProviderDoc = cmp(function ProviderDoc(props: any) {
1889
1457
  const { provider } = props
@@ -1926,6 +1494,7 @@ if ('undefined' !== typeof module) {
1926
1494
  export {
1927
1495
  Main,
1928
1496
  recordKey,
1497
+ rkOnCreate,
1929
1498
  cmdActions,
1930
1499
  entityActionList,
1931
1500
  }