@voxgig/sdkgen-infrapack 0.0.11 → 0.0.13

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,45 +6,11 @@ import {
6
6
  } from '@voxgig/sdkgen'
7
7
 
8
8
 
9
- // The rest of the seneca-provider package: its test suite, CI workflow and
10
- // README. Split out of Main only for size — everything here is driven by the
11
- // same `provider` shape Main builds from the model.
12
- //
13
- // The tests are the reason this target is worth generating at all. A provider
14
- // is thin, and the thin part is exactly where the mistakes are: a cmd that
15
- // forgets a parent path param, an entity that comes back under the wrong
16
- // canon, a 404 that should have been `null` and instead threw. All three are
17
- // checked below, offline, against the SDK's own mock transport — so a
18
- // generated provider is verified without a server.
19
-
20
-
21
- // WHERE A FETCHED SDK CHECKOUT LIVES, inside this repo.
22
- //
23
- // Fixed, and the same under every layout, which is the whole point: it is
24
- // what lets the generated docs, the live-test instructions and `make regen`
25
- // name the SDK source without naming anyone's directory layout. Gitignored —
26
- // the checkout is derived from the pin and disposable, so committing it would
27
- // vendor the SDK into a repo that already depends on it.
28
- //
29
- // NOT a git submodule. A submodule pins a COMMIT and puts the pin in git's
30
- // own plumbing, where updating it is a second repository operation and a
31
- // stale one is invisible in a normal diff. The pin here is an ordinary
32
- // committed file naming a TAG, regenerated from the model like everything
33
- // else, so it moves with the SDK version it belongs to and shows up in review.
9
+
10
+
34
11
  const SDK_SRC_DIR = '.sdksrc'
35
12
 
36
13
 
