@voxgig/sdkgen-infrapack 0.0.1

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.
@@ -0,0 +1,1295 @@
1
+ import {
2
+ cmp, each,
3
+ File, Content, Copy, Folder,
4
+ entityCollection, entityOps, entityIdField, entityClassName,
5
+ entityActions,
6
+ opRequestShape, opParams, ownPoint, entityPath,
7
+ collectDeps, repoInfo, packageName, packageVersion, apiName, envName,
8
+ authorInfo, contributorList, isAuthActive, isHttpBasicAuth, jsKey, jsProp,
9
+ SdkGenError,
10
+ PUBLISHER, PUBLISHER_URL,
11
+ pointSegments,
12
+ } from '@voxgig/sdkgen'
13
+
14
+ import {
15
+ KIT,
16
+ } from '@voxgig/apidef'
17
+
18
+ import { Tests, Scripts, Workflow, Readme, Docs } from './Extras_seneca-provider'
19
+ import { Gitignore } from './Gitignore_seneca-provider'
20
+
21
+
22
+ // The `seneca-provider` target: a Seneca plugin exposing this API's entities
23
+ // as Seneca entities (`provider/<name>/<entity>`), layered on the sibling
24
+ // `ts` SDK.
25
+ //
26
+ // A consumer target in the go-cli / py-data mould — every standard phase is
27
+ // off in model/target/seneca-provider.aon and this component emits the
28
+ // whole package. It differs from those in one way that shapes everything
29
+ // here: it generates into ITS OWN REPO (`output: path`), depends on the SDK
30
+ // as a PUBLISHED npm package rather than by path, and therefore carries a
31
+ // repo's worth of furniture rather than a subfolder's.
32
+ //
33
+ // SHAPE OF THE MAPPING
34
+ //
35
+ // Seneca's store commands are list / load / save / remove. The SDK's are
36
+ // list / load / create / update / remove. `save` is the one that is not
37
+ // one-to-one: Seneca's convention is that an entity carrying an id is an
38
+ // update and one without is a create, so `save` dispatches on `data.id`.
39
+ //
40
+ // Everything else follows from the model:
41
+ // - which cmds exist at all, from the entity's declared ops;
42
+ // - the required path params of each op, which become argument guards (a
43
+ // nested entity like `moon` under `/planet/{planet_id}/moon` cannot build
44
+ // its URL without the parent id, and an opaque 404 from a half-built URL
45
+ // is a bad error message);
46
+ // - the SDK accessor and entity class names, from the same helpers the ts
47
+ // target uses, so the two cannot drift.
48
+
49
+
50
+ // Seneca store cmd -> the SDK ops it needs. `save` needs BOTH create and
51
+ // update; it is emitted when either is present and dispatches on the id.
52
+ const CMD_OPS: Record<string, string[]> = {
53
+ list: ['list'],
54
+ load: ['load'],
55
+ save: ['create', 'update'],
56
+ remove: ['remove'],
57
+ }
58
+
59
+
60
+ // The custom ACTIONS one Seneca cmd can reach, as action name -> SDK op.
61
+ //
62
+ // apidef folds a non-CRUD verb into an ordinary op as an extra point marked
63
+ // `select.$action`: GitHub's `PUT /repos/{owner}/{repo}/pulls/{n}/merge` is a
64
+ // second point of `pull.update`, beside the canonical `PATCH`. The SDK
65
+ // selects one with `$action` in the call's argument; without this map the
66
+ // provider has no way to name one at all, and `merge` is simply unreachable
67
+ // through a generated plugin.
68
+ //
69
+ // KEYED BY CMD, NOT BY OP, and that is the whole point of the function.
70
+ // `save` covers create AND update, so an action folded into `create` arrives
71
+ // through `save$` exactly as one folded into `update` does — assuming
72
+ // `update` would send `upload_image` (petstore's
73
+ // `POST /pet/{petId}/uploadImage`, a create point) to the wrong endpoint, and
74
+ // the SDK would then refuse it as an invalid action on an operation the
75
+ // caller never named.
76
+ //
77
+ // A name claimed by an earlier op WINS: `entityActions` walks the op map in
78
+ // sorted-key order, so for `save` that is create before update. Two ops of
79
+ // one entity sharing an action name is not something apidef produces from a
80
+ // spec — the name comes from the route — and if it ever does, a stable choice
81
+ // beats a last-writer-wins one.
82
+ function cmdActions(ent: any, cmd: string): Record<string, string> {
83
+ const ops = CMD_OPS[cmd] || []
84
+
85
+ // ACTIVE ops only, for the same reason `parentKeys` uses `entityOps`: an op
86
+ // the model marks `active: false` generates no SDK method, so an action
87
+ // folded into it is not reachable and must not be advertised as if it were.
88
+ const live = entityOps(ent)
89
+ const out: Record<string, string> = {}
90
+
91
+ // `null == out[a.action]` on a plain object reads Object's PROTOTYPE for a
92
+ // name like `toString` or `constructor`, finds a function, and concludes
93
+ // the name is already claimed — dropping a modelled action that happens to
94
+ // carry one. Ask whether the map itself has the key.
95
+ const claimed = (name: string) =>
96
+ Object.prototype.hasOwnProperty.call(out, name)
97
+
98
+ for (const a of entityActions(ent)) {
99
+ if (ops.includes(a.op) && live.includes(a.op) && !claimed(a.action)) {
100
+ out[a.action] = a.op
101
+ }
102
+ }
103
+
104
+ return out
105
+ }
106
+
107
+
108
+ // The ops that have a route of their OWN, as opposed to nothing but folded-in
109
+ // actions. An op whose every point is an action point has no plain call: a
110
+ // `save$` naming no action still reaches the SDK's `update`, but the SDK finds
111
+ // one point and takes it, so the "canonical" update IS the action's route.
112
+ //
113
+ // Which is what the generated tests need to know before writing a plain,
114
+ // id-bearing save: without this they were emitted for an entity that has no
115
+ // such call to make.
116
+ function canonicalOps(ent: any): string[] {
117
+ return entityOps(ent).filter((opname: string) => {
118
+ const op = (ent.op || {})[opname]
119
+ const points: any[] = (op && op.points) || []
120
+
121
+ return points.some((pt: any) =>
122
+ null == (pt && pt.select && pt.select['$action']))
123
+ })
124
+ }
125
+
126
+
127
+ // Every action the entity exposes, with the cmd that reaches it — for the
128
+ // README and the generated tests, which describe the entity rather than one
129
+ // call. `cmd` is what a Seneca user types; `op` is what the SDK is asked for.
130
+ function entityActionList(ent: any):
131
+ { cmd: string, op: string, action: string, path: string }[] {
132
+ const out: { cmd: string, op: string, action: string, path: string }[] = []
133
+
134
+ for (const cmd of Object.keys(CMD_OPS).sort()) {
135
+ const map = cmdActions(ent, cmd)
136
+ for (const a of entityActions(ent)) {
137
+ if (map[a.action] === a.op) {
138
+ out.push({ cmd, op: a.op, action: a.action, path: a.path })
139
+ }
140
+ }
141
+ }
142
+
143
+ return out
144
+ }
145
+
146
+
147
+ // The required (non-optional) request keys of an op, id first. These are what
148
+ // the SDK needs to build the path, so they are what the provider must have
149
+ // before it calls.
150
+ function requiredKeys(ent: any, opname: string): string[] {
151
+ const idf = entityIdField(ent)
152
+ return opRequestShape(ent, opname).items
153
+ .filter((it: any) => !it.optional)
154
+ .map((it: any) => it.name)
155
+ .sort((a: string, b: string) => (a === idf ? 0 : 1) - (b === idf ? 0 : 1))
156
+ }
157
+
158
+
159
+ // The key that addresses ONE record.
160
+ //
161
+ // `entityIdField` answers whenever the model declares one, which apidef does
162
+ // for any entity carrying a field literally named `id` — and it renames an
163
+ // `<entity>_id` path param to `id` besides, which is why most APIs never reach
164
+ // the fallback.
165
+ //
166
+ // When it does NOT answer, the record key is the LAST path param of the
167
+ // op's own point, in PATH order — a route addresses parents first and the
168
+ // record last, by construction. Without this the key was simply unknown,
169
+ // so a param named `code` failed the `!== idf` test in opParentKeys and
170
+ // was classified as a PARENT — `load$('SAVE20')` threw "coupon load: code
171
+ // is required" instead of loading anything, and the entity was treated as
172
+ // nested throughout.
173
+ //
174
+ // opParams(op) is NOT the source here: it alphabetizes params for output
175
+ // stability, which loses path order on a 3+-param route — Airtable's
176
+ // record (base_id, table_id, record_id) alphabetizes with table_id last,
177
+ // so the old `params[params.length - 1]` picked the wrong parent as the
178
+ // record's own key. The point's own `parts` still has the true order.
179
+ function recordKey(ent: any): string {
180
+ const idf = entityIdField(ent)
181
+ if (null != idf && '' !== idf) {
182
+ return String(idf)
183
+ }
184
+
185
+ for (const opname of ['load', 'remove', 'update']) {
186
+ const op = (ent.op || {})[opname]
187
+ if (null == op || 0 === (op.points || []).length) {
188
+ continue
189
+ }
190
+
191
+ const canonical = op.points.filter((pt: any) =>
192
+ null == (pt && pt.select && pt.select['$action']))
193
+ const point = ownPoint(0 < canonical.length ? canonical : op.points)
194
+ // The LAST variable segment names the record's key. apidef states which
195
+ // segments are variables (its ADR-003), so this reads the name off the
196
+ // vector rather than finding a `{` and slicing the braces back off.
197
+ const vars = pointSegments(point)
198
+ .filter((seg: any) => null != seg.var)
199
+
200
+ if (0 < vars.length) {
201
+ return String(vars[vars.length - 1].var)
202
+ }
203
+
204
+ // No path param at all (e.g. GET /scan/async/result?trace_id=...): the
205
+ // record's own key can still be a single required QUERY param.
206
+ const query = (point && point.args && point.args.query) || []
207
+ const reqdQuery = query.filter((q: any) => false !== q.reqd)
208
+ if (1 === reqdQuery.length) {
209
+ return String(reqdQuery[0].name)
210
+ }
211
+ }
212
+
213
+ return 'id'
214
+ }
215
+
216
+
217
+ // The guard function's name. Spec-derived param names are not constrained to
218
+ // identifiers — Evervault's `/payments/3ds-sessions/{3ds_session_id}` is the
219
+ // standing example — and a name is a DECLARATION here, so it cannot be
220
+ // bracket-quoted the way a property access can. Non-identifier characters are
221
+ // replaced rather than dropped, so `a-b` and `a_b` cannot collide.
222
+ function guardName(e: any, key: string): string {
223
+ return `need_${e.name}_${String(key).replace(/[^A-Za-z0-9_$]/g, '_')}`
224
+ .replace(/^need_(\d)/, 'need__$1')
225
+ }
226
+
227
+
228
+ // The parent PATH params of ONE op: `moon`'s load under
229
+ // `/planet/{planet_id}/moon/{id}` yields ['planet_id']. These are the keys a
230
+ // caller can forget, so each gets a guard.
231
+ //
232
+ // From opParams — the op's declared path params — NOT from opRequestShape.
233
+ // For create/update the latter also returns the request BODY fields, so
234
+ // reading it here generated a "planet name is required" guard for every
235
+ // writable field on every entity.
236
+ function opParentKeys(ent: any, opname: string): string[] {
237
+ const rk = recordKey(ent)
238
+ const op = (ent.op || {})[opname]
239
+
240
+ if (null == op) {
241
+ return []
242
+ }
243
+
244
+ const seen = new Set<string>()
245
+
246
+ for (const p of opParams(op)) {
247
+ const name = String((p as any).name)
248
+ if (false !== (p as any).reqd && name !== rk && name !== 'id') {
249
+ seen.add(name)
250
+ }
251
+ }
252
+
253
+ return [...seen].sort()
254
+ }
255
+
256
+
257
+ // Every parent key the entity has, across its ACTIVE ops — for the seed data
258
+ // and the docs, which describe the entity rather than one call.
259
+ //
260
+ // `entityOps` and not `Object.keys(ent.op)`: an op the model marks
261
+ // `active: false` generates no SDK method, so letting it contribute a key
262
+ // here put a mandatory guard for a parameter of a call that does not exist
263
+ // onto every cmd that does.
264
+ function parentKeys(ent: any): string[] {
265
+ const seen = new Set<string>()
266
+
267
+ for (const opname of entityOps(ent)) {
268
+ for (const key of opParentKeys(ent, opname)) {
269
+ seen.add(key)
270
+ }
271
+ }
272
+
273
+ return [...seen].sort()
274
+ }
275
+
276
+
277
+ // A model field's broad shape, for generating seed data that reads as data.
278
+ // The model carries canon strings (`\`$STRING\``), not JS types.
279
+ //
280
+ // ORDER MATTERS and the tests are substring tests, so the container kinds are
281
+ // checked FIRST: a multi-type field's sentinel is the ARRAY
282
+ // `['`$ONE`', [members...]]`, and String() flattens it to a comma-joined
283
+ // string — so a `$ONE` of string|number matched `includes('NUMBER')` and was
284
+ // seeded as a bare number. A union is not a number; it is whatever its first
285
+ // member is, and falling back to a string is the safe answer.
286
+ //
287
+ // `$ARRAY` and `$OBJECT` are in the sentinel vocabulary (see
288
+ // helpers/canonType.ts) and used to fall through to 'string', which put
289
+ // `tags: 'quick-tags'` into test/quick.js — a type-incorrect body that a
290
+ // validating server rejects.
291
+ function fieldKind(type: any): string {
292
+ if (Array.isArray(type)) {
293
+ return 'string'
294
+ }
295
+
296
+ const t = String(type || '').toUpperCase()
297
+
298
+ if (t.includes('ARRAY') || t.includes('LIST')) return 'array'
299
+ if (t.includes('OBJECT') || t.includes('MAP')) return 'object'
300
+ if (t.includes('BOOLEAN')) return 'boolean'
301
+ if (t.includes('NUMBER') || t.includes('INTEGER')) return 'number'
302
+
303
+ return 'string'
304
+ }
305
+
306
+
307
+ // Which entity a parent path param refers to: `planet_id` -> `planet`, but
308
+ // only when an entity of that name actually exists. A key that names no
309
+ // entity gets no cross-reference, and the seed falls back to a plain string.
310
+ function parentEntityOf(key: string, names: string[]): string {
311
+ const stem = key.replace(/_id$/, '')
312
+ return names.includes(stem) ? stem : ''
313
+ }
314
+
315
+
316
+ // The repo this provider is released from — NOT the SDK's. A provider is its
317
+ // own package in its own repo, so its manifest's homepage/repository must
318
+ // point there; deriving them from `main: kit: repo` sends every link in the
319
+ // published package to the SDK instead.
320
+ //
321
+ // Order: the project's `output: repo`, else the Seneca convention.
322
+ function providerRepo(model: any, lower: string, tname: string):
323
+ { url: string, path: string } {
324
+ const host = model?.main?.[KIT]?.repo?.host || 'github.com'
325
+ const declared = model?.main?.[KIT]?.target?.[tname]?.output?.repo
326
+ const path = null != declared && '' !== declared ?
327
+ String(declared) : `senecajs/seneca-${lower}-provider`
328
+
329
+ return { url: `https://${host}/${path}`, path }
330
+ }
331
+
332
+
333
+ // The provider's published package name: the project's pin when it has one,
334
+ // else `@seneca/<name>-provider`. packageName() cannot answer this — its
335
+ // derivations are all SDK-shaped (`@<origin>/<slug>-sdk`).
336
+ function providerPackage(model: any, lower: string, tname: string): string {
337
+ const declared = model?.main?.[KIT]?.target?.[tname]
338
+ ?.publish?.registry?.package
339
+ return null != declared && '' !== declared ?
340
+ String(declared) : `@seneca/${lower}-provider`
341
+ }
342
+
343
+
344
+ const Main = cmp(function Main(props: any) {
345
+ const { target, ctx$ } = props
346
+ const { model } = ctx$
347
+
348
+ // HARD REQUIREMENT: this plugin imports the TypeScript SDK. Generating it
349
+ // without `ts` produces a package whose every import fails, so fail at
350
+ // GENERATE time with an actionable message instead.
351
+ const targets = model.main[KIT].target || {}
352
+ if (null == targets.ts) {
353
+ throw new SdkGenError(
354
+ 'seneca-provider requires the `ts` target in the same SDK: it imports ' +
355
+ 'the TypeScript SDK that `ts` generates. Add it with:\n' +
356
+ ' npm run add-target ts\n' +
357
+ 'then regenerate.')
358
+ }
359
+
360
+ const Name = model.const.Name // Solardemo
361
+ const lower = String(model.const.name) // solardemo
362
+ const ENV = envName(model) // SOLARDEMO
363
+ const sdkClass = `${Name}SDK` // SolardemoSDK
364
+ const pluginName = `${Name}Provider` // SolardemoProvider
365
+ const fileBase = `${lower}-provider` // solardemo-provider
366
+
367
+ // The SDK is a PUBLISHED dependency, not a path: this package lives in its
368
+ // own repo. Its name is whatever the ts target publishes under, pin
369
+ // included, so the two can never disagree.
370
+ // The TypeScript SDK this provider WRAPS — a different target, so it
371
+ // keeps its own name and does not follow this provider's alias.
372
+ const sdkPkg = packageName(model, 'npm')
373
+ const sdkVersion = packageVersion(model, 'ts')
374
+
375
+ // UNFILTERED by design — see the AGENTS.md sharp edge: entityCollection is
376
+ // the resolver every component must use (getModelPath rebuilds its container
377
+ // per call, defeating the class-name memo), and it deliberately includes
378
+ // inactive entities because the typed-model emitters need them.
379
+ //
380
+ // Which makes filtering `active` the CALLER's job, and this component was
381
+ // the one consumer target that skipped it — go-cli, go-mcp and py-data all
382
+ // re-filter. The cost: MainEntity_ts emits an accessor only for an ACTIVE
383
+ // entity, so an inactive one produced `this.shared.sdk.Ghost()` in the
384
+ // provider and an `assert.equal(typeof sdk.Ghost, 'function')` in its tests,
385
+ // against a method the SDK does not have.
386
+ const entityColl = entityCollection(model)
387
+
388
+ const activeEntities = Object.keys(entityColl).sort()
389
+ .map((key: string) => entityColl[key])
390
+ .filter((ent: any) => false !== ent.active)
391
+
392
+ // Entities this provider can serve: those with at least one op that maps to
393
+ // a Seneca store cmd. An entity with no such op would produce an empty cmd
394
+ // map, which seneca-entity treats as a store that answers nothing.
395
+ const entityNames = activeEntities.map((ent: any) => ent.name)
396
+
397
+ const entities = activeEntities
398
+ .map((ent: any) => {
399
+ const parents = parentKeys(ent)
400
+
401
+ // The parent entity PER KEY. Deriving it from `parents[0]` alone left an
402
+ // entity nested two levels deep with no cross-reference for its outer
403
+ // parents, so their seed values came out as the literal '0'.
404
+ const parentOf: Record<string, string> = {}
405
+ for (const key of parents) {
406
+ parentOf[key] = parentEntityOf(key, entityNames)
407
+ }
408
+
409
+ // The parent keys of each op SEPARATELY. The guards used to be the union
410
+ // across all ops, applied to every cmd alike, while the argument handed
411
+ // to the SDK was computed per op — so an entity whose routes are not
412
+ // uniformly nested (a flat `load`, a nested `create`) demanded a
413
+ // parameter its own call would never use.
414
+ const opParents: Record<string, string[]> = {}
415
+ for (const opname of entityOps(ent)) {
416
+ opParents[opname] = opParentKeys(ent, opname)
417
+ }
418
+
419
+ return {
420
+ ent,
421
+ name: ent.name,
422
+ // The SDK ACCESSOR on the client (`client.Moon()`), which is the
423
+ // entity's PascalCase name — NOT entityClassName, which is the
424
+ // collision-safe CLASS name the accessor constructs (`MoonEntity`).
425
+ // Calling the class name reads plausibly and fails at runtime with
426
+ // "sdk.MoonEntity is not a function". MainEntity_ts is the authority
427
+ // for this: it declares the method as `${entity.Name}()`.
428
+ acc: ent.Name,
429
+ // The entity's canonical route, used to probe the live server for
430
+ // liveness. Path params are left in place only if the route has
431
+ // them — a collection route (the `list` op's) has none, which is why
432
+ // entityPath prefers it.
433
+ path: entityPath(ent),
434
+ cls: entityClassName(ent, entityColl),
435
+ ops: entityOps(ent),
436
+ idf: entityIdField(ent),
437
+ parents,
438
+ parentOf,
439
+ opParents,
440
+ // The entity the FIRST parent key points at. Kept because the docs and
441
+ // the scripts speak about "the parent" in the singular; anything that
442
+ // must be right per key reads parentOf.
443
+ parentEntity: 0 < parents.length ? parentOf[parents[0]] : '',
444
+ // The custom actions this entity exposes, as cmd -> action -> SDK
445
+ // op. Emitted as a map in the plugin so a handler can route
446
+ // `action$` to the op that actually serves it, and so an unknown
447
+ // name can be refused with the valid ones named.
448
+ actions: Object.keys(CMD_OPS).sort().reduce(
449
+ (acc: Record<string, Record<string, string>>, cmd: string) => {
450
+ acc[cmd] = cmdActions(ent, cmd)
451
+ return acc
452
+ }, {}),
453
+ // The same information flattened, for the README and the generated
454
+ // tests: [{ cmd, op, action, path }].
455
+ actionList: entityActionList(ent),
456
+ // The ops with a route of their own. `ops` minus those that are
457
+ // nothing but folded-in actions — what the generated tests consult
458
+ // before assuming a plain call exists.
459
+ canonicalOps: canonicalOps(ent),
460
+ // Required fields only: a seed record has to satisfy the shape the
461
+ // SDK will hand back, and optional noise makes the assertions
462
+ // harder to read.
463
+ //
464
+ // A parent path param is then FORCED IN even when the entity's own
465
+ // schema omits it or marks it optional. A path param is a routing key,
466
+ // not necessarily a response field: when the child's schema left it
467
+ // out the seeded record had no link back to its parent, and the mock's
468
+ // match found nothing — which the nested `load` test reported as a
469
+ // TypeError and the nested `list` test reported as a pass.
470
+ fields: (() => {
471
+ const req = (ent.fields || [])
472
+ .filter((f: any) => false !== f.req)
473
+ .map((f: any) => ({
474
+ name: f.name,
475
+ kind: fieldKind(f.type),
476
+ parentEntity: parentEntityOf(f.name, entityNames),
477
+ }))
478
+
479
+ const have = new Set(req.map((f: any) => f.name))
480
+
481
+ for (const key of parents) {
482
+ if (!have.has(key)) {
483
+ req.push({
484
+ name: key,
485
+ kind: 'string',
486
+ parentEntity: parentOf[key],
487
+ })
488
+ }
489
+ }
490
+
491
+ return req
492
+ })(),
493
+ }
494
+ })
495
+ .map((e: any) => ({
496
+ ...e,
497
+ cmds: Object.keys(CMD_OPS)
498
+ .filter((cmd) => CMD_OPS[cmd].some((op) => e.ops.includes(op))),
499
+ }))
500
+ .filter((e: any) => 0 < e.cmds.length)
501
+
502
+ if (0 === entities.length) {
503
+ throw new SdkGenError(
504
+ 'seneca-provider: no entity in this model declares a list/load/create/' +
505
+ 'update/remove operation, so the plugin would expose no entities at ' +
506
+ 'all. Remove the target, or add an entity with CRUD ops.')
507
+ }
508
+
509
+ const repo = providerRepo(model, lower, target.name)
510
+
511
+ // The companion test server lives in the SDK repo's `app/` and is NOT
512
+ // published, so the only way to reach it is the local checkout. The path
513
+ // back to it is the inverse of this target's own `output: path`, computed
514
+ // once by the external pass — see cmp/ExternalTarget.
515
+ const sdkrel = ctx$.sdkrelpath || '..'
516
+
517
+ // Where a live run points — and whether there is anything honest to point
518
+ // it at.
519
+ //
520
+ // NOT simply `servers[0].url`. For an OpenAPI-derived model that is the
521
+ // PRODUCTION host of a third-party API, and everything gated on it aims
522
+ // there: the `describe('live')` block, which `npm test` runs on every CI
523
+ // push on three operating systems; the `serverUp` probe in front of it; and
524
+ // test/quick.js, whose header says "start the companion server first" while
525
+ // BASE silently defaults to production and whose body is a create / update /
526
+ // remove cycle. A live suite that reaches a stranger's API from CI is not a
527
+ // live suite, it is traffic — and unauthenticated traffic at that.
528
+ //
529
+ // So a live base is taken only when it is unambiguously OURS: declared
530
+ // outright by the project, or a loopback address, which no third party can
531
+ // be behind. Anything else leaves live testing ungenerated, which is the
532
+ // honest answer for an API nobody here runs.
533
+ const live = model?.main?.[KIT]?.test?.live || {}
534
+ const servers = (model?.main?.[KIT]?.info?.servers || [])
535
+ const specBase = 0 < servers.length ? String(servers[0].url || '') : ''
536
+
537
+ const loopback = (url: string) =>
538
+ /^https?:\/\/(localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])([:/]|$)/i.test(url)
539
+
540
+ const liveBase = null != live.base && '' !== live.base ? String(live.base) :
541
+ (loopback(specBase) ? specBase : '')
542
+
543
+ // Whether the SDK's own repo ships a runnable companion server under
544
+ // `app/`. NOTHING in an API definition says so — it is a property of the
545
+ // sibling project — so it cannot be inferred, and inferring it is what put
546
+ // an unconditional `git clone && cd app && npm install && npm run build`
547
+ // into every generated provider's CI.
548
+ const liveApp = true === live.app || (loopback(specBase) && '' === (live.base || ''))
549
+
550
+ const provider = {
551
+ Name, lower, ENV, sdkClass, pluginName, fileBase,
552
+ sdkPkg, sdkVersion, entities,
553
+ repoUrl: repo.url,
554
+ // The SDK's own repo, for pointing at the companion test server which is
555
+ // only distributed in source.
556
+ sdkRepoUrl: repoInfo(model).repoUrl,
557
+ api: apiName(model),
558
+ version: packageVersion(model, target.name),
559
+ sdkrel,
560
+ liveBase,
561
+ liveApp,
562
+ // The sponsor line. @seneca/maintain's `content_readme` check requires
563
+ // the publisher's name in the README, so this is load-bearing rather
564
+ // than decorative — the generated `maintain` test fails without it.
565
+ publisher: PUBLISHER,
566
+ publisherUrl: PUBLISHER_URL,
567
+ // A route with NO path params, so the probe is a plain GET. An entity
568
+ // whose every route is parameterised gives none, and then the probe
569
+ // falls back to the base URL itself.
570
+ probePath: (entities.find((e: any) =>
571
+ '' !== e.path && !e.path.includes('{')) || { path: '' }).path,
572
+ // The provider's OWN published name. Not derived from the SDK slug the
573
+ // way a language target's is — a provider is `@seneca/<name>-provider` —
574
+ // so read the project's pin directly and default to that shape.
575
+ pkgName: providerPackage(model, lower, target.name),
576
+ // Whether this API authenticates at all. Decides if the plugin plumbs a
577
+ // credential: the SDK's auth stage emits nothing for an auth-inactive
578
+ // model and strips the authorization header regardless of options, so
579
+ // plumbing one anyway produces a credential path that cannot work.
580
+ authActive: isAuthActive(model),
581
+ // Whether this API's scheme is genuine HTTP Basic Auth (two credentials,
582
+ // base64-joined), matching the SDK's own auth.basic signal. Decides
583
+ // whether the plugin also plumbs a `secret` alongside `apikey` — a
584
+ // Basic-Auth SDK's own auth stage deletes the header when either is
585
+ // missing, so a provider forwarding apikey alone could never
586
+ // authenticate, the same class of gap `apikey`-only forwarding was.
587
+ authBasic: isAuthActive(model) && isHttpBasicAuth(model),
588
+ }
589
+
590
+ // `.gitignore` is EMITTED rather than copied — npm strips that filename
591
+ // from the tarball, so as a template it reached only checkout users. See
592
+ // Gitignore_seneca-provider. Called before the Copy, as every language
593
+ // target calls its own.
594
+ Gitignore({})
595
+
596
+ // Static furniture: LICENSE, CODE_OF_CONDUCT, Makefile, tsfmt.json and
597
+ // both tsconfigs. Same for every provider.
598
+ Copy({
599
+ from: 'tm/' + target.name,
600
+ replace: { ...ctx$.stdrep },
601
+ })
602
+
603
+ PackageJson({ provider, target })
604
+ ProviderSource({ provider })
605
+ ProviderDoc({ provider })
606
+ Tests({ provider })
607
+ Scripts({ provider })
608
+ Workflow({ provider })
609
+ Readme({ provider })
610
+ Docs({ provider })
611
+ })
612
+
613
+
614
+ // --- package.json -----------------------------------------------------------
615
+
616
+ const PackageJson = cmp(function PackageJson(props: any) {
617
+ const { provider, target } = props
618
+ const { model } = props.ctx$
619
+
620
+ const deps = collectDeps(model, target.name, target.deps, props.ctx$.log)
621
+
622
+ // collectDeps returns { name, version, source, raw } — the DECLARED kind
623
+ // (prod/peer/dev) is on `raw`, not hoisted. Reading `d.kind` instead
624
+ // silently yields an empty manifest section, which is how the first cut of
625
+ // this component shipped a package.json with no seneca peers at all.
626
+ //
627
+ // `kind` is a COMMA-SEPARATED LIST, so one dependency can land in more than
628
+ // one manifest section. That is the Seneca plugin convention and not an
629
+ // embellishment: `seneca` is a PEER (the plugin must run inside the host's
630
+ // instance, never its own bundled copy) and also a DEV dependency (the test
631
+ // suite does `require('seneca')` directly, so a bare `npm install` in a
632
+ // clean checkout has to produce it). collectDeps deduplicates by package
633
+ // name and the model's dep map is keyed by name, so the same package cannot
634
+ // be declared twice — the kind has to carry the list instead.
635
+ const dep = (kind: string) => {
636
+ const out: Record<string, string> = {}
637
+ const kinds = (d: any) => String((d.raw && d.raw.kind) || '')
638
+ .split(',').map((s: string) => s.trim()).filter((s: string) => '' !== s)
639
+
640
+ for (const d of deps.filter((d: any) => kinds(d).includes(kind))) {
641
+ out[d.name] = d.version
642
+ }
643
+ return out
644
+ }
645
+
646
+ // Attribution. Model-driven, because it cannot be derived and because
647
+ // regeneration OVERWRITES the manifest: the hand-written provider this
648
+ // target was modelled on lost its author and both named contributors on the
649
+ // first regeneration, and nothing failed. Unset falls back to the publisher.
650
+ const author = authorInfo(model)
651
+ const contributors = contributorList(model)
652
+
653
+ const pkg = {
654
+ name: provider.pkgName,
655
+ version: provider.version,
656
+ main: `dist/${provider.fileBase}.js`,
657
+ type: 'commonjs',
658
+ types: `dist/${provider.fileBase}.d.ts`,
659
+ description:
660
+ `Seneca entity provider for the ${provider.api} API, using the ` +
661
+ `${provider.sdkPkg} SDK.`,
662
+ homepage: provider.repoUrl,
663
+ keywords: ['seneca', provider.lower, `${provider.lower}-provider`,
664
+ provider.publisher.toLowerCase(), 'sdk'],
665
+ author,
666
+ // Omitted entirely when the project names none, rather than emitted as an
667
+ // empty array — npm treats `"contributors": []` as a declaration that
668
+ // there are none, which is a different claim from not having said.
669
+ ...(0 < contributors.length ? { contributors } : {}),
670
+ license: 'MIT',
671
+ repository: { type: 'git', url: `git+${provider.repoUrl}.git` },
672
+ scripts: {
673
+ test: 'node --enable-source-maps --test test/**/*.test.js',
674
+ 'test-some':
675
+ 'node --enable-source-maps --test-name-pattern="$TEST_PATTERN" ' +
676
+ '--test "test/**/*.test.js"',
677
+ 'test-watch': 'node --test --watch test/**/*.test.js',
678
+ watch: 'tsc --build src test -w',
679
+ build: 'tsc --build src test',
680
+ 'test-coverage':
681
+ 'node --enable-source-maps --experimental-test-coverage --test ' +
682
+ 'test/**/*.test.js',
683
+ clean:
684
+ 'rm -rf node_modules dist dist-test .tsbuildinfo yarn.lock ' +
685
+ 'package-lock.json',
686
+ reset: 'npm run clean && npm i && npm run build && npm test',
687
+ // The Seneca release convention: tag from package.json, push, publish.
688
+ // Generated because a provider is released like every other Seneca
689
+ // plugin, and a maintainer who has to remember the incantation will
690
+ // eventually publish an untested build.
691
+ 'repo-tag':
692
+ 'REPO_VERSION=`node -e "console.log(require(\'./package\').version)"` ' +
693
+ '&& echo TAG: v$REPO_VERSION && git commit -a -m v$REPO_VERSION ' +
694
+ '&& git push && git tag v$REPO_VERSION && git push --tags;',
695
+ 'repo-publish': 'npm run clean && npm i && npm run repo-publish-quick',
696
+ 'repo-publish-quick':
697
+ 'npm run build && npm run test && npm run repo-tag && ' +
698
+ 'npm publish --access public --registry https://registry.npmjs.org',
699
+ },
700
+ // What actually ships. Without `files`, `npm publish` packs the test
701
+ // suite and build output into the tarball.
702
+ files: ['dist', 'src/**/*.ts', 'LICENSE'],
703
+ engines: { node: '>=24' },
704
+ dependencies: {
705
+ // The SDK this plugin wraps, by its PUBLISHED name and version.
706
+ [provider.sdkPkg]: `^${provider.sdkVersion}`,
707
+ ...dep('prod'),
708
+ },
709
+ peerDependencies: dep('peer'),
710
+ devDependencies: dep('dev'),
711
+ }
712
+
713
+ File({ name: 'package.json' }, () => {
714
+ Content(JSON.stringify(pkg, null, 2) + '\n')
715
+ })
716
+ })
717
+
718
+
719
+ // --- src/<name>-provider.ts -------------------------------------------------
720
+
721
+ const ProviderSource = cmp(function ProviderSource(props: any) {
722
+ const { provider } = props
723
+
724
+ Folder({ name: 'src' }, () => {
725
+ File({ name: `${provider.fileBase}.ts` }, () => {
726
+ Content(`/* Generated by @voxgig/sdkgen. Do not edit. */
727
+
728
+ const Pkg = require('../package.json')
729
+
730
+ const { ${provider.sdkClass} } = require('${provider.sdkPkg}')
731
+
732
+ const SdkPkg = require('${provider.sdkPkg}/package.json')
733
+
734
+
735
+ type ${provider.pluginName}Options = {
736
+ // Options passed straight to the ${provider.sdkClass} constructor,
737
+ // most usefully \`base\` to point at a server.
738
+ sdk?: Record<string, any>
739
+
740
+ // Run the SDK in offline test mode (in-memory mock transport).
741
+ test?: boolean
742
+
743
+ // Test feature options, e.g. {entity: {${provider.entities[0].name}: {...}}} to
744
+ // seed the mock with data. Only used when \`test\` is true.
745
+ testopts?: Record<string, any>
746
+ }
747
+
748
+
749
+ function ${provider.pluginName}(this: any, options: ${provider.pluginName}Options) {
750
+ const seneca: any = this
751
+
752
+ const entityBuilder = this.export('provider/entityBuilder')
753
+
754
+ seneca.message('sys:provider,provider:${provider.lower},get:info', get_info)
755
+
756
+ async function get_info(this: any, _msg: any) {
757
+ return {
758
+ ok: true,
759
+ name: '${provider.lower}',
760
+ version: Pkg.version,
761
+ sdk: {
762
+ name: '${provider.sdkPkg}',
763
+ version: SdkPkg.version,
764
+ },
765
+ }
766
+ }
767
+
768
+
769
+ // Every SDK operation resolves to an SDK ENTITY rather than raw data (a
770
+ // removed record included: it comes back marked deleted, still holding what
771
+ // it held). Seneca wants plain data, which the entity hands over through
772
+ // data().
773
+ function plain(res: any) {
774
+ return null == res ? res : res.data()
775
+ }
776
+
777
+
778
+ // Seneca query directives (sort$, limit$, ...) are for the store, not the
779
+ // API, so they must not reach the SDK as match fields.
780
+ function cleanq(q: any) {
781
+ const out: any = {}
782
+ for (const k in (q || {})) {
783
+ if (!k.endsWith('$')) {
784
+ out[k] = q[k]
785
+ }
786
+ }
787
+ return out
788
+ }
789
+
790
+
791
+ // \`action$\` — the directive that selects a custom API action instead of
792
+ // the plain cmd. READ BEFORE cleanq strips it, and still stripped from
793
+ // what reaches the SDK as match fields: it is an instruction to the store,
794
+ // like sort$ and limit$, not a value to filter on.
795
+ //
796
+ // THREE PLACES, because seneca-entity puts it in three places depending on
797
+ // how the caller spelled it, and dropping any one of them silently turns a
798
+ // named action into an ordinary call:
799
+ //
800
+ // list$/load$/remove$({ action$ }) -> msg.q.action$
801
+ // ent.directive$({ action$ }).save$() -> msg.action$
802
+ // const e = ent.make$({...}); e.action$=.. -> msg.ent.action$
803
+ //
804
+ // \`make$({ action$ })\` is NOT among them and cannot be: seneca-entity's
805
+ // make$ copies only keys without a \`$\`, plus the four directives it knows
806
+ // (id$, merge$, custom$, directive$), so an unknown trailing-\`$\` key is
807
+ // dropped before any store sees it. \`id$\` reads like the precedent for
808
+ // one, but it works only because make$ names it explicitly. The README
809
+ // says so; there is nothing this plugin can check, because nothing arrives.
810
+ function actionOf(msg: any) {
811
+ const q = msg && msg.q
812
+ const ent = msg && msg.ent
813
+
814
+ return null != (q && q.action$) ? q.action$ :
815
+ null != (msg && msg.action$) ? msg.action$ :
816
+ null != (ent && ent.action$) ? ent.action$ :
817
+ undefined
818
+ }
819
+
820
+
821
+ // The SDK argument for an ACTION on a read cmd: everything the caller sent
822
+ // minus Seneca's own directives, with the record key carried across, plus
823
+ // the \`$action\` selector the SDK dispatches on.
824
+ //
825
+ // WIDER than the canonical argument on purpose. An action route has its own
826
+ // parameters — GitHub's merge takes commit_title and merge_method, which the
827
+ // canonical PATCH knows nothing about — so narrowing to the plain op's
828
+ // required keys would strip the action's whole payload. The SDK still
829
+ // validates: an action whose own point cannot be built is refused with
830
+ // \`point_action_invalid\` rather than sent somewhere else.
831
+ function actionq(q: any, rk: string, action: string) {
832
+ const out = cleanq(q)
833
+
834
+ if ('id' !== rk && null != out.id) {
835
+ out[rk] = out.id
836
+ delete out.id
837
+ }
838
+
839
+ out.$action = action
840
+ return out
841
+ }
842
+
843
+
844
+ // The SDK throws on any non-2xx. A 404 from a single-item read is an
845
+ // ordinary "not found" answer rather than a failure, so return null and let
846
+ // everything else propagate. SDK errors carry the HTTP status at the top
847
+ // level, so ask them rather than digging into \`result\`.
848
+ async function ornull(action: () => Promise<any>) {
849
+ try {
850
+ return await action()
851
+ }
852
+ catch (e: any) {
853
+ if (true === e?.notFound) {
854
+ return null
855
+ }
856
+ throw e
857
+ }
858
+ }
859
+
860
+ `)
861
+
862
+ // A guard per required parent key. Without it the SDK builds a
863
+ // half-formed URL and the caller gets an opaque 404 instead of being
864
+ // told what they left out.
865
+ const guarded = provider.entities.filter((e: any) => 0 < e.parents.length)
866
+ if (0 < guarded.length) {
867
+ Content(` // Nested entities cannot build their path without the parent id, so
868
+ // say which key is missing rather than letting the SDK report an opaque
869
+ // 404 on a half-built URL.
870
+ `)
871
+ each(guarded, (e: any) => {
872
+ each(e.parents, (key: any) => {
873
+ const k = String(key.val$ ?? key)
874
+ Content(` function ${guardName(e, k)}(value: any, cmd: string) {
875
+ if (null == value || '' === value) {
876
+ throw new Error(
877
+ '${provider.pkgName}: ${e.name} ' + cmd + ': ${k} is required'
878
+ )
879
+ }
880
+ return value
881
+ }
882
+
883
+
884
+ `)
885
+ })
886
+ })
887
+ }
888
+
889
+ // Seneca's own entity key is ALWAYS literally `id` — `load$('x')` sets
890
+ // `q = { id: 'x' }`, and the id of the entity it builds comes off
891
+ // `data.id`. An API that addresses a record by anything else therefore
892
+ // needs translating in both directions, or `load$` requests a record
893
+ // keyed `undefined` and every entity handed back has no id at all — one
894
+ // 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)}
903
+ }
904
+ return data
905
+ }
906
+
907
+
908
+ `)
909
+ })
910
+
911
+ // WHICH SDK OP SERVES EACH `action$`, per entity and per cmd.
912
+ //
913
+ // Emitted for EVERY cmd, including the ones with no actions at all.
914
+ // That empty map is not waste: it is what lets `actionop` refuse an
915
+ // `action$` on an entity that has none, instead of ignoring the key
916
+ // and performing an ordinary call. Passing `action$` and getting a
917
+ // plain save is the failure this whole mechanism exists to prevent —
918
+ // it is how GitHub's `merge` silently became an "update".
919
+ Content(` // The custom actions each cmd can reach, as action -> SDK op. An action
920
+ // is an alternative POINT of an ordinary op (\`select.$action\` in the API
921
+ // model), so \`save$\` routes by this map rather than assuming update: an
922
+ // action folded into \`create\` is reached through \`save$\` too.
923
+ const ACTIONS: Record<string, Record<string, Record<string, string>>> = {
924
+ `)
925
+ each(provider.entities, (e: any) => {
926
+ Content(` [${JSON.stringify(e.name)}]: {
927
+ `)
928
+ each(e.cmds, (cmd: any) => {
929
+ const name = String(cmd.val$ ?? cmd)
930
+ const map = e.actions[name] || {}
931
+ const names = Object.keys(map).sort()
932
+ // COMPUTED KEYS, not `jsKey`. An action named `__proto__` written
933
+ // as a plain (or quoted) object-literal key SETS THE PROTOTYPE
934
+ // instead of creating a property, so the action would vanish from
935
+ // its own map and be unreachable. A computed key always defines an
936
+ // own property. apidef derives an action name from a route segment,
937
+ // and `__proto__` is a legal one.
938
+ Content(` ${name}: {${names.map((a: string) =>
939
+ ` [${JSON.stringify(a)}]: '${map[a]}'`).join(',')}${0 < names.length ? ' ' : ''}},
940
+ `)
941
+ })
942
+ Content(` },
943
+ `)
944
+ })
945
+ Content(` }
946
+
947
+
948
+ // Resolve an \`action$\` to the SDK op that serves it, or REFUSE it.
949
+ //
950
+ // Never falls through to the ordinary call. An action name the entity does
951
+ // not have is a caller mistake worth a message that names what is
952
+ // available; performing a plain save instead is the one outcome that must
953
+ // not happen, because it succeeds and does the wrong thing.
954
+ // AN OWN PROPERTY, never an inherited one. \`map[name]\` resolves
955
+ // \`toString\`, \`constructor\`, \`valueOf\` and the rest off Object's
956
+ // prototype, and each of those is non-null — so the refusal below never
957
+ // fired and the inherited function was handed to the SDK as an op name.
958
+ // That is the silent drop in another hat: the caller named something the
959
+ // entity does not have and was not told. An empty map inherits them all,
960
+ // so a cmd with no actions was the most exposed.
961
+ function actionop(name: string, entname: string, cmd: string) {
962
+ const own = Object.prototype.hasOwnProperty
963
+ const ents: any = own.call(ACTIONS, entname) ? ACTIONS[entname] : {}
964
+ const map: any = own.call(ents, cmd) ? ents[cmd] : {}
965
+ const op = own.call(map, name) ? map[name] : null
966
+
967
+ if (null == op) {
968
+ const valid = Object.keys(map).sort()
969
+ throw new Error(
970
+ '${provider.pkgName}: ' + entname + ' ' + cmd + ': action$ "' + name +
971
+ '" is not an action of this operation. Valid: ' +
972
+ (0 < valid.length ? valid.join(', ') : '(none)'))
973
+ }
974
+
975
+ return op
976
+ }
977
+
978
+
979
+ `)
980
+
981
+ // The cmd map, declared up front so every action is attached to a
982
+ // shape seneca-entity can read before the actions are defined.
983
+ Content(` const entity: any = {
984
+ `)
985
+ each(provider.entities, (e: any) => {
986
+ Content(` ${jsKey(e.name)}: {
987
+ cmd: {
988
+ `)
989
+ each(e.cmds, (cmd: any) => {
990
+ Content(` ${String(cmd.val$ ?? cmd)}: { action: (undefined as any) },
991
+ `)
992
+ })
993
+ Content(` },
994
+ },
995
+
996
+ `)
997
+ })
998
+ Content(` }
999
+
1000
+ `)
1001
+
1002
+ each(provider.entities, (e: any) => {
1003
+ // The guard set is PER OP, not the union across the entity's ops. An
1004
+ // entity whose routes are not uniformly nested — a flat `load`, a
1005
+ // nested `create` — used to demand the parent id on the flat call too,
1006
+ // an argument its own SDK request would then never use.
1007
+ //
1008
+ // `save` guards the union of create's and update's keys: one action
1009
+ // serves both and dispatches at runtime, so it cannot know which set
1010
+ // applies until it has the data.
1011
+ const guard = (cmd: string, src: string) => {
1012
+ const keys = 'save' === cmd ?
1013
+ [...new Set([...(e.opParents.create || []), ...(e.opParents.update || [])])].sort() :
1014
+ (e.opParents[cmd] || [])
1015
+
1016
+ return keys
1017
+ .map((k: string) =>
1018
+ ` ${guardName(e, k)}(${jsProp(src, k)}, '${cmd}')\n`)
1019
+ .join('')
1020
+ }
1021
+
1022
+ // Reading the record's own key off the Seneca query, which always
1023
+ // spells it `id`, and every other required key off its own name.
1024
+ const rk = recordKey(e.ent)
1025
+ const sdkArg = (opname: string) => {
1026
+ const keys = requiredKeys(e.ent, opname)
1027
+ if (0 === keys.length) {
1028
+ return '{}'
1029
+ }
1030
+ return `{ ${keys.map((k: string) =>
1031
+ `${jsKey(k)}: ${jsProp('q', k === rk ? 'id' : k)}`).join(', ')} }`
1032
+ }
1033
+
1034
+ // 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}))`
1038
+
1039
+ // The action branch, emitted for every cmd whether or not this entity
1040
+ // has actions. `actionop` is what refuses an unknown name, so leaving
1041
+ // it out where the map is empty would restore the silent drop for
1042
+ // exactly the entities most likely to be typed at by mistake.
1043
+ //
1044
+ // IT COMES BEFORE THE PARENT GUARDS, and that ordering is the whole
1045
+ // of its correctness. The guards describe the CANONICAL route —
1046
+ // opParams drops action points when computing them — and an action
1047
+ // route need not be nested the same way: Zoom's canonical update is
1048
+ // `/user/{user_id}/meeting/{id}` while its status action hangs off
1049
+ // `/meeting/{id}`. Guarding first rejected that action for want of a
1050
+ // `user_id` its own URL has no segment for.
1051
+ //
1052
+ // The action path is not left unguarded, it is guarded by the RIGHT
1053
+ // thing: the SDK builds the action's own point and refuses an
1054
+ // unbuildable one with `point_action_invalid`, naming the op and the
1055
+ // action. A call naming no action falls through to the guards exactly
1056
+ // as before.
1057
+ const actionBranch = (cmd: string, call: string) => ` const action$ = actionOf(msg)
1058
+ if (null != action$) {
1059
+ const op$ = actionop(action$, '${e.name}', '${cmd}')
1060
+ ${call} }
1061
+
1062
+ `
1063
+
1064
+ if (e.cmds.includes('list')) {
1065
+ Content(`
1066
+ ${jsProp('entity', e.name)}.cmd.list.action =
1067
+ async function list_${e.name}(this: any, entize: any, msg: any) {
1068
+ const q = cleanq(msg.q)
1069
+ ${actionBranch('list',
1070
+ ` const found = await this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$))
1071
+ return found.map((data: any) => entize(${out('data')}))
1072
+ `)}${guard('list', 'q')} const list = await this.shared.sdk.${e.acc}().list(q)
1073
+ return list.map((data: any) => entize(${out('data')}))
1074
+ }
1075
+
1076
+ `)
1077
+ }
1078
+
1079
+ if (e.cmds.includes('load')) {
1080
+ Content(`
1081
+ ${jsProp('entity', e.name)}.cmd.load.action =
1082
+ async function load_${e.name}(this: any, entize: any, msg: any) {
1083
+ const q = cleanq(msg.q)
1084
+ ${actionBranch('load',
1085
+ ` const hit = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1086
+ 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')})
1089
+ }
1090
+
1091
+ `)
1092
+ }
1093
+
1094
+ if (e.cmds.includes('save')) {
1095
+ const hasCreate = e.ops.includes('create')
1096
+ const hasUpdate = e.ops.includes('update')
1097
+
1098
+ // Dispatch on the SENECA key. The record arriving here is a Seneca
1099
+ // entity's data, so its id lives at `id` whatever the API calls it —
1100
+ // dispatching on the API's key sent every save to `create`, leaving
1101
+ // update unreachable.
1102
+ const body = hasCreate && hasUpdate
1103
+ ? ` const res = null == data.id
1104
+ ? await sdk.${e.acc}().create(data)
1105
+ : await sdk.${e.acc}().update(data)`
1106
+ : hasCreate
1107
+ ? ` const res = await sdk.${e.acc}().create(data)`
1108
+ : ` const res = await sdk.${e.acc}().update(data)`
1109
+
1110
+ // ... and hand the API back its own key, which Seneca does not know
1111
+ // to send.
1112
+ const alias = 'id' === rk ? '' :
1113
+ `
1114
+ // This API keys a ${e.name} by \`${rk}\`; Seneca carries it as \`id\`.
1115
+ if (null == ${jsProp('data', rk)} && null != data.id) {
1116
+ ${jsProp('data', rk)} = data.id
1117
+ }
1118
+ `
1119
+
1120
+ Content(`
1121
+ ${jsProp('entity', e.name)}.cmd.save.action =
1122
+ async function save_${e.name}(this: any, entize: any, msg: any) {
1123
+ const data = msg.ent.data$(false)
1124
+ ${alias} const sdk = this.shared.sdk
1125
+
1126
+ ${actionBranch('save',
1127
+ ` // The action's OWN payload is the entity's own fields — data$(false)
1128
+ // has already dropped every trailing-\`$\` key, \`action$\` included,
1129
+ // so \`$action\` is the only thing added here.
1130
+ data.$action = action$
1131
+ const done = await sdk.${e.acc}()[op$](data)
1132
+ return entize(${out('done')})
1133
+ `)}${guard('save', 'data')}${body}
1134
+
1135
+ return entize(${out('res')})
1136
+ }
1137
+
1138
+ `)
1139
+ }
1140
+
1141
+ if (e.cmds.includes('remove')) {
1142
+ // A REMOVE ACTION ANSWERS FOR ITSELF. The canonical remove has
1143
+ // nothing to hand back — the record is gone — so its handler
1144
+ // returns null and takes no `entize`. An action folded into
1145
+ // `remove` is a different endpoint with a response of its own
1146
+ // (`/meeting/{id}/archive` answers with the archive record), and
1147
+ // the other three cmds all pass an action's response through.
1148
+ // Applying the canonical "return null" to it threw that away, so
1149
+ // the action appeared to succeed and yielded nothing.
1150
+ //
1151
+ // `entize` is therefore named, never `_entize`. Naming it
1152
+ // conditionally on the entity HAVING a remove action does not work
1153
+ // and the type-check says so: the action branch is emitted for
1154
+ // every entity — that is what refuses an unknown `action$` on one
1155
+ // with no actions — so the parameter is always referenced.
1156
+ Content(`
1157
+ ${jsProp('entity', e.name)}.cmd.remove.action =
1158
+ async function remove_${e.name}(this: any, entize: any, msg: any) {
1159
+ const q = cleanq(msg.q)
1160
+ ${actionBranch('remove',
1161
+ ` const gone = await ornull(() => this.shared.sdk.${e.acc}()[op$](actionq(msg.q, '${rk}', action$)))
1162
+ return null == gone ? null : entize(${out('gone')})
1163
+ `)}${guard('remove', 'q')} await ornull(() => this.shared.sdk.${e.acc}().remove(${sdkArg('remove')}))
1164
+ return null
1165
+ }
1166
+
1167
+ `)
1168
+ }
1169
+ })
1170
+
1171
+ Content(`
1172
+ entityBuilder(this, {
1173
+ provider: {
1174
+ name: '${provider.lower}',
1175
+ },
1176
+ entity
1177
+ })
1178
+
1179
+
1180
+ seneca.prepare(async function(this: any) {
1181
+ const sdkopts: any = Object.assign({}, options.sdk)
1182
+ ${provider.authActive ? `
1183
+ // The provider convention carries credentials, so honour an \`apikey\`
1184
+ // when one is configured and stay quiet when it is not.
1185
+ const res = await this.post('sys:provider,get:keymap,provider:${provider.lower}')
1186
+ const apikey = res?.keymap?.apikey?.value
1187
+
1188
+ // Hand the credential to the SDK as \`apikey\`, NOT as an authorization
1189
+ // HEADER. The SDK's own auth stage owns that header: it reads
1190
+ // \`options.apikey\`, and on every path where it finds none it DELETES
1191
+ // \`authorization\` before the request goes out. A provider that set the
1192
+ // header itself was therefore never authenticated — the SDK stripped the
1193
+ // very thing it had just written, on every call, silently. The SDK also
1194
+ // owns the scheme prefix, which is resolved from the API definition
1195
+ // rather than assumed to be \`Bearer\`.
1196
+ if (null != apikey && '' !== apikey) {
1197
+ sdkopts.apikey = apikey
1198
+ }
1199
+ ${provider.authBasic ? `
1200
+ // Genuine HTTP Basic Auth needs a SECOND credential (the SDK sends
1201
+ // \`Authorization: Basic base64(apikey:secret)\`) — without it the SDK's
1202
+ // auth stage treats the pair as incomplete and deletes the header, same
1203
+ // as a missing apikey.
1204
+ const secret = res?.keymap?.secret?.value
1205
+
1206
+ if (null != secret && '' !== secret) {
1207
+ sdkopts.secret = secret
1208
+ }
1209
+ ` : ''}` : `
1210
+ // This API declares no authentication, so no credential is plumbed. The
1211
+ // SDK's auth stage emits nothing for an auth-inactive model and deletes
1212
+ // any \`authorization\` header regardless of options, so a keymap lookup
1213
+ // here would read a key that could not reach the wire — which is what the
1214
+ // first version of this target did.
1215
+ `}
1216
+ this.shared.sdk = options.test
1217
+ ? ${provider.sdkClass}.test(options.testopts || {}, sdkopts)
1218
+ : new ${provider.sdkClass}(sdkopts)
1219
+ })
1220
+
1221
+
1222
+ return {
1223
+ exports: {
1224
+ sdk: () => this.shared.sdk,
1225
+ },
1226
+ }
1227
+ }
1228
+
1229
+
1230
+ // Default options.
1231
+ const defaults: ${provider.pluginName}Options = {
1232
+ sdk: {},
1233
+ test: false,
1234
+ testopts: {},
1235
+ }
1236
+
1237
+ Object.assign(${provider.pluginName}, { defaults })
1238
+
1239
+ export default ${provider.pluginName}
1240
+
1241
+ if ('undefined' !== typeof module) {
1242
+ module.exports = ${provider.pluginName}
1243
+ }
1244
+ `)
1245
+ })
1246
+ })
1247
+ })
1248
+
1249
+
1250
+ // --- src/<Name>Provider-doc.ts ----------------------------------------------
1251
+
1252
+ const ProviderDoc = cmp(function ProviderDoc(props: any) {
1253
+ const { provider } = props
1254
+
1255
+ Folder({ name: 'src' }, () => {
1256
+ File({ name: `${provider.pluginName}-doc.ts` }, () => {
1257
+ Content(`/* Generated by @voxgig/sdkgen. Do not edit. */
1258
+
1259
+
1260
+ const messages = {
1261
+ get_info: {
1262
+ desc: 'Get information about the ${provider.api} SDK.',
1263
+ },
1264
+ }
1265
+
1266
+
1267
+ const sections = {
1268
+ intro: {
1269
+ path: '../provider/doc/intro.md'
1270
+ }
1271
+ }
1272
+
1273
+ const docs = {
1274
+ sections,
1275
+ messages
1276
+ }
1277
+
1278
+ export default docs
1279
+
1280
+
1281
+ if ('undefined' !== typeof module) {
1282
+ module.exports = docs
1283
+ }
1284
+ `)
1285
+ })
1286
+ })
1287
+ })
1288
+
1289
+
1290
+ export {
1291
+ Main,
1292
+ recordKey,
1293
+ cmdActions,
1294
+ entityActionList,
1295
+ }