@voxgig/sdkgen-infrapack 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -20,14 +20,46 @@ import {
20
20
 
21
21
  // Does this entity's load op have a real identifying param (path or
22
22
  // required query), e.g. GET /result?trace_id=? A paramless GET has none.
23
- function loadHasKey(ent: any): boolean {
24
- const point = (ent.op && ent.op.load && ent.op.load.points || [])[0]
25
- if (null == point) return false
26
- // apidef states which segments are variables (its ADR-003) — no brace test.
27
- const hasPathParam = pointSegments(point).some((seg: any) => null != seg.var)
28
- const hasQueryParam = (point.args && point.args.query || [])
29
- .some((q: any) => false !== q.reqd)
30
- return hasPathParam || hasQueryParam
23
+ function loadHasKey(e: any): boolean {
24
+ // FROM WHAT THE HANDLER ACTUALLY SENDS (Main's addressKeys), not from the
25
+ // route's shape. A route can carry parameters and still be a singleton
26
+ // read: github's `interaction` is `/user/interaction-limits`, and its
27
+ // sibling `webhook_config` reads one config per app — both are called as
28
+ // `load({})`, so every id hits the same record and a not-found test
29
+ // against them asserts the opposite of the truth. Asking the route
30
+ // whether it has any parameter said yes for both.
31
+ return true === (e.idaddressed || {}).load
32
+ }
33
+
34
+
35
+ // CAN A REMOVE DELETE WHAT A CREATE JUST MADE? A round-trip that ends by
36
+ // asserting the record is gone needs one that can address it.
37
+ //
38
+ // github's `action` is a tag bucket whose ops address different resources:
39
+ // keyed `archive_format` from its download route, removed by
40
+ // `hosted_runner_id` and `org_id`. The remove therefore deleted whichever
41
+ // record the store yielded first — usually a SEEDED one — and the round-trip
42
+ // failed on its own record surviving, intermittently, because the created
43
+ // id is random and its position in iteration order decides.
44
+ function removeAddresses(e: any): boolean {
45
+ return true === (e.idaddressed || {}).remove
46
+ }
47
+
48
+
49
+ // WOULD THIS CMD REFUSE? A cmd whose only route addresses a different
50
+ // resource does not send the request (Main's `misaddressed`), so a test that
51
+ // drives it reaches the refusal and nothing beyond — a parent-key guard, a
52
+ // round-trip's update leg, a plain-save assertion. Each such test is skipped
53
+ // here and the refusal is pinned by its own `<entity>-<cmd>-refused`.
54
+ // What a refusing cmd's route DOES address, for the message and the note.
55
+ function addressNames(e: any, cmd: string): string[] {
56
+ const keys = (e.opParents || {})[cmd] || []
57
+ return 0 < keys.length ? keys : ['nothing more specific']
58
+ }
59
+
60
+
61
+ function cmdRefuses(e: any, cmd: string): boolean {
62
+ return true === (e.idmisaddressed || {})[cmd]
31
63
  }
32
64
 
33
65
 
@@ -76,6 +108,151 @@ function parentPairs(e: any, live: boolean): string {
76
108
  }
77
109
 
78
110
 
111
+ // A COMPOSITE-KEY ENTITY CARRIES ITS PARENTS INSIDE ITS ID, so a query must
112
+ // NOT also pass them as separate keys — `load$({ owner, id })` is the shape
113
+ // the handler now rejects, because the id it splits already holds the owner.
114
+ // These three keep every emitted test, script and doc addressing such an
115
+ // entity the one way that works.
116
+ function idPartsOf(e: any): string[] {
117
+ return Array.isArray(e.idparts) && 1 < e.idparts.length ?
118
+ e.idparts.map((p: any) => String(p)) : []
119
+ }
120
+
121
+
122
+ // The Seneca id for one seeded record: the record key for an ordinary
123
+ // entity, and the seeded parts joined for a composite one — `owner0/repo0`
124
+ // where the parts are `owner` (a parent, so its seeded parent id) and `repo`
125
+ // (the record's own key).
126
+ function entIdLiteral(e: any, suffix: string): string {
127
+ const parts = idPartsOf(e)
128
+ if (0 === parts.length) {
129
+ return `${e.name}${suffix}`
130
+ }
131
+
132
+ const sep = null != e.idsep && '' !== String(e.idsep) ? String(e.idsep) : '/'
133
+ const vals = parts.map((p: string) =>
134
+ e.parents.includes(p) ? parentSeed(e, p) : `${e.name}${suffix}`)
135
+
136
+ // THE SUFFIX MUST SURVIVE, or `-nosuch` names the record that exists.
137
+ //
138
+ // A part that is also a parent key takes the parent's seeded value, which
139
+ // ignores the suffix — and for github's repo BOTH parts are parent keys,
140
+ // so `entIdLiteral(e, '-nosuch')` returned `owner0/repo0`. The not-found
141
+ // test then loaded the seeded record and asserted it was null. The last
142
+ // part is the record's own key, so that is where the suffix belongs.
143
+ if ('' !== suffix && !vals.some((v: string) => v.endsWith(suffix))) {
144
+ vals[vals.length - 1] = vals[vals.length - 1] + suffix
145
+ }
146
+
147
+ return vals.join(sep)
148
+ }
149
+
150
+
151
+ // Object-literal pairs putting each composite part at its `from` path, as a
152
+ // create must send them. `owner.login` becomes `owner: { login: '...' }`;
153
+ // several parts sharing a prefix are merged into one object.
154
+ function idFromPairs(e: any, suffix: string): string {
155
+ const parts = idPartsOf(e)
156
+ const from = e.idfrom || {}
157
+ const tree: any = {}
158
+
159
+ for (const part of parts) {
160
+ const value = e.parents.includes(part) ?
161
+ parentSeed(e, part) : `${e.name}${suffix}`
162
+ const keys = String(from[part] || part).split('.')
163
+ let node = tree
164
+ for (let i = 0; i < keys.length - 1; i++) {
165
+ node[keys[i]] = node[keys[i]] || {}
166
+ node = node[keys[i]]
167
+ }
168
+ node[keys[keys.length - 1]] = value
169
+ }
170
+
171
+ const render = (node: any): string => '{ ' + Object.keys(node)
172
+ .map((k: string) => `${jsKey(k)}: ${'object' === typeof node[k] ?
173
+ render(node[k]) : `'${node[k]}'`}`)
174
+ .join(', ') + ' }'
175
+
176
+ return Object.keys(tree)
177
+ .map((k: string) => `${jsKey(k)}: ${'object' === typeof tree[k] ?
178
+ render(tree[k]) : `'${tree[k]}'`}`)
179
+ .join(', ')
180
+ }
181
+
182
+
183
+ // Parent pairs for a query, or nothing when the id already carries them.
184
+ function queryPairs(e: any, live: boolean): string {
185
+ const parts = idPartsOf(e)
186
+ if (0 === parts.length) {
187
+ return parentPairs(e, live)
188
+ }
189
+
190
+ // ONLY THE PARTS TRAVEL INSIDE THE ID. A required key that is not one of
191
+ // them still has to be passed, and dropping every parent because SOME of
192
+ // them are parts left github's api_insights_summary_stat — keyed
193
+ // `actor_type/actor_id`, and requiring a `min_timestamp` besides — called
194
+ // without the timestamp its own handler guards. Its three read tests
195
+ // failed on the guard rather than on anything they were written to check.
196
+ const rest = e.parents.filter((p: string) => !parts.includes(p))
197
+
198
+ return rest
199
+ .map((p: string) => live ? `${p}, ` : `${p}: '${parentSeed(e, p)}', `)
200
+ .join('')
201
+ }
202
+
203
+
204
+ // CAN A CREATED RECORD'S COMPOSITE ID BE REBUILT? Only if every part is
205
+ // recoverable, and for a composite entity that is not a given.
206
+ //
207
+ // A create supplies the parts one of two ways: as path parameters of the
208
+ // create route, or in the response. github's repo has NEITHER for its
209
+ // `repo` part — `POST /user/repos` takes no path parameters, and the
210
+ // response names the repository `name`, never `repo`. So there is no honest
211
+ // way to know the id of a repo the API just made, and a create/update/remove
212
+ // round-trip cannot be written against it.
213
+ //
214
+ // THIS IS A MODEL GAP, NOT A TEST TO FORCE. What is missing is a mapping
215
+ // from a path parameter to the response field that carries it (`repo` ->
216
+ // `name`); apidef knows the parameter and the field but nothing relates
217
+ // them. Emitting the round-trip anyway produced a 404 on the update leg that
218
+ // pointed at the mock rather than at the cause, so the honest thing is to
219
+ // leave it out and say why in the generated file.
220
+ //
221
+ // load, load-missing and the malformed-id test are all still emitted: those
222
+ // address an existing record, where the id comes from the caller.
223
+ function compositeRoundTrip(e: any): boolean {
224
+ const parts = idPartsOf(e)
225
+ if (0 === parts.length) {
226
+ return true
227
+ }
228
+
229
+ // EVERY PART PLACED, AND PLACED AT THE TOP LEVEL.
230
+ //
231
+ // Placed at all: the model must say where a response carries the part, or a
232
+ // created record's id cannot be rebuilt by anything.
233
+ //
234
+ // Top level: only for the OFFLINE round-trip, and only because of how this
235
+ // transport matches a write. It takes the keys it matches on from the
236
+ // request BODY, and a write's addressing parameters no longer travel there
237
+ // — they go in the entity match, which is what stopped them displacing a
238
+ // nested response field. So an update finds nothing to pin the record by.
239
+ //
240
+ // Widening the transport's key set to the point's required parameters was
241
+ // tried and over-constrains reads: PullEntity's basic load began matching
242
+ // on a parameter it had never constrained, and answered 404. The proper
243
+ // fix is for the transport to take a write's addressing keys from the
244
+ // resolved path parameters specifically, which is a change to shared
245
+ // machinery that wants its own validation pass.
246
+ //
247
+ // Reads, lists and removes round-trip through a nested part today.
248
+ const from = e.idfrom || {}
249
+ return parts.every((p: string) => {
250
+ const path = from[p]
251
+ return null != path && '' !== String(path) && !String(path).includes('.')
252
+ })
253
+ }
254
+
255
+
79
256
  // The entity a parent path param addresses, or null when the model has none of
80
257
  // that name.
81
258
  function parentEntityFor(provider: any, e: any, key: string): any {
@@ -171,6 +348,9 @@ function mutableField(e: any): string {
171
348
  function crudTest(provider: any, e: any, mode: 'offline' | 'live'): string {
172
349
  const live = 'live' === mode
173
350
  const pairs = parentPairs(e, live)
351
+ // A composite id already holds the parent keys; passing them again as
352
+ // separate query fields is the shape the handler now rejects.
353
+ const qpairs = queryPairs(e, live)
174
354
  // Seneca's key, not the API's: this test drives seneca.entity(...), whose
175
355
  // query and entity always spell the id `id`. The provider translates to
176
356
  // whatever the API calls it.
@@ -185,28 +365,37 @@ function crudTest(provider: any, e: any, mode: 'offline' | 'live'): string {
185
365
  f.name !== idf && 'id' !== f.name && !e.parents.includes(f.name)).length ?
186
366
  seedLiteral(e, 'crud') : ''
187
367
 
188
- return `${ind}it('${e.name}-crud', async (${live ? 't' : ''}) => {
189
- ${live ? `${ind} if (!live) return t.skip(noServer())\n` : ''}${ind} const seneca = await ${mk}
190
- ${ind} const ent = seneca.entity('provider/${provider.lower}/${e.name}')
191
-
192
- ${setup}${ind} // Seneca's convention: an entity WITHOUT an id is a create. The API
193
- ${ind} // assigns the id itself, so the saved record comes back with one it chose.
194
- ${ind} const made = await ent.make$({ ${pairs}${made} }).save$()
195
-
196
- ${ind} assert.ok(null != made.${idf})
197
- ${ind} assert.equal(
198
- ${ind} made.canon\$({ string: true }),
199
- ${ind} 'provider/${provider.lower}/${e.name}',
200
- ${ind} )
201
-
202
- ${ind} const id = made.${idf}
203
-
204
- ${ind} try {
205
- ${ind} const loaded = await ent.load\$({ ${pairs}${idf}: id })
368
+ // A COMPOSITE RECORD MUST BE CREATED IN THE SHAPE IT COMES BACK IN.
369
+ //
370
+ // The offline transport echoes what a create sent, so a create that sends
371
+ // its parts flat produces a record whose id cannot be read back — `from`
372
+ // looks for github's owner at `owner.login` and finds a bare string. The
373
+ // parts therefore go in at their `from` paths, appended AFTER the field
374
+ // literal so they win over the flat pair the seed emitted.
375
+ //
376
+ // The record key gets its own value rather than the seed's, so a created
377
+ // record is distinguishable from a seeded one in the same store.
378
+ const idmake = 0 === idPartsOf(e).length ? '' :
379
+ ', ' + idFromPairs(e, '-crud')
380
+
381
+ // NOT EVERY WRITABLE ENTITY IS READABLE. github's `app` declares create,
382
+ // update, remove and list — and no load at all, because the API offers no
383
+ // route that reads one app back. The round-trip read `ent.load$(...)` on
384
+ // nine such entities, got the null the provider correctly returns for a
385
+ // cmd it does not implement, and died on `loaded.id` — so the write path
386
+ // those tests existed to cover went unexercised.
387
+ //
388
+ // What can be checked still is: the update runs on the created entity
389
+ // itself, and the remove runs. What cannot be checked is stated in the
390
+ // file rather than quietly dropped.
391
+ const hasLoad = e.cmds.includes('load')
392
+
393
+ const body = hasLoad ? `${ind} try {
394
+ ${ind} const loaded = await ent.load\$({ ${qpairs}${idf}: id })
206
395
  ${ind} assert.equal(loaded.${idf}, id)
207
396
  ${
208
- '' === mut ? '' :
209
- `
397
+ '' === mut ? '' :
398
+ `
210
399
  ${ind} // An entity CARRYING an id is an update, not a second create.
211
400
  ${ind} loaded.${mut} = 'crud-${mut}-2'
212
401
  ${ind} const updated = await loaded.save\$()
@@ -214,19 +403,53 @@ ${ind} const updated = await loaded.save\$()
214
403
  ${ind} assert.equal(updated.${idf}, id)
215
404
  ${ind} assert.equal(updated.${mut}, 'crud-${mut}-2')
216
405
 
217
- ${ind} const reloaded = await ent.load\$({ ${pairs}${idf}: id })
406
+ ${ind} const reloaded = await ent.load\$({ ${qpairs}${idf}: id })
218
407
  ${ind} assert.equal(reloaded.${mut}, 'crud-${mut}-2')
219
408
  `}${ind} }
220
409
  ${ind} finally {
221
410
  ${ind} // Always clean up. The mock and the server both hold data for the
222
411
  ${ind} // process lifetime, so a leaked record changes what later tests see.
223
- ${ind} await ent.remove\$({ ${pairs}${idf}: id })
412
+ ${ind} await ent.remove\$({ ${qpairs}${idf}: id })
224
413
  ${ind} }
225
414
 
226
415
  ${ind} // remove is real: the record is gone, and reading it is an ordinary
227
416
  ${ind} // not-found rather than an error.
228
417
  ${ind} assert.equal(await ent.load\$({ ${pairs}${idf}: id }), null)
229
- ${ind}})
418
+ ` : `${ind} // This ${e.name} has no load cmd: the API offers no route that reads
419
+ ${ind} // one back, so the record cannot be re-read here and the remove cannot
420
+ ${ind} // be confirmed by a follow-up read. The write path is still exercised.
421
+ ${ind} try {
422
+ ${
423
+ '' === mut ? '' :
424
+ `${ind} // An entity CARRYING an id is an update, not a second create.
425
+ ${ind} made.${mut} = 'crud-${mut}-2'
426
+ ${ind} const updated = await made.save\$()
427
+
428
+ ${ind} assert.equal(updated.${idf}, id)
429
+ ${ind} assert.equal(updated.${mut}, 'crud-${mut}-2')
430
+ `}${ind} }
431
+ ${ind} finally {
432
+ ${ind} await ent.remove\$({ ${qpairs}${idf}: id })
433
+ ${ind} }
434
+ `
435
+
436
+ return `${ind}it('${e.name}-crud', async (${live ? 't' : ''}) => {
437
+ ${live ? `${ind} if (!live) return t.skip(noServer())\n` : ''}${ind} const seneca = await ${mk}
438
+ ${ind} const ent = seneca.entity('provider/${provider.lower}/${e.name}')
439
+
440
+ ${setup}${ind} // Seneca's convention: an entity WITHOUT an id is a create. The API
441
+ ${ind} // assigns the id itself, so the saved record comes back with one it chose.
442
+ ${ind} const made = await ent.make$({ ${pairs}${made}${idmake} }).save$()
443
+
444
+ ${ind} assert.ok(null != made.${idf})
445
+ ${ind} assert.equal(
446
+ ${ind} made.canon\$({ string: true }),
447
+ ${ind} 'provider/${provider.lower}/${e.name}',
448
+ ${ind} )
449
+
450
+ ${ind} const id = made.${idf}
451
+
452
+ ${body}${ind}})
230
453
 
231
454
  `
232
455
  }
@@ -266,10 +489,24 @@ function seedLiteral(e: any, tag: string): string {
266
489
  function seedRecord(e: any, idx: number): Record<string, any> {
267
490
  const out: Record<string, any> = {}
268
491
 
492
+ // The field the API's routes address this record by. `e.idf` is null
493
+ // whenever the load match has no `id` at all, which is exactly the case
494
+ // this seed was getting wrong, so prefer the route-derived key.
495
+ const rkey = e.rk || e.idf || 'id'
496
+
269
497
  for (const f of e.fields) {
270
- if ('id' === f.name || f.name === e.idf) {
498
+ if (f.name === rkey) {
271
499
  out[f.name] = `${e.name}${idx}`
272
500
  }
501
+ // AN `id` THAT IS NOT THE ADDRESSING KEY IS SEEDED DISTINCTLY. Seeding
502
+ // both the same value made the offline suite unable to tell a provider
503
+ // that addresses records correctly from one that confuses the API's own
504
+ // `id` with the key its routes take — the seed agreed with either. Real
505
+ // GitHub never returns that: a pull has a global database `id` AND a
506
+ // repo-scoped `number`, and they differ.
507
+ else if ('id' === f.name) {
508
+ out[f.name] = `${e.name}-apiid-${idx}`
509
+ }
273
510
  else if (e.parents.includes(f.name)) {
274
511
  // A nested entity's parent id must match a record the parent seeds, or
275
512
  // the offline store answers nothing and every nested test reads as a
@@ -300,10 +537,79 @@ function seedRecord(e: any, idx: number): Record<string, any> {
300
537
  }
301
538
  }
302
539
 
540
+ // THE ADDRESSING KEY IS ALWAYS PRESENT, even when the response schema has
541
+ // no field of that name.
542
+ //
543
+ // The offline transport is a store, and it can only answer a request by
544
+ // matching the request's own parameters against a stored record
545
+ // (TestFeature.buildArgs). github reads an org's artifact retention from
546
+ // `/orgs/{org}/actions/permissions/artifact-and-log-retention`, whose body
547
+ // is `{days, maximum_allowed_days}` — no org anywhere in it. The loop
548
+ // above stamps the key only onto a field that already exists, so such a
549
+ // record was seeded with nothing the provider addresses it by, every
550
+ // offline load of it answered 404, and forty-one generated tests failed on
551
+ // a null they could not have avoided.
552
+ //
553
+ // This is a property of the mock, not a claim about the API: a real
554
+ // response need not echo the path parameter that selected it, which is
555
+ // why the handler carries the request's own values across into the id
556
+ // rather than reading them back off the body.
557
+ if ('' !== String(rkey) && null == out[rkey] &&
558
+ 0 === (Array.isArray(e.idparts) ? e.idparts.length : 0)) {
559
+ out[rkey] = e.parents.includes(rkey) ?
560
+ parentSeed(e, rkey) : `${e.name}${idx}`
561
+ }
562
+
563
+ // THE SEED MODELS THE REAL RESPONSE, through the same `from` mapping the
564
+ // runtime reads. github's repo owner goes to `owner.login` and its name to
565
+ // `name`, because that is where the API puts them — so a record the mock
566
+ // returns is identifiable by exactly the code that identifies a real one.
567
+ //
568
+ // This only works because the offline transport now matches a request
569
+ // parameter against `id.from` as well as against its own name
570
+ // (TestFeature.buildArgs). Seeding this shape before that landed made
571
+ // every composite record unfindable: the mock looked for a field called
572
+ // `owner` and found an object.
573
+ seedIdParts(e, out, idx)
574
+
303
575
  return out
304
576
  }
305
577
 
306
578
 
579
+
580
+
581
+ // Write each composite part's seeded value into the record at the path the
582
+ // model says carries it, creating the intermediate objects a dotted path
583
+ // implies. A part that is a PARENT key takes the parent's seeded id, so a
584
+ // nested composite record still lines up with its parent.
585
+ function seedIdParts(e: any, out: Record<string, any>, idx: number): void {
586
+ const parts: string[] = Array.isArray(e.idparts) && 1 < e.idparts.length ?
587
+ e.idparts.map((p: any) => String(p)) : []
588
+ if (0 === parts.length) {
589
+ return
590
+ }
591
+
592
+ const from = e.idfrom || {}
593
+
594
+ for (const part of parts) {
595
+ const path = String(from[part] || part)
596
+ const value = e.parents.includes(part) ?
597
+ parentSeed(e, part) : `${e.name}${idx}`
598
+
599
+ const keys = path.split('.')
600
+ let node: any = out
601
+ for (let i = 0; i < keys.length - 1; i++) {
602
+ const k = keys[i]
603
+ if (null == node[k] || 'object' !== typeof node[k] || Array.isArray(node[k])) {
604
+ node[k] = {}
605
+ }
606
+ node = node[k]
607
+ }
608
+ node[keys[keys.length - 1]] = value
609
+ }
610
+ }
611
+
612
+
307
613
  const Tests = cmp(function Tests(props: any) {
308
614
  const { provider } = props
309
615
 
@@ -480,9 +786,9 @@ describe('${provider.fileBase}', () => {
480
786
  const seneca = await makeSeneca()
481
787
  const found = await seneca
482
788
  .entity('provider/${provider.lower}/${e.name}')
483
- .load$('${e.name}0')
789
+ .load$('${entIdLiteral(e, '0')}')
484
790
 
485
- assert.equal(found.${e.idf || 'id'}, '${e.name}0')
791
+ assert.equal(found.${e.idf || 'id'}, '${entIdLiteral(e, '0')}')
486
792
  assert.equal(
487
793
  found.canon$({ string: true }),
488
794
  'provider/${provider.lower}/${e.name}',
@@ -493,7 +799,7 @@ describe('${provider.fileBase}', () => {
493
799
  // Paramless read (e.g. GET /usage): every id "misses" the same
494
800
  // way a hit does -- the mock has nothing to filter by -- so a
495
801
  // load-missing test would just assert the happy path again.
496
- if (loadHasKey(e.ent)) {
802
+ if (loadHasKey(e)) {
497
803
  Content(`
498
804
  // A 404 from a single-item read is an ordinary "not found" answer, not a
499
805
  // failure: the provider turns it into null rather than letting the SDK
@@ -502,7 +808,7 @@ describe('${provider.fileBase}', () => {
502
808
  const seneca = await makeSeneca()
503
809
  const missing = await seneca
504
810
  .entity('provider/${provider.lower}/${e.name}')
505
- .load$('nosuch${e.name}')
811
+ .load$('${entIdLiteral(e, '-nosuch')}')
506
812
 
507
813
  assert.equal(missing, null)
508
814
  })
@@ -515,6 +821,53 @@ describe('${provider.fileBase}', () => {
515
821
  // A nested entity cannot build its path without the parent id. That is
516
822
  // the mistake this target exists to make impossible, so pin it.
517
823
  each(nested, (e: any) => {
824
+ // A COMPOSITE-KEY ENTITY HAS NO SEPARATE PARENT GUARD to pin: its
825
+ // parents travel inside the id, so `need_<e>_<parent>` is not
826
+ // emitted and there is nothing that could throw "<parent> is
827
+ // required". What replaces it is a malformed id, which splitid_<e>
828
+ // refuses by name — so pin THAT instead, and keep the property the
829
+ // original test was defending: an incomplete address never reaches
830
+ // the API.
831
+ if (0 < idPartsOf(e).length) {
832
+ const sep = null != e.idsep && '' !== String(e.idsep) ? String(e.idsep) : '/'
833
+ const shape = idPartsOf(e).join(sep)
834
+ // The separator is a SLASH, and this goes inside a regex literal:
835
+ // unescaped it closes the literal early and the emitted test is a
836
+ // syntax error ("Invalid regular expression flags"). Escape every
837
+ // regex metacharacter, not just the slash, so a future separator
838
+ // cannot reintroduce this.
839
+ const shapeRe = shape.replace(/[.*+?^${}()|[\]\\\/]/g, '\\$&')
840
+ const cmd = ['load', 'remove', 'update'].find((op: string) =>
841
+ e.cmds.includes('remove' === op ? 'remove' : 'load' === op ? 'load' : 'save'))
842
+
843
+ if (null != cmd) {
844
+ // THE OTHER REQUIRED KEYS STILL TRAVEL. A composite id carries
845
+ // the parts and nothing else, so an entity that also requires a
846
+ // plain query key — github's api_insights_summary_stat needs a
847
+ // `min_timestamp` besides its `actor_type/actor_id` — tripped
848
+ // that guard first and this test asserted the wrong refusal.
849
+ const rest = queryPairs(e, false)
850
+ const call = 'remove' === cmd ?
851
+ `remove$({ ${rest}id: 'incomplete' })` :
852
+ `load$({ ${rest}id: 'incomplete' })`
853
+
854
+ Content(`
855
+ // This ${e.name} is addressed by \`${shape}\`, so an id that is not all
856
+ // of those parts cannot build a request. It is refused here rather than
857
+ // sent as a URL that would address the wrong record.
858
+ it('${e.name}-needs-full-id', async () => {
859
+ const seneca = await makeSeneca()
860
+
861
+ await assert.rejects(
862
+ () => seneca.entity('provider/${provider.lower}/${e.name}').${call},
863
+ /id must be '${shapeRe}'/,
864
+ )
865
+ })
866
+
867
+ `)
868
+ }
869
+ }
870
+
518
871
  // EVERY parent key, not just the first. An entity nested two levels
519
872
  // deep is guarded on both, so a test supplying only the alphabetically
520
873
  // first tripped the second guard and failed on the code it was meant
@@ -529,12 +882,28 @@ describe('${provider.fileBase}', () => {
529
882
  // list is parent-scoped, which fails for e.g. an entity guarded on
530
883
  // load/update/remove but whose list is unscoped (GitHub's `repo`:
531
884
  // owner guards load, not list).
885
+ // AND NOT A CMD THAT REFUSES. A refusing cmd emits no guards at all
886
+ // — the refusal replaces them — so a test driving it asserted a
887
+ // "<key> is required" message that no longer exists.
532
888
  const guardOp = ['list', 'load', 'update', 'remove']
533
- .find((op: string) => (e.opParents[op] || []).includes(key))
534
-
535
- if (null != guardOp) {
536
- const call = 'list' === guardOp ?
537
- `${guardOp}$({})` : `${guardOp}$({ id: '${e.name}0' })`
889
+ .find((op: string) => (e.opParents[op] || []).includes(key) &&
890
+ !cmdRefuses(e, op))
891
+
892
+ // Not for a composite key: there is no separate parent guard to
893
+ // trip, because the parents live inside the id. needs-full-id above
894
+ // is what pins the same property for those entities.
895
+ if (null != guardOp && 0 === idPartsOf(e).length) {
896
+ // SENECA HAS NO `update$`. The entity cmds are load$/save$/list$/
897
+ // remove$, and an update is a `save$` on an entity that CARRIES an
898
+ // id — that is the whole convention this provider is built on.
899
+ // Emitting `update$({id})` produced eight tests that failed with
900
+ // "update$ is not a function", so they asserted nothing about the
901
+ // guard they were written for.
902
+ const call =
903
+ 'list' === guardOp ? `${guardOp}$({})` :
904
+ 'update' === guardOp ?
905
+ `make$({ id: '${entIdLiteral(e, '0')}' }).save$()` :
906
+ `${guardOp}$({ id: '${entIdLiteral(e, '0')}' })`
538
907
 
539
908
  Content(`
540
909
  it('${e.name}-needs-${key}', async () => {
@@ -567,7 +936,9 @@ describe('${provider.fileBase}', () => {
567
936
  list[0].canon$({ string: true }),
568
937
  'provider/${provider.lower}/${e.name}',
569
938
  )
570
- assert.equal(list[0].${key}, '${parentSeed(e, key)}')
939
+ ${0 < idPartsOf(e).length ?
940
+ `assert.equal(list[0].id, '${entIdLiteral(e, '0')}')` :
941
+ `assert.equal(list[0].${key}, '${parentSeed(e, key)}')`}
571
942
  })
572
943
 
573
944
  `)
@@ -582,24 +953,24 @@ describe('${provider.fileBase}', () => {
582
953
  const seneca = await makeSeneca()
583
954
  const found = await seneca
584
955
  .entity('provider/${provider.lower}/${e.name}')
585
- .load$({ ${pairs}, id: '${e.name}0' })
956
+ .load$({ ${queryPairs(e, false)}id: '${entIdLiteral(e, '0')}' })
586
957
 
587
- assert.equal(found.id, '${e.name}0')
958
+ assert.equal(found.id, '${entIdLiteral(e, '0')}')
588
959
  assert.equal(
589
960
  found.canon$({ string: true }),
590
961
  'provider/${provider.lower}/${e.name}',
591
962
  )
592
963
  })
593
-
964
+ ${!loadHasKey(e) ? '' : `
594
965
 
595
966
  it('${e.name}-load-missing', async () => {
596
967
  const seneca = await makeSeneca()
597
968
  const missing = await seneca
598
969
  .entity('provider/${provider.lower}/${e.name}')
599
- .load$({ ${pairs}, id: 'nosuch${e.name}' })
970
+ .load$({ ${queryPairs(e, false)}id: '${entIdLiteral(e, '-nosuch')}' })
600
971
 
601
972
  assert.equal(missing, null)
602
- })
973
+ })`}
603
974
 
604
975
  `)
605
976
  }
@@ -612,8 +983,94 @@ describe('${provider.fileBase}', () => {
612
983
  // transport implements create/update/remove, so this needs no server.
613
984
  each(provider.entities, (e: any) => {
614
985
  if (e.cmds.includes('save') && e.cmds.includes('remove')) {
615
- Content(`
986
+ if (compositeRoundTrip(e) && removeAddresses(e) &&
987
+ !cmdRefuses(e, 'update')) {
988
+ Content(`
616
989
  ` + crudTest(provider, e, 'offline'))
990
+ }
991
+ else if (removeAddresses(e) && cmdRefuses(e, 'update')) {
992
+ Content(`
993
+ // NO ${e.name} create/update/remove round-trip: THIS API HAS NO UPDATE
994
+ // ROUTE FOR ONE ${e.name}. Its update route addresses
995
+ // \`${addressNames(e, 'update').join('\`, \`')}\`, not
996
+ // \`${0 < idPartsOf(e).length ?
997
+ idPartsOf(e).join(String(e.idsep || '/')) : e.rk}\`, so the update leg of a round-trip
998
+ // would change a different record. The cmd refuses instead — see
999
+ // ${e.name}-update-refused. Create and remove are unaffected.
1000
+
1001
+ `)
1002
+ }
1003
+ else if (!removeAddresses(e)) {
1004
+ // Said in the file rather than silently omitted: a missing test
1005
+ // that nobody can see is how a gap becomes permanent. And the
1006
+ // refusal itself IS tested, below.
1007
+ Content(`
1008
+ // NO ${e.name} create/update/remove round-trip: THIS API HAS NO REMOVE
1009
+ // ROUTE FOR ONE ${e.name}.
1010
+ //
1011
+ // The key is \`${0 < idPartsOf(e).length ?
1012
+ idPartsOf(e).join(String(e.idsep || '/')) : e.rk}\`, which the remove route does not
1013
+ // take — it addresses ${0 === e.parents.length ? 'nothing more specific' :
1014
+ '\`' + e.parents.join('\`, \`') + '\` and no further'}. So there is
1015
+ // no record for this test to remove, and the cmd refuses rather than
1016
+ // deleting whatever that route names: see ${e.name}-remove-refused.
1017
+ //
1018
+ // This befalls an entity whose ops address DIFFERENT resources, which a
1019
+ // tag-derived entity can. Reads and lists are unaffected.
1020
+
1021
+ `)
1022
+ }
1023
+ else {
1024
+ Content(`
1025
+ // NO ${e.name} create/update/remove round-trip. This API addresses a
1026
+ // ${e.name} by \`${idPartsOf(e).join(String(e.idsep || '/'))}\`, and at least
1027
+ // one of those parts is carried NESTED in a response
1028
+ // (${Object.keys(e.idfrom || {}).filter((k: string) =>
1029
+ String((e.idfrom || {})[k]).includes('.'))
1030
+ .map((k: string) => k + ' at ' + (e.idfrom || {})[k]).join(', ')}).
1031
+ //
1032
+ // Reads, lists and removes work: they address a record and never rewrite
1033
+ // it. A create or update cannot, offline — the SDK takes path parameters
1034
+ // from the same object as the request body, so the flat value the URL
1035
+ // needs displaces the nested one the response shape requires, and this
1036
+ // transport echoes a create and merges an update. Against the real API,
1037
+ // where the two are separate, the cycle is fine.
1038
+
1039
+ `)
1040
+ }
1041
+ }
1042
+ })
1043
+
1044
+
1045
+ // THE REFUSAL. A cmd whose only route addresses a different resource
1046
+ // must not send the request — `migration`'s remove would delete a
1047
+ // repository's migration archive, `user`'s a GPG key, `pull`'s a
1048
+ // review comment, with the caller's id dropped and a successful reply.
1049
+ // That is the worst possible answer, so it is refused, and refused
1050
+ // BY NAME: the message says which key the route does not take.
1051
+ each(provider.entities, (e: any) => {
1052
+ for (const cmd of ['remove', 'update']) {
1053
+ if (true !== (e.idmisaddressed || {})[cmd]) {
1054
+ continue
1055
+ }
1056
+
1057
+ // `update` is reached through save$ on an entity CARRYING an id —
1058
+ // that is what makes it an update rather than a create.
1059
+ const call = 'remove' === cmd ?
1060
+ `remove$({ ${parentPairs(e, false)}id: '${entIdLiteral(e, '0')}' })` :
1061
+ `make$({ ${parentPairs(e, false)}id: '${entIdLiteral(e, '0')}' }).save$()`
1062
+
1063
+ Content(`
1064
+ it('${e.name}-${cmd}-refused', async () => {
1065
+ const seneca = await makeSeneca()
1066
+
1067
+ await assert.rejects(
1068
+ () => seneca.entity('provider/${provider.lower}/${e.name}').${call},
1069
+ /has no ${cmd} route for one ${e.name}/,
1070
+ )
1071
+ })
1072
+
1073
+ `)
617
1074
  }
618
1075
  })
619
1076
 
@@ -683,7 +1140,8 @@ describe('${provider.fileBase}', () => {
683
1140
  // The ACTION tests below are not gated on any of this. They are what
684
1141
  // this entity does have.
685
1142
  const canPlainSave = e.cmds.includes('load') &&
686
- e.canonicalOps.includes('update')
1143
+ e.canonicalOps.includes('update') &&
1144
+ !cmdRefuses(e, 'update')
687
1145
 
688
1146
  if (0 < acts.length && e.cmds.includes('save')) {
689
1147
  const mut = canPlainSave ? mutableField(e) : ''
@@ -695,7 +1153,7 @@ describe('${provider.fileBase}', () => {
695
1153
  const seneca = await makeSeneca()
696
1154
  const ent = seneca.entity('provider/${provider.lower}/${e.name}')
697
1155
 
698
- const loaded = await ent.load$({ ${pairs}id: '${e.name}0' })
1156
+ const loaded = await ent.load$({ ${queryPairs(e, false)}id: '${entIdLiteral(e, '0')}' })
699
1157
  loaded.${mut} = 'plain-${mut}'
700
1158
  const saved = await loaded.save$()
701
1159
 
@@ -812,7 +1270,7 @@ describe('${provider.fileBase}', () => {
812
1270
  // honest way to get one.
813
1271
  each(provider.entities, (e: any) => {
814
1272
  if (e.cmds.includes('save') && e.cmds.includes('remove') &&
815
- liveParentsResolvable(provider, e)) {
1273
+ liveParentsResolvable(provider, e) && compositeRoundTrip(e)) {
816
1274
  Content(crudTest(provider, e, 'live'))
817
1275
  }
818
1276
  })
@@ -1163,8 +1621,14 @@ const Workflow = cmp(function Workflow(props: any) {
1163
1621
  File({ name: 'build.yml' }, () => {
1164
1622
  Content(`# Generated by @voxgig/sdkgen. Do not edit.
1165
1623
  #
1166
- # The ${provider.api} SDK is a normal published dependency, so \`npm install\`
1167
- # is all that is needed to build and run the offline tests on every platform.
1624
+ ${provider.sdkGit ?
1625
+ `# The ${provider.api} SDK is depended on by GIT TAG rather than taken from a\n` +
1626
+ '# registry, so `npm install` resolves the tag named in package.json and\n' +
1627
+ '# needs git on PATH — every GitHub runner has it. Nothing else is needed\n' +
1628
+ '# to build and run the offline tests on any platform.' :
1629
+ `# The ${provider.api} SDK is a normal published dependency, so \`npm install\`\n` +
1630
+ '# is all that is needed to build and run the offline tests on every\n' +
1631
+ '# platform.'}
1168
1632
  ${!provider.liveApp ? '' : `#
1169
1633
  # The live tests additionally need the companion server, which is only
1170
1634
  # distributed in the SDK's source repository (it is not published). That repo
@@ -1434,8 +1898,26 @@ await seneca.ready()
1434
1898
  `)
1435
1899
  }
1436
1900
  if (subject.cmds.includes('load')) {
1901
+ // THE FIRST RUNNABLE EXAMPLE HAS TO RUN. `load$('some-id')` passes a
1902
+ // bare id and nothing else, but the generated handler calls
1903
+ // `need_<entity>_<parent>()` on every parent key before it reaches the
1904
+ // SDK — so for any entity that has one, the README's opening example
1905
+ // threw `<entity> load: <parent> is required`. Show the object form
1906
+ // with the parent keys the handler actually enforces; the bare-string
1907
+ // form stays for a parentless entity, where it is correct and shorter.
1908
+ // A COMPOSITE KEY IS ONE STRING, not a bag of keys. Its parents travel
1909
+ // inside the id, so the object form with them alongside is the shape
1910
+ // its own handler rejects — the example has to show the joined id.
1911
+ const cparts = idPartsOf(subject)
1912
+ const loadArg = 0 < cparts.length ?
1913
+ `'${cparts.map((p: string) => 'some-' + p).join(
1914
+ null != subject.idsep && '' !== String(subject.idsep) ?
1915
+ String(subject.idsep) : '/')}'` :
1916
+ 0 === subject.parents.length ? `'some-id'` :
1917
+ `{ ` + subject.parents.map((p: string) => `${p}: 'some-${p}'`).join(', ') +
1918
+ `, id: 'some-id' }`
1437
1919
  Content(`const ${subject.name} = await seneca
1438
- .entity('provider/${provider.lower}/${subject.name}').load$('some-id')
1920
+ .entity('provider/${provider.lower}/${subject.name}').load$(${loadArg})
1439
1921
  `)
1440
1922
  }
1441
1923
  Content(`\`\`\`
@@ -2476,17 +2958,34 @@ const DocHowto = cmp(function DocHowto(props: any) {
2476
2958
 
2477
2959
  const key = (k: string) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : `'${k}'`
2478
2960
 
2479
- const literal = (rec: Record<string, any>) => {
2480
- const names = Object.keys(rec)
2481
- if (0 === names.length) {
2482
- return '{}'
2961
+ // SERIALISE, DO NOT COERCE. `String(value)` renders an object as
2962
+ // `[object Object]` and an empty array as the empty string, so a field of
2963
+ // either kind turned the documented create recipe into a syntax error
2964
+ // (`code_of_conduct: [object Object]`, `labels: ,`). Every value a seed
2965
+ // record can hold — string, number, boolean, array, plain object — now
2966
+ // emits as the JS literal it claims to be, recursively, so a reader can
2967
+ // copy the block and run it.
2968
+ const jsval = (v: any): string => {
2969
+ if (null === v || undefined === v) {
2970
+ return 'null'
2483
2971
  }
2484
- return '{ ' + names
2485
- .map((k) => `${key(k)}: ` +
2486
- ('string' === typeof rec[k] ? `'${rec[k]}'` : String(rec[k])))
2487
- .join(', ') + ' }'
2972
+ if ('string' === typeof v) {
2973
+ // Escape what would otherwise end the literal early.
2974
+ return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`
2975
+ }
2976
+ if ('number' === typeof v || 'boolean' === typeof v) {
2977
+ return String(v)
2978
+ }
2979
+ if (Array.isArray(v)) {
2980
+ return 0 === v.length ? '[]' : '[' + v.map(jsval).join(', ') + ']'
2981
+ }
2982
+ const ks = Object.keys(v)
2983
+ return 0 === ks.length ? '{}' :
2984
+ '{ ' + ks.map((k) => `${key(k)}: ${jsval(v[k])}`).join(', ') + ' }'
2488
2985
  }
2489
2986
 
2987
+ const literal = (rec: Record<string, any>) => jsval(rec)
2988
+
2490
2989
  // What a create sends: the seeded record without its id, because the id is
2491
2990
  // the API's to assign. Parent keys stay — a nested write carries them in
2492
2991
  // the data rather than the query.