37
- // The SDK this provider was generated from: repository, release tag, and the
38
- // published package the tag corresponds to.
39
- //
40
- // Committed, and REGENERATED — so it cannot drift from the dependency in
41
- // package.json, which comes from the same model version. The tag is
42
- // `v<version>` because that is what the SDK's publish workflow cuts for its
43
- // primary npm target.
44
- //
45
- // This is the file that makes the repo independently buildable: with it, the
46
- // SDK that generates this provider can be fetched from scratch, at the exact
47
- // revision that generated it, by a script that knows nothing else.
48
14
  const SdkPin = cmp(function SdkPin(props: any) {
49
15
  const { provider } = props
50
16
 
@@ -73,37 +39,15 @@ const SdkPin = cmp(function SdkPin(props: any) {
73
39
  // Does this entity's load op have a real identifying param (path or
74
40
  // required query), e.g. GET /result?trace_id=? A paramless GET has none.
75
41
  function loadHasKey(e: any): boolean {
76
- // FROM WHAT THE HANDLER ACTUALLY SENDS (Main's addressKeys), not from the
77
- // route's shape. A route can carry parameters and still be a singleton
78
- // read: github's `interaction` is `/user/interaction-limits`, and its
79
- // sibling `webhook_config` reads one config per app — both are called as
80
- // `load({})`, so every id hits the same record and a not-found test
81
- // against them asserts the opposite of the truth. Asking the route
82
- // whether it has any parameter said yes for both.
83
42
  return true === (e.idaddressed || {}).load
84
43
  }
85
44
 
86
45
 
87
- // CAN A REMOVE DELETE WHAT A CREATE JUST MADE? A round-trip that ends by
88
- // asserting the record is gone needs one that can address it.
89
- //
90
- // github's `action` is a tag bucket whose ops address different resources:
91
- // keyed `archive_format` from its download route, removed by
92
- // `hosted_runner_id` and `org_id`. The remove therefore deleted whichever
93
- // record the store yielded first — usually a SEEDED one — and the round-trip
94
- // failed on its own record surviving, intermittently, because the created
95
- // id is random and its position in iteration order decides.
96
46
  function removeAddresses(e: any): boolean {
97
47
  return true === (e.idaddressed || {}).remove
98
48
  }
99
49
 
100
50
 
101
- // WOULD THIS CMD REFUSE? A cmd whose only route addresses a different
102
- // resource does not send the request (Main's `misaddressed`), so a test that
103
- // drives it reaches the refusal and nothing beyond — a parent-key guard, a
104
- // round-trip's update leg, a plain-save assertion. Each such test is skipped
105
- // here and the refusal is pinned by its own `<entity>-<cmd>-refused`.
106
- // What a refusing cmd's route DOES address, for the message and the note.
107
51
  function addressNames(e: any, cmd: string): string[] {
108
52
  const keys = (e.opParents || {})[cmd] || []
109
53
  return 0 < keys.length ? keys : ['nothing more specific']
@@ -115,13 +59,6 @@ function cmdRefuses(e: any, cmd: string): boolean {
115
59
  }
116
60
 
117
61
 
118
- // The name of the entity a parent path param addresses, or '' when the model
119
- // has none of that name.
120
- //
121
- // From `e.parentOf`, which Main derives PER KEY. `e.parentEntity` describes
122
- // only the FIRST parent, so an entity nested two levels deep had every one of
123
- // its parents resolved to the innermost one — addressing the wrong record, or
124
- // none.
125
62
  function parentName(e: any, key: string): string {
126
63
  const byKey = (e.parentOf || {})[key]
127
64
  if (null != byKey && '' !== byKey) {
@@ -143,16 +80,6 @@ function parentSeed(e: any, key: string): string {
143
80
  }
144
81
 
145
82
 
146
- // `key: 'value', ` pairs for an entity's parent path params, ready to splice
147
- // into an object literal. Empty for a top-level entity, so the same emitter
148
- // serves both.
149
- //
150
- // OFFLINE the value is the seeded parent id, which exists because the seed put
151
- // it there. LIVE it is a local VARIABLE, emitted as ES shorthand: a real server
152
- // holds whatever records it holds, and a fixture id written into a live test is
153
- // a 404 waiting to happen. That is not hypothetical — seeding the live nested
154
- // create is exactly how the first version of this failed, with
155
- // `create: request: 404` against a parent that only ever existed in the mock.
156
83
  function parentPairs(e: any, live: boolean): string {
157
84
  return e.parents
158
85
  .map((p: string) => live ? `${p}, ` : `${p}: '${parentSeed(e, p)}', `)
@@ -185,13 +112,6 @@ function entIdLiteral(e: any, suffix: string): string {
185
112
  const vals = parts.map((p: string) =>
186
113
  e.parents.includes(p) ? parentSeed(e, p) : `${e.name}${suffix}`)
187
114
 
188
- // THE SUFFIX MUST SURVIVE, or `-nosuch` names the record that exists.
189
- //
190
- // A part that is also a parent key takes the parent's seeded value, which
191
- // ignores the suffix — and for github's repo BOTH parts are parent keys,
192
- // so `entIdLiteral(e, '-nosuch')` returned `owner0/repo0`. The not-found
193
- // test then loaded the seeded record and asserted it was null. The last
194
- // part is the record's own key, so that is where the suffix belongs.
195
115
  if ('' !== suffix && !vals.some((v: string) => v.endsWith(suffix))) {
196
116
  vals[vals.length - 1] = vals[vals.length - 1] + suffix
197
117
  }
@@ -239,12 +159,6 @@ function queryPairs(e: any, live: boolean): string {
239
159
  return parentPairs(e, live)
240
160
  }
241
161
 
242
- // ONLY THE PARTS TRAVEL INSIDE THE ID. A required key that is not one of
243
- // them still has to be passed, and dropping every parent because SOME of
244
- // them are parts left github's api_insights_summary_stat — keyed
245
- // `actor_type/actor_id`, and requiring a `min_timestamp` besides — called
246
- // without the timestamp its own handler guards. Its three read tests
247
- // failed on the guard rather than on anything they were written to check.
248
162
  const rest = e.parents.filter((p: string) => !parts.includes(p))
249
163
 
250
164
  return rest
@@ -253,50 +167,12 @@ function queryPairs(e: any, live: boolean): string {
253
167
  }
254
168
 
255
169
 
256
- // CAN A CREATED RECORD'S COMPOSITE ID BE REBUILT? Only if every part is
257
- // recoverable, and for a composite entity that is not a given.
258
- //
259
- // A create supplies the parts one of two ways: as path parameters of the
260
- // create route, or in the response. github's repo has NEITHER for its
261
- // `repo` part — `POST /user/repos` takes no path parameters, and the
262
- // response names the repository `name`, never `repo`. So there is no honest
263
- // way to know the id of a repo the API just made, and a create/update/remove
264
- // round-trip cannot be written against it.
265
- //
266
- // THIS IS A MODEL GAP, NOT A TEST TO FORCE. What is missing is a mapping
267
- // from a path parameter to the response field that carries it (`repo` ->
268
- // `name`); apidef knows the parameter and the field but nothing relates
269
- // them. Emitting the round-trip anyway produced a 404 on the update leg that
270
- // pointed at the mock rather than at the cause, so the honest thing is to
271
- // leave it out and say why in the generated file.
272
- //
273
- // load, load-missing and the malformed-id test are all still emitted: those
274
- // address an existing record, where the id comes from the caller.
275
170
  function compositeRoundTrip(e: any): boolean {
276
171
  const parts = idPartsOf(e)
277
172
  if (0 === parts.length) {
278
173
  return true
279
174
  }
280
175
 
281
- // EVERY PART PLACED, AND PLACED AT THE TOP LEVEL.
282
- //
283
- // Placed at all: the model must say where a response carries the part, or a
284
- // created record's id cannot be rebuilt by anything.
285
- //
286
- // Top level: only for the OFFLINE round-trip, and only because of how this
287
- // transport matches a write. It takes the keys it matches on from the
288
- // request BODY, and a write's addressing parameters no longer travel there
289
- // — they go in the entity match, which is what stopped them displacing a
290
- // nested response field. So an update finds nothing to pin the record by.
291
- //
292
- // Widening the transport's key set to the point's required parameters was
293
- // tried and over-constrains reads: PullEntity's basic load began matching
294
- // on a parameter it had never constrained, and answered 404. The proper
295
- // fix is for the transport to take a write's addressing keys from the
296
- // resolved path parameters specifically, which is a change to shared
297
- // machinery that wants its own validation pass.
298
- //
299
- // Reads, lists and removes round-trip through a nested part today.
300
176
  const from = e.idfrom || {}
301
177
  return parts.every((p: string) => {
302
178
  const path = from[p]
@@ -327,18 +203,6 @@ function liveParentsResolvable(provider: any, e: any): boolean {
327
203
  }
328
204
 
329
205
 
330
- // A DECLARED identifier derived from an entity name.
331
- //
332
- // apidef canonizes an entity name to `[A-Za-z_0-9]` — `canonize` strips
333
- // everything else, so hyphens and dots never reach the model and `a-b` and
334
- // `a_b` arrive as the same `a_b`. The one shape that survives and is NOT a
335
- // legal identifier is a LEADING DIGIT, which real resources produce:
336
- // `3ds-sessions` canonizes to `3ds_session`, `2fa-tokens` to `2fa_token`.
337
- //
338
- // A DECLARATION cannot be bracket-quoted the way a property access can — the
339
- // same constraint `guardName` in Main documents — so it is prefixed instead.
340
- // Only a leading digit is touched, so every ordinary entity keeps the name it
341
- // has always generated.
342
206
  function entVar(name: string, suffix = ''): string {
343
207
  return /^[0-9]/.test(name) ? `e_${name}${suffix}` : `${name}${suffix}`
344
208
  }
@@ -384,19 +248,6 @@ function mutableField(e: any): string {
384
248
  }
385
249
 
386
250
 
387
- // A create -> load -> update -> remove round-trip for one entity.
388
- //
389
- // Emitted for any entity declaring BOTH save and remove, in both modes: once
390
- // offline against the SDK's mock transport, once live behind the server probe.
391
- // The write path is where a provider actually breaks — a save that forgets a
392
- // parent key, an update that creates a second record instead of amending the
393
- // first — and it was covered by nothing until this existed. The hand-written
394
- // provider this target was modelled on had exactly these tests, live; dropping
395
- // them on the first regeneration left every cmd.save and cmd.remove action in
396
- // the generated plugin unexecuted by its own suite.
397
- //
398
- // The created id is never asserted to a VALUE: both the mock and a real API
399
- // assign it themselves and ignore any the SDK sends.
400
251
  function crudTest(provider: any, e: any, mode: 'offline' | 'live'): string {
401
252
  const live = 'live' === mode
402
253
  const pairs = parentPairs(e, live)
@@ -417,29 +268,9 @@ function crudTest(provider: any, e: any, mode: 'offline' | 'live'): string {
417
268
  f.name !== idf && 'id' !== f.name && !e.parents.includes(f.name)).length ?
418
269
  seedLiteral(e, 'crud') : ''
419
270
 
420
- // A COMPOSITE RECORD MUST BE CREATED IN THE SHAPE IT COMES BACK IN.
421
- //
422
- // The offline transport echoes what a create sent, so a create that sends
423
- // its parts flat produces a record whose id cannot be read back — `from`
424
- // looks for github's owner at `owner.login` and finds a bare string. The
425
- // parts therefore go in at their `from` paths, appended AFTER the field
426
- // literal so they win over the flat pair the seed emitted.
427
- //
428
- // The record key gets its own value rather than the seed's, so a created
429
- // record is distinguishable from a seeded one in the same store.
430
271
  const idmake = 0 === idPartsOf(e).length ? '' :
431
272
  ', ' + idFromPairs(e, '-crud')
432
273
 
433
- // NOT EVERY WRITABLE ENTITY IS READABLE. github's `app` declares create,
434
- // update, remove and list — and no load at all, because the API offers no
435
- // route that reads one app back. The round-trip read `ent.load$(...)` on
436
- // nine such entities, got the null the provider correctly returns for a
437
- // cmd it does not implement, and died on `loaded.id` — so the write path
438
- // those tests existed to cover went unexercised.
439
- //
440
- // What can be checked still is: the update runs on the created entity
441
- // itself, and the remove runs. What cannot be checked is stated in the
442
- // file rather than quietly dropped.
443
274
  const hasLoad = e.cmds.includes('load')
444
275
 
445
276
  const body = hasLoad ? `${ind} try {
@@ -507,12 +338,6 @@ ${body}${ind}})
507
338
  }
508
339
 
509
340
 
510
- // A source literal for one field, by kind.
511
- //
512
- // `$ARRAY` and `$OBJECT` are in the model's sentinel vocabulary and used to
513
- // fall through to the string branch, so a list field came out as
514
- // `tags: 'quick-tags'` — a type-incorrect body that a validating server
515
- // rejects, and a fixture that quietly stopped exercising non-scalar payloads.
516
341
  function fieldLiteral(f: any, tag: string): string {
517
342
  switch (f.kind) {
518
343
  case 'number': return '12345'
@@ -550,26 +375,10 @@ function seedRecord(e: any, idx: number): Record<string, any> {
550
375
  if (f.name === rkey) {
551
376
  out[f.name] = `${e.name}${idx}`
552
377
  }
553
- // AN `id` THAT IS NOT THE ADDRESSING KEY IS SEEDED DISTINCTLY. Seeding
554
- // both the same value made the offline suite unable to tell a provider
555
- // that addresses records correctly from one that confuses the API's own
556
- // `id` with the key its routes take — the seed agreed with either. Real
557
- // GitHub never returns that: a pull has a global database `id` AND a
558
- // repo-scoped `number`, and they differ.
559
378
  else if ('id' === f.name) {
560
379
  out[f.name] = `${e.name}-apiid-${idx}`
561
380
  }
562
381
  else if (e.parents.includes(f.name)) {
563
- // A nested entity's parent id must match a record the parent seeds, or
564
- // the offline store answers nothing and every nested test reads as a
565
- // false pass. Reuses parentSeed's fallback rather than f.parentEntity
566
- // directly: when no entity in the model shares this key's name (the
567
- // common case for a scoping param like `user_id` with no `user`
568
- // entity, or a same-named response field that means something else
569
- // entirely, like GitHub's `owner`), f.parentEntity is '' and seeding
570
- // '0' desynced the record from every query built against the SAME
571
- // key via parentSeed (parentPairs, crudTest, ...) — 0 results, or a
572
- // seeded field asserted against the wrong literal.
573
382
  out[f.name] = parentSeed(e, f.name)
574
383
  }
575
384
  else if ('number' === f.kind) {
@@ -589,39 +398,12 @@ function seedRecord(e: any, idx: number): Record<string, any> {
589
398
  }
590
399
  }
591
400
 
592
- // THE ADDRESSING KEY IS ALWAYS PRESENT, even when the response schema has
593
- // no field of that name.
594
- //
595
- // The offline transport is a store, and it can only answer a request by
596
- // matching the request's own parameters against a stored record
597
- // (TestFeature.buildArgs). github reads an org's artifact retention from
598
- // `/orgs/{org}/actions/permissions/artifact-and-log-retention`, whose body
599
- // is `{days, maximum_allowed_days}` — no org anywhere in it. The loop
600
- // above stamps the key only onto a field that already exists, so such a
601
- // record was seeded with nothing the provider addresses it by, every
602
- // offline load of it answered 404, and forty-one generated tests failed on
603
- // a null they could not have avoided.
604
- //
605
- // This is a property of the mock, not a claim about the API: a real
606
- // response need not echo the path parameter that selected it, which is
607
- // why the handler carries the request's own values across into the id
608
- // rather than reading them back off the body.
609
401
  if ('' !== String(rkey) && null == out[rkey] &&
610
402
  0 === (Array.isArray(e.idparts) ? e.idparts.length : 0)) {
611
403
  out[rkey] = e.parents.includes(rkey) ?
612
404
  parentSeed(e, rkey) : `${e.name}${idx}`
613
405
  }
614
406
 
615
- // THE SEED MODELS THE REAL RESPONSE, through the same `from` mapping the
616
- // runtime reads. github's repo owner goes to `owner.login` and its name to
617
- // `name`, because that is where the API puts them — so a record the mock
618
- // returns is identifiable by exactly the code that identifies a real one.
619
- //
620
- // This only works because the offline transport now matches a request
621
- // parameter against `id.from` as well as against its own name
622
- // (TestFeature.buildArgs). Seeding this shape before that landed made
623
- // every composite record unfindable: the mock looked for a field called
624
- // `owner` and found an object.
625
407
  seedIdParts(e, out, idx)
626
408
 
627
409
  return out
@@ -706,10 +488,6 @@ module.exports = { SEED }
706
488
  })
707
489
 
708
490
 
709
- // The message-level spec seneca-msg-test drives. TypeScript, compiled to
710
- // dist-test by test/tsconfig.json — which is also why it must exist: the
711
- // shipped tsconfig has `include: ["**/*.ts"]` and tsc fails outright on a
712
- // config that matches no input.
713
491
  File({ name: 'basic.messages.ts' }, () => {
714
492
  Content(`/* Generated by @voxgig/sdkgen. Do not edit. */
715
493
 
@@ -803,12 +581,6 @@ describe('${provider.fileBase}', () => {
803
581
 
804
582
  `)
805
583
 
806
- // Every flat entity (no parent keys), not just one "subject" — a
807
- // provider with two or more flat siblings used to leave every one
808
- // but the busiest untested beyond the accessor check above. A bare
809
- // `list$()`/`load$(id)` call has no way to carry a parent key, so
810
- // entities that need one are covered by the `nested` block below
811
- // instead, with their keys filled in.
812
584
  const flat = provider.entities.filter((e: any) => 0 === e.parents.length)
813
585
 
814
586
  each(flat, (e: any) => {
@@ -873,13 +645,6 @@ describe('${provider.fileBase}', () => {
873
645
  // A nested entity cannot build its path without the parent id. That is
874
646
  // the mistake this target exists to make impossible, so pin it.
875
647
  each(nested, (e: any) => {
876
- // A COMPOSITE-KEY ENTITY HAS NO SEPARATE PARENT GUARD to pin: its
877
- // parents travel inside the id, so `need_<e>_<parent>` is not
878
- // emitted and there is nothing that could throw "<parent> is
879
- // required". What replaces it is a malformed id, which splitid_<e>
880
- // refuses by name — so pin THAT instead, and keep the property the
881
- // original test was defending: an incomplete address never reaches
882
- // the API.
883
648
  if (0 < idPartsOf(e).length) {
884
649
  const sep = null != e.idsep && '' !== String(e.idsep) ? String(e.idsep) : '/'
885
650
  const shape = idPartsOf(e).join(sep)
@@ -893,11 +658,6 @@ describe('${provider.fileBase}', () => {
893
658
  e.cmds.includes('remove' === op ? 'remove' : 'load' === op ? 'load' : 'save'))
894
659
 
895
660
  if (null != cmd) {
896
- // THE OTHER REQUIRED KEYS STILL TRAVEL. A composite id carries
897
- // the parts and nothing else, so an entity that also requires a
898
- // plain query key — github's api_insights_summary_stat needs a
899
- // `min_timestamp` besides its `actor_type/actor_id` — tripped
900
- // that guard first and this test asserted the wrong refusal.
901
661
  const rest = queryPairs(e, false)
902
662
  const call = 'remove' === cmd ?
903
663
  `remove$({ ${rest}id: 'incomplete' })` :
@@ -920,23 +680,10 @@ describe('${provider.fileBase}', () => {
920
680
  }
921
681
  }
922
682
 
923
- // EVERY parent key, not just the first. An entity nested two levels
924
- // deep is guarded on both, so a test supplying only the alphabetically
925
- // first tripped the second guard and failed on the code it was meant
926
- // to be exercising.
927
683
  const key = e.parents[0]
928
684
  const pairs = e.parents
929
685
  .map((k: string) => `${k}: '${parentSeed(e, k)}'`).join(', ')
930
686
 
931
- // The guard is PER OP (Main's opParents), not a blanket property of
932
- // the entity, so the op this test calls has to be one that actually
933
- // requires `key` — hardcoding `list` assumed every nested entity's
934
- // list is parent-scoped, which fails for e.g. an entity guarded on
935
- // load/update/remove but whose list is unscoped (GitHub's `repo`:
936
- // owner guards load, not list).
937
- // AND NOT A CMD THAT REFUSES. A refusing cmd emits no guards at all
938
- // — the refusal replaces them — so a test driving it asserted a
939
- // "<key> is required" message that no longer exists.
940
687
  const guardOp = ['list', 'load', 'update', 'remove']
941
688
  .find((op: string) => (e.opParents[op] || []).includes(key) &&
942
689
  !cmdRefuses(e, op))
@@ -945,12 +692,6 @@ describe('${provider.fileBase}', () => {
945
692
  // trip, because the parents live inside the id. needs-full-id above
946
693
  // is what pins the same property for those entities.
947
694
  if (null != guardOp && 0 === idPartsOf(e).length) {
948
- // SENECA HAS NO `update$`. The entity cmds are load$/save$/list$/
949
- // remove$, and an update is a `save$` on an entity that CARRIES an
950
- // id — that is the whole convention this provider is built on.
951
- // Emitting `update$({id})` produced eight tests that failed with
952
- // "update$ is not a function", so they asserted nothing about the
953
- // guard they were written for.
954
695
  const call =
955
696
  'list' === guardOp ? `${guardOp}$({})` :
956
697
  'update' === guardOp ?
@@ -970,12 +711,6 @@ describe('${provider.fileBase}', () => {
970
711
  `)
971
712
  }
972
713
  if (e.cmds.includes('list')) {
973
- // Assert on the SEEDED RECORDS, not merely that an array came back.
974
- // `Array.isArray` is true of the empty array, so the nested-list
975
- // test passed while proving nothing: the seed puts both of this
976
- // entity's records under the same parent, so both must come back,
977
- // under this plugin's canon, still carrying the parent key that
978
- // addressed them.
979
714
  Content(`
980
715
  it('${e.name}-list', async () => {
981
716
  const seneca = await makeSeneca()
@@ -1094,12 +829,6 @@ ${!loadHasKey(e) ? '' : `
1094
829
  })
1095
830
 
1096
831
 
1097
- // THE REFUSAL. A cmd whose only route addresses a different resource
1098
- // must not send the request — `migration`'s remove would delete a
1099
- // repository's migration archive, `user`'s a GPG key, `pull`'s a
1100
- // review comment, with the caller's id dropped and a successful reply.
1101
- // That is the worst possible answer, so it is refused, and refused
1102
- // BY NAME: the message says which key the route does not take.
1103
832
  each(provider.entities, (e: any) => {
1104
833
  for (const cmd of ['remove', 'update']) {
1105
834
  if (true !== (e.idmisaddressed || {})[cmd]) {
@@ -1127,18 +856,6 @@ ${!loadHasKey(e) ? '' : `
1127
856
  })
1128
857
 
1129
858
 
1130
- // ACTIONS — the `action$` directive.
1131
- //
1132
- // The test that matters most is the NEGATIVE one. A name this entity
1133
- // does not have must throw, because the alternative is that the plugin
1134
- // ignores the key and performs an ordinary save: a call that succeeds,
1135
- // reports success, and did something else. That is exactly how GitHub's
1136
- // `merge` reached its provider as an "update" — the endpoint existed,
1137
- // the plugin had no way to name it, and nothing said so.
1138
- //
1139
- // Generated for EVERY entity, whether it has actions or not: an entity
1140
- // with none is the case most likely to be typed at by mistake, and its
1141
- // error is the one that names the empty set.
1142
859
  each(provider.entities, (e: any) => {
1143
860
  const pairs = parentPairs(e, false)
1144
861
  const acts = e.actionList.filter((a: any) => 'save' === a.cmd)
@@ -1175,22 +892,6 @@ ${!loadHasKey(e) ? '' : `
1175
892
  `)
1176
893
  }
1177
894
 
1178
- // THE SILENT-DROP PIN. A save with no `action$` must still take the
1179
- // canonical route: the whole mechanism is worthless if adding it
1180
- // changed what an ordinary call does, and this is the assertion that
1181
- // would fail if the action branch ever ran unconditionally.
1182
- //
1183
- // GATED ON THE ENTITY BEING ABLE TO PERFORM ONE, which is three
1184
- // separate facts and was none of them. The test loads a record, edits
1185
- // it and saves it back, so it needs a `load` cmd to fetch with, a
1186
- // canonical `update` route to save to — an entity whose only update
1187
- // point is the action has no plain save at all — and a mutable field
1188
- // to change. Emitted without those it ships a red suite to a package
1189
- // whose action works perfectly, which is the worst kind of generated
1190
- // test: it fails for a reason that is not about the code it names.
1191
- //
1192
- // The ACTION tests below are not gated on any of this. They are what
1193
- // this entity does have.
1194
895
  const canPlainSave = e.cmds.includes('load') &&
1195
896
  e.canonicalOps.includes('update') &&
1196
897
  !cmdRefuses(e, 'update')
@@ -1219,23 +920,6 @@ ${!loadHasKey(e) ? '' : `
1219
920
  `)
1220
921
  }
1221
922
 
1222
- // And the POSITIVE case: a name the entity DOES have is accepted
1223
- // and dispatched. `directive$` rather than `make$({ action$ })`
1224
- // because make$ drops an unknown trailing-`$` key before any store
1225
- // sees it — see the README's Actions section.
1226
- //
1227
- // WHAT THIS DOES NOT ASSERT, and why. The offline mock answers by
1228
- // matching a seeded record against the parameters of the point the
1229
- // SDK chose, and the seed is built for the CANONICAL route — an
1230
- // action route with parameters of its own has nothing seeded to
1231
- // match, so the mock's honest answer is a 404. Asserting a returned
1232
- // record here would mean generating a test that fails for every API
1233
- // whose actions are not shaped like its CRUD.
1234
- //
1235
- // The provider's own responsibility is to accept the name and route
1236
- // it. That is what is asserted: whatever comes back, it is not this
1237
- // plugin refusing the action. Paired with the unknown-action test
1238
- // above, the two together say the map holds exactly the right names.
1239
923
  const act = acts[0]
1240
924
  Content(`
1241
925
  // \`${act.action}\` is an action of \`${act.op}\`: ${act.path}
@@ -1311,15 +995,6 @@ ${!loadHasKey(e) ? '' : `
1311
995
  `)
1312
996
  }
1313
997
 
1314
- // The write path against a REAL server. The mock answers the shape the
1315
- // SDK expects by construction; only a live run proves the request the
1316
- // provider builds is one the API actually accepts — which for a nested
1317
- // entity means the parent id reached the URL rather than the body.
1318
- //
1319
- // Emitted only when a live parent id is OBTAINABLE (see
1320
- // liveParentsResolvable): against a real server the parent has to be
1321
- // looked up, and an entity whose parent cannot be listed offers no
1322
- // honest way to get one.
1323
998
  each(provider.entities, (e: any) => {
1324
999
  if (e.cmds.includes('save') && e.cmds.includes('remove') &&
1325
1000
  liveParentsResolvable(provider, e) && compositeRoundTrip(e)) {
@@ -1332,10 +1007,6 @@ ${!loadHasKey(e) ? '' : `
1332
1007
  `)
1333
1008
  }
1334
1009
 
1335
- // Repository hygiene, from the @seneca/maintain dependency this package
1336
- // declares. Two of its checks report a fault that is not there, because
1337
- // of WHERE they run rather than what they find, so each is excluded
1338
- // only in the environments that break it.
1339
1010
  Content(`
1340
1011
  it('maintain', async () => {
1341
1012
  const exclude = []
@@ -1430,13 +1101,6 @@ async function makeSeneca(pluginopts) {
1430
1101
  })
1431
1102
 
1432
1103
 
1433
- // --- test/live.js, test/quick.js --------------------------------------------
1434
- //
1435
- // Manual scripts, not part of `npm test`: they need the companion server in
1436
- // the SDK repo's `app/`, which is not published. Generated because the path
1437
- // to that server is knowable — it is the inverse of this target's own
1438
- // `output: path` — so the instruction can be exact rather than "start the
1439
- // server somehow".
1440
1104
 
1441
1105
  const Scripts = cmp(function Scripts(props: any) {
1442
1106
  const { provider } = props
@@ -1601,16 +1265,6 @@ async function run() {
1601
1265
  `)
1602
1266
  }
1603
1267
 
1604
- // The NESTED write, which is the leg worth having a manual script
1605
- // for: it is the one where the parent id has to reach the URL rather
1606
- // than the body, and where a provider that forgets it reports an
1607
- // opaque 404 instead of saying what is missing.
1608
- //
1609
- // Only for a child of the record just created — then the parent id is
1610
- // `id`, already in hand, and removing the child leaves the server
1611
- // exactly as found. A child of anything else would need its own
1612
- // lookup, which belongs in the test suite rather than in a script
1613
- // whose whole point is to be readable.
1614
1268
  const child = provider.entities.find((e: any) =>
1615
1269
  1 === e.parents.length &&
1616
1270
  e.parentEntity === subject.name &&
@@ -1663,7 +1317,6 @@ async function run() {
1663
1317
  })
1664
1318
 
1665
1319
 
1666
- // --- .github/workflows/build.yml --------------------------------------------
1667
1320
 
1668
1321
  const Workflow = cmp(function Workflow(props: any) {
1669
1322
  const { provider } = props
@@ -1776,27 +1429,6 @@ ${!provider.liveApp ? '' : `
1776
1429
  `)
1777
1430
  })
1778
1431
 
1779
- // --- publish.yml ---------------------------------------------------
1780
- //
1781
- // Release on a `v*` tag push, via GitHub OIDC Trusted Publishing — no
1782
- // NPM_TOKEN secret anywhere. `id-token: write` lets npm exchange a
1783
- // GitHub OIDC token for a short-lived publish credential, and npm
1784
- // attaches provenance automatically.
1785
- //
1786
- // TWO THINGS ARE LOAD-BEARING AND EASY TO GET WRONG.
1787
- //
1788
- // The FILENAME. npm's trusted publisher is registered against this
1789
- // file's name, so renaming it breaks publishing until the npm-side
1790
- // configuration is changed to match. It is publish.yml deliberately.
1791
- //
1792
- // `npm install`, NOT `npm ci`. A Seneca plugin does not commit its
1793
- // lockfile (see .gitignore), so there is nothing for ci to install
1794
- // from — it fails outright. The SDK repo commits one and uses ci; this
1795
- // package cannot.
1796
- //
1797
- // The host framework is installed explicitly for the same reason
1798
- // build.yml does it: seneca and its plugins are PEER dependencies, and
1799
- // the test suite requires them directly.
1800
1432
  File({ name: 'publish.yml' }, () => {
1801
1433
  Content(`# Generated by @voxgig/sdkgen. Do not edit.
1802
1434
  #
@@ -1957,13 +1589,6 @@ jobs:
1957
1589
  })
1958
1590
 
1959
1591
 
1960
- // --- README.md ---------------------------------------------------------------
1961
- //
1962
- // The heading set is NOT free: @seneca/maintain checks a Seneca plugin README
1963
- // for "Quick Example", "More Examples", "Motivation", "Support", "API",
1964
- // "Contributing" and "Background", and the generated `maintain` test fails
1965
- // without them. That check is the reason to generate this file rather than
1966
- // leave it to a maintainer.
1967
1592
 
1968
1593
  const Readme = cmp(function Readme(props: any) {
1969
1594
  const { provider } = props
@@ -2044,16 +1669,6 @@ await seneca.ready()
2044
1669
  `)
2045
1670
  }
2046
1671
  if (subject.cmds.includes('load')) {
2047
- // THE FIRST RUNNABLE EXAMPLE HAS TO RUN. `load$('some-id')` passes a
2048
- // bare id and nothing else, but the generated handler calls
2049
- // `need_<entity>_<parent>()` on every parent key before it reaches the
2050
- // SDK — so for any entity that has one, the README's opening example
2051
- // threw `<entity> load: <parent> is required`. Show the object form
2052
- // with the parent keys the handler actually enforces; the bare-string
2053
- // form stays for a parentless entity, where it is correct and shorter.
2054
- // A COMPOSITE KEY IS ONE STRING, not a bag of keys. Its parents travel
2055
- // inside the id, so the object form with them alongside is the shape
2056
- // its own handler rejects — the example has to show the joined id.
2057
1672
  const cparts = idPartsOf(subject)
2058
1673
  const loadArg = 0 < cparts.length ?
2059
1674
  `'${cparts.map((p: string) => 'some-' + p).join(
@@ -2099,9 +1714,6 @@ Each API entity is exposed as a Seneca entity under
2099
1714
  | Seneca entity | Commands | Fields |
2100
1715
  | --- | --- | --- |
2101
1716
  `)
2102
- // Fields as well as commands: a reader deciding whether this plugin
2103
- // covers what they need has to know what a record CONTAINS, and the
2104
- // table used to answer only half the question.
2105
1717
  each(provider.entities, (e: any) => {
2106
1718
  const fields = 0 === e.fields.length ? '—' :
2107
1719
  e.fields.map((f: any) => '`' + f.name + '`').join(', ')
@@ -2124,18 +1736,6 @@ missing key, rather than failing as an opaque 404 from a half-built URL.
2124
1736
  })
2125
1737
  }
2126
1738
 
2127
- // CUSTOM ACTIONS.
2128
- //
2129
- // apidef folds a non-CRUD verb into an ordinary op as an alternative
2130
- // point, and the SDK reaches it with `$action` in the call's argument.
2131
- // The `ts` target documents this in its own REFERENCE.md and the same
2132
- // treatment belongs here, because the Seneca spelling is DIFFERENT
2133
- // (`action$`, trailing dollar, Seneca's directive convention) and a
2134
- // reader who has only ever seen the SDK's would guess wrong.
2135
- //
2136
- // Undocumented, this is the state the plugin was in before: a GitHub
2137
- // provider with a `pull` entity and no way to merge a pull request at
2138
- // all, because nothing anywhere said the endpoint existed.
2139
1739
  const acting = provider.entities.filter((e: any) => 0 < e.actionList.length)
2140
1740
 
2141
1741
  if (0 < acting.length) {
@@ -2337,33 +1937,6 @@ ${provider.api} API definition, against the
2337
1937
  })
2338
1938
 
2339
1939
 
2340
- // --- doc/tutorial.md ---------------------------------------------------------
2341
- //
2342
- // The Diataxis TUTORIAL: an empty folder to a working script in about fifteen
2343
- // minutes. It teaches, so it is deliberately narrower than the other three
2344
- // documents — one path, no alternatives, and no decisions asked of the
2345
- // reader.
2346
- //
2347
- // Two decisions shape this component.
2348
- //
2349
- // FIRST, a tutorial must never ask the reader to invent a value. Every id in
2350
- // the script is therefore either seeded here (offline) or read back from a
2351
- // list call (live) — never a literal that only happens to exist on the
2352
- // author's machine. That is also why a declared server is not by itself
2353
- // enough to choose the live lesson: the primary entity must be listable, and
2354
- // a nested primary entity must have a listable parent, or there is no honest
2355
- // way to come by the first id. Failing that the offline lesson runs, which is
2356
- // a complete tutorial in its own right rather than an apology for a missing
2357
- // server.
2358
- //
2359
- // SECOND, the step numbers are computed rather than written, because which
2360
- // steps exist depends on which cmds the model declares. `step()` counts as it
2361
- // emits, and the prose refers to what a step did rather than to a number that
2362
- // may not be there.
2363
- //
2364
- // The offline seed reuses seedRecord() — the same function behind
2365
- // test/seed.js — so what the reader is told to paste has the shape the SDK
2366
- // really answers with.
2367
1940
 
2368
1941
  const DocTutorial = cmp(function DocTutorial(props: any) {
2369
1942
  const { provider } = props
@@ -3030,21 +2603,6 @@ the way you saw:
3030
2603
  })
3031
2604
 
3032
2605
 
3033
- // --- doc/how-to.md ----------------------------------------------------------
3034
- //
3035
- // The task-oriented quadrant of the Diataxis set: one problem per section, for
3036
- // a reader who already has the plugin loaded. It instructs and does not
3037
- // explain — anything that starts justifying a design choice belongs in
3038
- // explanation.md and is linked to instead.
3039
- //
3040
- // Two decisions worth naming. First, the section list is built as data before
3041
- // anything is emitted, so the table of contents and the sections themselves
3042
- // are produced from the SAME guards and cannot drift: a recipe that is
3043
- // suppressed because no entity declares the cmd also loses its TOC entry.
3044
- // Second, every example id is the one `seedRecord` gives that entity, so the
3045
- // examples here and the seed in test/seed.js agree — the offline recipe can
3046
- // then be copied verbatim and the ids used in every other recipe will
3047
- // actually resolve.
3048
2606
 
3049
2607
  const DocHowto = cmp(function DocHowto(props: any) {
3050
2608
  const { provider } = props
@@ -3101,13 +2659,6 @@ const DocHowto = cmp(function DocHowto(props: any) {
3101
2659
 
3102
2660
  const key = (k: string) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : `'${k}'`
3103
2661
 
3104
- // SERIALISE, DO NOT COERCE. `String(value)` renders an object as
3105
- // `[object Object]` and an empty array as the empty string, so a field of
3106
- // either kind turned the documented create recipe into a syntax error
3107
- // (`code_of_conduct: [object Object]`, `labels: ,`). Every value a seed
3108
- // record can hold — string, number, boolean, array, plain object — now
3109
- // emits as the JS literal it claims to be, recursively, so a reader can
3110
- // copy the block and run it.
3111
2662
  const jsval = (v: any): string => {
3112
2663
  if (null === v || undefined === v) {
3113
2664
  return 'null'
@@ -3711,25 +3262,6 @@ ${s.body}
3711
3262
  })
3712
3263
 
3713
3264
 
3714
- // --- doc/reference.md ---------------------------------------------------------
3715
- //
3716
- // The Diátaxis reference: information-oriented, complete, and never teaching.
3717
- // Everything a caller can reach — options, canons, fields, patterns, exports,
3718
- // errors, environment variables, scripts — stated once, in tables, with the
3719
- // exact strings the generated source actually emits.
3720
- //
3721
- // Three facts here are easy to get wrong by copying a hand-written original.
3722
- // The guard message carries the PUBLISHED package name, because that is what
3723
- // Main interpolates (`${provider.pkgName}: <entity> <cmd>: <key> is required`).
3724
- // The `sdk` block of the get:info response carries the SDK's PACKAGE name, not
3725
- // its slug. And an entity whose id field is not literally `id` cannot be read
3726
- // with the `load$('x')` short form at all — Seneca turns that into `{id: 'x'}`,
3727
- // which the generated action does not look at — so the object form is
3728
- // documented for those entities instead of the string form.
3729
- //
3730
- // Nothing here assumes CRUD: every table row is conditional on the cmds and
3731
- // ops the model actually declares, so an API offering only create, or only
3732
- // reads, documents only what it has.
3733
3265
 
3734
3266
  const DocReference = cmp(function DocReference(props: any) {
3735
3267
  const { provider } = props
@@ -3740,8 +3272,6 @@ const DocReference = cmp(function DocReference(props: any) {
3740
3272
  // has nothing to select here.
3741
3273
  const acting = provider.entities.filter((e: any) => 0 < e.actionList.length)
3742
3274
 
3743
- // The entity used for worked examples: the same choice the tests and README
3744
- // make, so all three documents show the same entity.
3745
3275
  const subject = [...provider.entities]
3746
3276
  .sort((a: any, b: any) =>
3747
3277
  (a.parents.length - b.parents.length) || (b.cmds.length - a.cmds.length))[0]
@@ -3760,8 +3290,6 @@ const DocReference = cmp(function DocReference(props: any) {
3760
3290
 
3761
3291
  const live = '' !== provider.liveBase
3762
3292
 
3763
- // test/quick.js is emitted only when the subject entity can be created and
3764
- // removed again — see the Scripts cmp — so only document it when it exists.
3765
3293
  const quick = live && subject.cmds.includes('save') && subject.cmds.includes('remove')
3766
3294
 
3767
3295
  const canon = (e: any) => `provider/${provider.lower}/${e.name}`
@@ -4450,28 +3978,10 @@ ${quick ? 'Both scripts target' : 'It targets'} \`$${provider.ENV}_TEST_BASE\`,
4450
3978
  })
4451
3979
 
4452
3980
 
4453
- // --- doc/explanation.md ------------------------------------------------------
4454
- //
4455
- // The understanding-oriented corner of the Diátaxis set: the document someone
4456
- // opens when the plugin surprised them. It DISCUSSES and never instructs, so
4457
- // nothing here is a step and nothing here is a table — those belong in
4458
- // tutorial.md, how-to.md and reference.md.
4459
- //
4460
- // The hard part of generating this one is that its subject is design reasoning,
4461
- // most of which is true of EVERY provider this target emits (the entityBuilder
4462
- // convention, the four-cmds-to-five-ops join, the .data() hop, the 404
4463
- // translation) and only some of which depends on the model (whether any entity
4464
- // is nested, whether writes exist at all, whether the API declares a server).
4465
- // So the invariant prose is written once and the model-dependent sections are
4466
- // guarded — an API with no nesting gets no nesting section rather than a
4467
- // section explaining that it has none.
4468
3981
 
4469
3982
  const DocExplanation = cmp(function DocExplanation(props: any) {
4470
3983
  const { provider } = props
4471
3984
 
4472
- // The entity used as the worked example throughout: fewest parent keys
4473
- // (nothing to arrange around it) and the most cmds. Same choice the Tests
4474
- // and Readme cmps make, so the documents agree on what they talk about.
4475
3985
  const subject = [...provider.entities]
4476
3986
  .sort((a: any, b: any) =>
4477
3987
  (a.parents.length - b.parents.length) || (b.cmds.length - a.cmds.length))[0]
@@ -4919,23 +4429,6 @@ order-dependent and then flaky.
4919
4429
  })
4920
4430
 
4921
4431
 
4922
- // --- doc/ --------------------------------------------------------------------
4923
- //
4924
- // The Diátaxis documentation set: an index plus the four quadrants.
4925
- //
4926
- // WHY THIS IS GENERATED AT ALL. Every other sdkgen target emits a single
4927
- // README, and for a language SDK that is the right amount: the SDK's real
4928
- // reference is its types. A Seneca provider has no types a reader can browse —
4929
- // its whole interface is message patterns and entity canons, which exist only
4930
- // in prose. The provider this target was modelled on carried 1100 lines of
4931
- // hand-written documentation for exactly that reason, and the first
4932
- // regeneration left all of it orphaned: the README's link table was gone and
4933
- // nothing emitted the files it had pointed at.
4934
- //
4935
- // Everything here is derived from the same `provider` shape the source and the
4936
- // tests are built from, so the docs cannot describe an entity the plugin does
4937
- // not expose, or a cmd it does not implement — the drift that makes
4938
- // hand-written provider docs untrustworthy after the second API change.
4939
4432
 
4940
4433
  const DocIndex = cmp(function DocIndex(props: any) {
4941
4434
  const { provider } = props
@@ -4987,9 +4480,6 @@ components, where fixing it once fixes every provider.
4987
4480
  })
4988
4481
 
4989
4482
 
4990
- // The whole `doc/` folder. One cmp so Main names the documentation once, and
4991
- // so the folder is opened in a single place — the four quadrant components
4992
- // emit a File each and know nothing about where they sit.
4993
4483
  const Docs = cmp(function Docs(props: any) {
4994
4484
  const { provider } = props
4995
4485