@voxgig/sdkgen-infrapack 0.0.3 → 0.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -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(
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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\$({ ${
|
|
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\$({ ${
|
|
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
|
-
|
|
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 (
|
|
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
|
|
789
|
+
.load$('${entIdLiteral(e, '0')}')
|
|
484
790
|
|
|
485
|
-
assert.equal(found.${e.idf || 'id'}, '${e
|
|
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
|
|
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$('
|
|
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
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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
|
-
|
|
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$({ ${
|
|
956
|
+
.load$({ ${queryPairs(e, false)}id: '${entIdLiteral(e, '0')}' })
|
|
586
957
|
|
|
587
|
-
assert.equal(found.id, '${e
|
|
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$({ ${
|
|
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
|
-
|
|
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$({ ${
|
|
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
|
})
|
|
@@ -1434,8 +1892,26 @@ await seneca.ready()
|
|
|
1434
1892
|
`)
|
|
1435
1893
|
}
|
|
1436
1894
|
if (subject.cmds.includes('load')) {
|
|
1895
|
+
// THE FIRST RUNNABLE EXAMPLE HAS TO RUN. `load$('some-id')` passes a
|
|
1896
|
+
// bare id and nothing else, but the generated handler calls
|
|
1897
|
+
// `need_<entity>_<parent>()` on every parent key before it reaches the
|
|
1898
|
+
// SDK — so for any entity that has one, the README's opening example
|
|
1899
|
+
// threw `<entity> load: <parent> is required`. Show the object form
|
|
1900
|
+
// with the parent keys the handler actually enforces; the bare-string
|
|
1901
|
+
// form stays for a parentless entity, where it is correct and shorter.
|
|
1902
|
+
// A COMPOSITE KEY IS ONE STRING, not a bag of keys. Its parents travel
|
|
1903
|
+
// inside the id, so the object form with them alongside is the shape
|
|
1904
|
+
// its own handler rejects — the example has to show the joined id.
|
|
1905
|
+
const cparts = idPartsOf(subject)
|
|
1906
|
+
const loadArg = 0 < cparts.length ?
|
|
1907
|
+
`'${cparts.map((p: string) => 'some-' + p).join(
|
|
1908
|
+
null != subject.idsep && '' !== String(subject.idsep) ?
|
|
1909
|
+
String(subject.idsep) : '/')}'` :
|
|
1910
|
+
0 === subject.parents.length ? `'some-id'` :
|
|
1911
|
+
`{ ` + subject.parents.map((p: string) => `${p}: 'some-${p}'`).join(', ') +
|
|
1912
|
+
`, id: 'some-id' }`
|
|
1437
1913
|
Content(`const ${subject.name} = await seneca
|
|
1438
|
-
.entity('provider/${provider.lower}/${subject.name}').load$(
|
|
1914
|
+
.entity('provider/${provider.lower}/${subject.name}').load$(${loadArg})
|
|
1439
1915
|
`)
|
|
1440
1916
|
}
|
|
1441
1917
|
Content(`\`\`\`
|
|
@@ -2476,17 +2952,34 @@ const DocHowto = cmp(function DocHowto(props: any) {
|
|
|
2476
2952
|
|
|
2477
2953
|
const key = (k: string) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(k) ? k : `'${k}'`
|
|
2478
2954
|
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2955
|
+
// SERIALISE, DO NOT COERCE. `String(value)` renders an object as
|
|
2956
|
+
// `[object Object]` and an empty array as the empty string, so a field of
|
|
2957
|
+
// either kind turned the documented create recipe into a syntax error
|
|
2958
|
+
// (`code_of_conduct: [object Object]`, `labels: ,`). Every value a seed
|
|
2959
|
+
// record can hold — string, number, boolean, array, plain object — now
|
|
2960
|
+
// emits as the JS literal it claims to be, recursively, so a reader can
|
|
2961
|
+
// copy the block and run it.
|
|
2962
|
+
const jsval = (v: any): string => {
|
|
2963
|
+
if (null === v || undefined === v) {
|
|
2964
|
+
return 'null'
|
|
2483
2965
|
}
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2966
|
+
if ('string' === typeof v) {
|
|
2967
|
+
// Escape what would otherwise end the literal early.
|
|
2968
|
+
return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n')}'`
|
|
2969
|
+
}
|
|
2970
|
+
if ('number' === typeof v || 'boolean' === typeof v) {
|
|
2971
|
+
return String(v)
|
|
2972
|
+
}
|
|
2973
|
+
if (Array.isArray(v)) {
|
|
2974
|
+
return 0 === v.length ? '[]' : '[' + v.map(jsval).join(', ') + ']'
|
|
2975
|
+
}
|
|
2976
|
+
const ks = Object.keys(v)
|
|
2977
|
+
return 0 === ks.length ? '{}' :
|
|
2978
|
+
'{ ' + ks.map((k) => `${key(k)}: ${jsval(v[k])}`).join(', ') + ' }'
|
|
2488
2979
|
}
|
|
2489
2980
|
|
|
2981
|
+
const literal = (rec: Record<string, any>) => jsval(rec)
|
|
2982
|
+
|
|
2490
2983
|
// What a create sends: the seeded record without its id, because the id is
|
|
2491
2984
|
// the API's to assign. Parent keys stay — a nested write carries them in
|
|
2492
2985
|
// the data rather than the query.
|