@voxgig/sdkgen 4.4.1 → 4.5.0

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.
Files changed (97) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/cmp/FeatureDocs.d.ts +16 -0
  3. package/dist/cmp/FeatureDocs.js +72 -0
  4. package/dist/cmp/FeatureDocs.js.map +1 -0
  5. package/dist/cmp/Readme.js +2 -0
  6. package/dist/cmp/Readme.js.map +1 -1
  7. package/dist/cmp/ReadmeFeatures.d.ts +2 -0
  8. package/dist/cmp/ReadmeFeatures.js +95 -0
  9. package/dist/cmp/ReadmeFeatures.js.map +1 -0
  10. package/dist/cmp/ReadmeRef.js +4 -0
  11. package/dist/cmp/ReadmeRef.js.map +1 -1
  12. package/dist/cmp/ReadmeRefFeatures.d.ts +2 -0
  13. package/dist/cmp/ReadmeRefFeatures.js +130 -0
  14. package/dist/cmp/ReadmeRefFeatures.js.map +1 -0
  15. package/dist/cmp/ReadmeTop.js +46 -0
  16. package/dist/cmp/ReadmeTop.js.map +1 -1
  17. package/dist/helpers/opShape.js +5 -1
  18. package/dist/helpers/opShape.js.map +1 -1
  19. package/dist/sdkgen.d.ts +3 -2
  20. package/dist/sdkgen.js +30 -10
  21. package/dist/sdkgen.js.map +1 -1
  22. package/dist/tsconfig.tsbuildinfo +1 -1
  23. package/dist/utility.d.ts +2 -1
  24. package/dist/utility.js +17 -1
  25. package/dist/utility.js.map +1 -1
  26. package/package.json +1 -1
  27. package/project/.sdk/src/cmp/c/ReadmeRef_c.ts +5 -0
  28. package/project/.sdk/src/cmp/clojure/ReadmeRef_clojure.ts +5 -0
  29. package/project/.sdk/src/cmp/cpp/ReadmeRef_cpp.ts +5 -0
  30. package/project/.sdk/src/cmp/csharp/ReadmeRef_csharp.ts +5 -0
  31. package/project/.sdk/src/cmp/dart/ReadmeRef_dart.ts +5 -0
  32. package/project/.sdk/src/cmp/elixir/ReadmeRef_elixir.ts +5 -0
  33. package/project/.sdk/src/cmp/go/ReadmeRef_go.ts +5 -0
  34. package/project/.sdk/src/cmp/go/TestDirect_go.ts +123 -2
  35. package/project/.sdk/src/cmp/java/ReadmeRef_java.ts +5 -0
  36. package/project/.sdk/src/cmp/js/ReadmeRef_js.ts +5 -0
  37. package/project/.sdk/src/cmp/js/TestDirect_js.ts +62 -0
  38. package/project/.sdk/src/cmp/kotlin/ReadmeRef_kotlin.ts +5 -0
  39. package/project/.sdk/src/cmp/lean/ReadmeRef_lean.ts +27 -3
  40. package/project/.sdk/src/cmp/lua/ReadmeRef_lua.ts +5 -0
  41. package/project/.sdk/src/cmp/ocaml/ReadmeRef_ocaml.ts +5 -0
  42. package/project/.sdk/src/cmp/perl/ReadmeRef_perl.ts +5 -0
  43. package/project/.sdk/src/cmp/php/ReadmeRef_php.ts +5 -0
  44. package/project/.sdk/src/cmp/py/ReadmeRef_py.ts +5 -0
  45. package/project/.sdk/src/cmp/rb/ReadmeRef_rb.ts +5 -0
  46. package/project/.sdk/src/cmp/rust/ReadmeRef_rust.ts +5 -0
  47. package/project/.sdk/src/cmp/scala/ReadmeRef_scala.ts +5 -0
  48. package/project/.sdk/src/cmp/seneca-provider/Extras_seneca-provider.ts +19 -1
  49. package/project/.sdk/src/cmp/seneca-provider/Main_seneca-provider.ts +27 -2
  50. package/project/.sdk/src/cmp/swift/ReadmeRef_swift.ts +5 -0
  51. package/project/.sdk/src/cmp/ts/Config_ts.ts +4 -1
  52. package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +9 -4
  53. package/project/.sdk/src/cmp/ts/ReadmeModel_ts.ts +8 -3
  54. package/project/.sdk/src/cmp/ts/ReadmeQuick_ts.ts +3 -2
  55. package/project/.sdk/src/cmp/ts/ReadmeRef_ts.ts +7 -2
  56. package/project/.sdk/src/cmp/ts/ReadmeTopQuick_ts.ts +3 -2
  57. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +5 -2
  58. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +5 -2
  59. package/project/.sdk/src/cmp/zig/ReadmeRef_zig.ts +5 -0
  60. package/project/.sdk/tm/clojure/src/sdk/features.clj +175 -0
  61. package/project/.sdk/tm/dart/test/feature_test.dart +54 -0
  62. package/project/.sdk/tm/go/feature/test_feature.go +17 -6
  63. package/project/.sdk/tm/go/test/feature_corpus_test.go +10 -3
  64. package/project/.sdk/tm/java/test/FeatureCorpusTest.java +5 -4
  65. package/project/.sdk/tm/js/src/feature/test/TestFeature.js +8 -1
  66. package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +3 -1
  67. package/project/.sdk/tm/js/test/feature/Corpus.test.js +13 -3
  68. package/project/.sdk/tm/js/test/feature.test.js +19 -9
  69. package/project/.sdk/tm/lean/src/SdkFeatures.lean +186 -1
  70. package/project/.sdk/tm/lua/feature/test_feature.lua +21 -7
  71. package/project/.sdk/tm/lua/test/feature_test.lua +18 -18
  72. package/project/.sdk/tm/lua/utility/make_options.lua +2 -0
  73. package/project/.sdk/tm/ocaml/sdk_features.ml +5 -0
  74. package/project/.sdk/tm/perl/t/feature_corpus.t +5 -4
  75. package/project/.sdk/tm/php/feature/TestFeature.php +19 -8
  76. package/project/.sdk/tm/php/test/FeatureCorpusTest.php +7 -4
  77. package/project/.sdk/tm/php/utility/MakeOptions.php +2 -1
  78. package/project/.sdk/tm/py/pkg/feature/test_feature.py +15 -4
  79. package/project/.sdk/tm/py/pkg/utility/make_options.py +2 -0
  80. package/project/.sdk/tm/py/test/test_feature.py +40 -0
  81. package/project/.sdk/tm/py/test/test_feature_corpus.py +14 -3
  82. package/project/.sdk/tm/rb/test/feature_corpus_test.rb +12 -4
  83. package/project/.sdk/tm/ts/src/feature/test/TestFeature.ts +15 -1
  84. package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +3 -1
  85. package/project/.sdk/tm/ts/src/utility/PrepareAuthUtility.ts +20 -0
  86. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +13 -3
  87. package/project/.sdk/tm/ts/test/feature.test.ts +19 -9
  88. package/project/sdkgen-package.json +1 -1
  89. package/src/cmp/FeatureDocs.ts +100 -0
  90. package/src/cmp/Readme.ts +2 -0
  91. package/src/cmp/ReadmeFeatures.ts +113 -0
  92. package/src/cmp/ReadmeRef.ts +4 -0
  93. package/src/cmp/ReadmeRefFeatures.ts +149 -0
  94. package/src/cmp/ReadmeTop.ts +48 -0
  95. package/src/helpers/opShape.ts +5 -1
  96. package/src/sdkgen.ts +28 -7
  97. package/src/utility.ts +21 -1
@@ -13,7 +13,7 @@ import (
13
13
 
14
14
  // The `body.<key>` form of an op's response transform: the mock wraps its
15
15
  // payload in <key> so the transform can unwrap it again.
16
- var envelopeResRe = regexp.MustCompile("^`body\\.([^`.]+)`$")
16
+ var envelopeResRe = regexp.MustCompile("^`body\\.(.+)`$")
17
17
 
18
18
  type TestFeature struct {
19
19
  BaseFeature
@@ -78,7 +78,14 @@ func (f *TestFeature) Init(ctx *core.Context, options map[string]any) {
78
78
  if m == nil {
79
79
  return data
80
80
  }
81
- return map[string]any{m[1]: data}
81
+ // Multi-segment on purpose: GraphQL ops unwrap body.data.<field>
82
+ // (and body.data.<field>.<entity> for mutations), not just one level.
83
+ segs := strings.Split(m[1], ".")
84
+ out := data
85
+ for i := len(segs) - 1; 0 <= i; i-- {
86
+ out = map[string]any{segs[i]: out}
87
+ }
88
+ return out
82
89
  }
83
90
 
84
91
  respond := func(status int, data any, extra map[string]any) map[string]any {
@@ -346,10 +353,14 @@ func (f *TestFeature) buildArgs(ctx *core.Context, op *core.Operation, args map[
346
353
  }
347
354
  }
348
355
 
349
- // Get required params.
356
+ // Path AND query: a path-only read misses a query-addressed record
357
+ // (e.g. GET /result?trace_id=), which has no path param at all.
350
358
  paramsPath := vs.GetPath([]any{"args", "params"}, point)
351
359
  reqdParams := vs.Select(paramsPath, map[string]any{"reqd": true})
352
- reqd := vs.Transform(reqdParams, []any{"`$EACH`", "", "`$KEY.name`"})
360
+ reqdFromParams := vs.Transform(reqdParams, []any{"`$EACH`", "", "`$KEY.name`"})
361
+ queryPath := vs.GetPath([]any{"args", "query"}, point)
362
+ reqdQuery := vs.Select(queryPath, map[string]any{"reqd": true})
363
+ reqdFromQuery := vs.Transform(reqdQuery, []any{"`$EACH`", "", "`$KEY.name`"})
353
364
 
354
365
  qand := []any{}
355
366
  q := map[string]any{"`$AND`": &qand}
@@ -357,8 +368,8 @@ func (f *TestFeature) buildArgs(ctx *core.Context, op *core.Operation, args map[
357
368
  if args != nil {
358
369
  for _, key := range vs.KeysOf(args) {
359
370
  isId := key == "id"
360
- selected := vs.Select(reqd, key)
361
- isReqd := !vs.IsEmpty(selected)
371
+ isReqd := !vs.IsEmpty(vs.Select(reqdFromParams, key)) ||
372
+ !vs.IsEmpty(vs.Select(reqdFromQuery, key))
362
373
 
363
374
  if isId || isReqd {
364
375
  v := ctx.Utility.Param(ctx, key)
@@ -27,9 +27,10 @@ import (
27
27
  sdk "GOMODULE"
28
28
  )
29
29
 
30
- // Features with a corpus section. A name here with no section is a skip, not
31
- // a failure: an SDK generated without the feature has nothing to run.
32
- var featureCorpusNames = []string{"cost"}
30
+ // Features with a corpus section are read from the corpus itself (see
31
+ // TestFeatureCorpus), so a project-authored section a custom feature
32
+ // added under .sdk/test/feature/ — runs without editing this file. An
33
+ // SDK generated without a listed feature still skips, not fails.
33
34
 
34
35
  // The standard operation names, in the order the runner prefers them. Every
35
36
  // entity declares every CRUD method, so an op that the API does not define
@@ -450,6 +451,12 @@ func TestFeatureCorpus(t *testing.T) {
450
451
  "corpus cannot exercise a feature without one")
451
452
  }
452
453
 
454
+ featureCorpusNames := make([]string, 0, len(featureSection))
455
+ for name := range featureSection {
456
+ featureCorpusNames = append(featureCorpusNames, name)
457
+ }
458
+ sort.Strings(featureCorpusNames)
459
+
453
460
  for _, name := range featureCorpusNames {
454
461
  name := name
455
462
  t.Run(name, func(t *testing.T) {
@@ -43,9 +43,10 @@ import JAVAPACKAGE.core.Utility;
43
43
  @SuppressWarnings({"unchecked"})
44
44
  public class FeatureCorpusTest {
45
45
 
46
- // Features with a corpus section. A name here with no section is a skip,
47
- // not a failure: an SDK generated without the feature has nothing to run.
48
- private static final List<String> FEATURE_CORPUS_NAMES = List.of("cost");
46
+ // Features with a corpus section are read from the corpus itself (see
47
+ // featureCorpus), so a project-authored section - a custom feature added
48
+ // under .sdk/test/feature/ - runs without editing this file. An SDK
49
+ // generated without a listed feature still skips, not fails.
49
50
 
50
51
  // The standard operation names, in the order the runner prefers them.
51
52
  private static final List<String> FEATURE_CORPUS_OPS =
@@ -377,7 +378,7 @@ public class FeatureCorpusTest {
377
378
  "this project's test.json has no `feature` section - recompile the "
378
379
  + "corpus (create-sdkgen .sdk/test/feature/) to run these cases");
379
380
 
380
- for (String name : FEATURE_CORPUS_NAMES) {
381
+ for (String name : new TreeMap<>(features).keySet()) {
381
382
  Object sectionRaw = features.get(name);
382
383
  if (!(sectionRaw instanceof Map)) {
383
384
  continue;
@@ -274,10 +274,17 @@ class TestFeature extends BaseFeature {
274
274
  }
275
275
  }
276
276
 
277
- const reqd = transform(
277
+ // Path AND query: a path-only read misses a query-addressed record
278
+ // (e.g. GET /result?trace_id=), which has no path param at all.
279
+ const reqdParams = transform(
278
280
  select(getpath(point, ['args', 'params']), { reqd: true }),
279
281
  ['`$EACH`', '', '`$KEY.name`']
280
282
  )
283
+ const reqdQuery = transform(
284
+ select(getpath(point, ['args', 'query']), { reqd: true }),
285
+ ['`$EACH`', '', '`$KEY.name`']
286
+ )
287
+ const reqd = [...(reqdParams || []), ...(reqdQuery || [])]
281
288
 
282
289
  const qand = []
283
290
  const q = { '`$AND`': qand }
@@ -41,11 +41,13 @@ function makeOptions(ctx) {
41
41
  // Standard SDK option values.
42
42
  const optspec = {
43
43
  apikey: '',
44
+ secret: '',
44
45
  base: 'http://localhost:8000',
45
46
  prefix: '',
46
47
  suffix: '',
47
48
  auth: {
48
- prefix: ''
49
+ prefix: '',
50
+ basic: false
49
51
  },
50
52
  headers: {
51
53
  '`$CHILD`': '`$STRING`'
@@ -27,9 +27,19 @@ const { join } = require('node:path')
27
27
  const { SDK, TEST_JSON_FILE } = require('../utility/index')
28
28
 
29
29
 
30
- // Features with a corpus section. A name here with no section is a skip, not
31
- // a failure: an SDK generated without the feature has nothing to run.
32
- const FEATURES = ['cost']
30
+ // Features with a corpus section read from the corpus itself, so a
31
+ // project-authored section (a custom feature added under
32
+ // .sdk/test/feature/) runs without editing this file. Read eagerly: the
33
+ // node test runner collects the per-feature tests at describe time,
34
+ // before any `before` hook fires. An SDK generated without a listed
35
+ // feature still skips, not fails.
36
+ const FEATURES = Object.keys((() => {
37
+ try {
38
+ return JSON.parse(readFileSync(
39
+ join(__dirname, '..', TEST_JSON_FILE), 'utf8')).feature || {}
40
+ }
41
+ catch (e) { return {} }
42
+ })()).sort()
33
43
 
34
44
 
35
45
  // One operation this SDK can actually perform is described by
@@ -21,6 +21,16 @@ function recordingServer(reply) {
21
21
  }
22
22
 
23
23
 
24
+ // A subtest that drives a second feature (netsim as the simulated network)
25
+ // can only run when this SDK was generated with it: the harness skips
26
+ // absent features, which would leave the scenario unsimulated and the
27
+ // assertions meaningless. Same convention as the other targets.
28
+ function skipWithout(name) {
29
+ return hasFeature(name) ? false
30
+ : ('this SDK was generated without the ' + name + ' feature')
31
+ }
32
+
33
+
24
34
  describe('feature', () => {
25
35
 
26
36
  test('at least the test feature is present', () => {
@@ -95,7 +105,7 @@ describe('feature', () => {
95
105
  // --- retry ----------------------------------------------------------------
96
106
  if (hasFeature('retry')) describe('retry', () => {
97
107
 
98
- test('retries transient failures then succeeds', async () => {
108
+ test('retries transient failures then succeeds', { skip: skipWithout('netsim') }, async () => {
99
109
  const clock = makeClock()
100
110
  const h = makeClient({ features: [
101
111
  { name: 'netsim', options: { failTimes: 2, failStatus: 503 } },
@@ -105,7 +115,7 @@ describe('feature', () => {
105
115
  strictEqual(h.client._retry.attempts, 2)
106
116
  })
107
117
 
108
- test('gives up after the budget', async () => {
118
+ test('gives up after the budget', { skip: skipWithout('netsim') }, async () => {
109
119
  const clock = makeClock()
110
120
  const h = makeClient({ features: [
111
121
  { name: 'netsim', options: { failTimes: 9, failStatus: 500 } },
@@ -131,7 +141,7 @@ describe('feature', () => {
131
141
  strictEqual(n, 3)
132
142
  })
133
143
 
134
- test('honours a server Retry-After', async () => {
144
+ test('honours a server Retry-After', { skip: skipWithout('netsim') }, async () => {
135
145
  const clock = makeClock()
136
146
  const h = makeClient({ features: [
137
147
  { name: 'netsim', options: { rateLimitTimes: 1, retryAfter: 2 } },
@@ -141,7 +151,7 @@ describe('feature', () => {
141
151
  strictEqual(clock.time, 2000)
142
152
  })
143
153
 
144
- test('default jitter path still succeeds', async () => {
154
+ test('default jitter path still succeeds', { skip: skipWithout('netsim') }, async () => {
145
155
  const h = makeClient({ features: [
146
156
  { name: 'netsim', options: { failTimes: 1 } },
147
157
  { name: 'retry', options: { retries: 2, minDelay: 0 } },
@@ -154,7 +164,7 @@ describe('feature', () => {
154
164
  // --- timeout --------------------------------------------------------------
155
165
  if (hasFeature('timeout')) describe('timeout', () => {
156
166
 
157
- test('a slow request times out', async () => {
167
+ test('a slow request times out', { skip: skipWithout('netsim') }, async () => {
158
168
  const h = makeClient({ features: [
159
169
  { name: 'netsim', options: { latency: 80 } },
160
170
  { name: 'timeout', options: { ms: 10 } },
@@ -319,7 +329,7 @@ describe('feature', () => {
319
329
  // --- metrics --------------------------------------------------------------
320
330
  if (hasFeature('metrics')) describe('metrics', () => {
321
331
 
322
- test('counts ok and err per op', async () => {
332
+ test('counts ok and err per op', { skip: skipWithout('netsim') }, async () => {
323
333
  const h = makeClient({ features: [
324
334
  { name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
325
335
  { name: 'metrics', options: {} },
@@ -352,7 +362,7 @@ describe('feature', () => {
352
362
  ok(/^00-.+-.+-01$/.test(sent['traceparent']))
353
363
  })
354
364
 
355
- test('records a failed span on error', async () => {
365
+ test('records a failed span on error', { skip: skipWithout('netsim') }, async () => {
356
366
  const h = makeClient({ features: [
357
367
  { name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
358
368
  { name: 'telemetry', options: {} },
@@ -377,7 +387,7 @@ describe('feature', () => {
377
387
  strictEqual(seen[0].headers.authorization, '<redacted>')
378
388
  })
379
389
 
380
- test('captures failures', async () => {
390
+ test('captures failures', { skip: skipWithout('netsim') }, async () => {
381
391
  const h = makeClient({ features: [
382
392
  { name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
383
393
  { name: 'debug', options: {} },
@@ -391,7 +401,7 @@ describe('feature', () => {
391
401
  // --- audit ----------------------------------------------------------------
392
402
  if (hasFeature('audit')) describe('audit', () => {
393
403
 
394
- test('one record per op with sink + actor', async () => {
404
+ test('one record per op with sink + actor', { skip: skipWithout('netsim') }, async () => {
395
405
  const sink = []
396
406
  const h = makeClient({ features: [
397
407
  { name: 'netsim', options: { failTimes: 1, failStatus: 500 } },
@@ -479,6 +479,190 @@ def pagingFeature : SIO Feature := do
479
479
  (newMap #[("pages", .num 0.0), ("items", .num 0.0)])
480
480
  bumpNum b "items" n.toFloat }
481
481
 
482
+ -- ---------------------------------------------------------------------------
483
+ -- cost
484
+ -- ---------------------------------------------------------------------------
485
+ -- Prices every transport ATTEMPT and commits the spend once per OPERATION.
486
+ -- Mirrors tm/ts/src/feature/cost/CostFeature.ts.
487
+ --
488
+ -- ORDER MATTERS. Cost must sit INSIDE the cache, or a response served from
489
+ -- cache is charged for money that was never spent.
490
+
491
+ def costFeature : SIO Feature := do
492
+ let optsR ← IO.mkRef (← emptyMap)
493
+ -- Accumulated per call and committed once: adding each attempt to the
494
+ -- running total and subtracting it again loses precision.
495
+ let pendR ← IO.mkRef (← newMap #[("attempts", Value.num 0.0), ("amount", Value.num 0.0),
496
+ ("reported", Value.num 0.0), ("estimated", Value.num 0.0),
497
+ ("source", Value.str "none")])
498
+ let seqR ← IO.mkRef 0
499
+
500
+ let record : Value → SIO Value := fun ctx => do
501
+ let o ← optsR.get
502
+ let cur ← optStr o "currency" "USD"
503
+ let lim ← optNum o "budget" 0.0
504
+ let total ← newMap #[("calls", Value.num 0.0), ("attempts", Value.num 0.0),
505
+ ("amount", Value.num 0.0), ("reported", Value.num 0.0),
506
+ ("estimated", Value.num 0.0)]
507
+ let ops ← emptyMap
508
+ let actors ← emptyMap
509
+ let budget ← newMap #[("limit", Value.num lim), ("spent", Value.num 0.0),
510
+ ("remaining", Value.num lim), ("exceeded", Value.bool false)]
511
+ trackCtx ctx "cost" (newMap #[("currency", Value.str cur), ("total", total),
512
+ ("ops", ops), ("actors", actors), ("budget", budget),
513
+ ("last", Value.noval)])
514
+
515
+ let resetPending : SIO Unit := do
516
+ let p ← newMap #[("attempts", Value.num 0.0), ("amount", Value.num 0.0),
517
+ ("reported", Value.num 0.0), ("estimated", Value.num 0.0),
518
+ ("source", Value.str "none")]
519
+ pendR.set p
520
+
521
+ -- Pricing precedence: a server-stated header beats the rate table, which
522
+ -- beats the flat unit.
523
+ let priceOf : Value → Value → SIO (Float × String) := fun ctx res => do
524
+ let o ← optsR.get
525
+ let perUnit ← optNum o "perUnit" 0.0
526
+ let hname ← optStr o "header" ""
527
+ let hv ← if hname == "" then pure Value.noval else headerCI (← gp res "headers") hname
528
+ let hn := numOf hv (-1.0)
529
+ if hname != "" && hn >= 0.0 then
530
+ pure (hn * perUnit, "header")
531
+ else do
532
+ let rates ← gp o "rates"
533
+ let opname ← SdkUtility.opnameOf ctx
534
+ -- The documented grammar is '<entity>.<op>', then '<op>', then '*'.
535
+ -- Checking only the bare name charged `{'widget.load': 0.25, '*': 0.01}`
536
+ -- a widget load at 0.01, unlike every other port.
537
+ let ent ← gpS (← gp ctx "op") "entity"
538
+ let byEntOp := numOf (← gp rates (ent ++ "." ++ opname)) (-1.0)
539
+ let byOp := numOf (← gp rates opname) (-1.0)
540
+ let byAny := numOf (← gp rates "*") (-1.0)
541
+ if byEntOp >= 0.0 then pure (byEntOp, "table")
542
+ else if byOp >= 0.0 then pure (byOp, "table")
543
+ else if byAny >= 0.0 then pure (byAny, "table")
544
+ else do
545
+ let unit ← optNum o "unit" 0.0
546
+ if unit != 0.0 then pure (unit, "unit") else pure (0.0, "none")
547
+
548
+ let bucketOf : Value → String → SIO Value := fun bucket key => do
549
+ let b ← gp bucket key
550
+ match b with
551
+ | Value.map _ => pure b
552
+ | _ => do
553
+ let m ← newMap #[("calls", Value.num 0.0), ("amount", Value.num 0.0)]
554
+ sp bucket key m
555
+ pure m
556
+
557
+ let commit : Value → SIO Unit := fun ctx => do
558
+ let o ← optsR.get
559
+ let p ← pendR.get
560
+ let rec_ ← record ctx
561
+
562
+ -- A body usage figure prices the WHOLE call, so it replaces the
563
+ -- per-attempt estimate rather than adding to it - and, being
564
+ -- server-stated, the whole amount counts as reported. Read here, not at
565
+ -- the transport seam, because the body is one-shot.
566
+ let path ← optStr o "path" ""
567
+ let perUnit ← optNum o "perUnit" 0.0
568
+ let bodyAmt ← if path == "" then pure (-1.0) else do
569
+ let body ← gp (← gp ctx "result") "body"
570
+ pure (numOf (← getpath body (Value.str path)) (-1.0))
571
+
572
+ -- Bind the pending figures first: `←` cannot be nested inside an
573
+ -- if-then-else that is not itself a `do` block.
574
+ let pendAmount := numOf (← gp p "amount") 0.0
575
+ let pendReported := numOf (← gp p "reported") 0.0
576
+ let pendEstimated := numOf (← gp p "estimated") 0.0
577
+ let amount := if bodyAmt >= 0.0 then bodyAmt * perUnit else pendAmount
578
+ let reported := if bodyAmt >= 0.0 then amount else pendReported
579
+ let estimated := if bodyAmt >= 0.0 then 0.0 else pendEstimated
580
+ if bodyAmt >= 0.0 then sp p "source" (Value.str "body")
581
+
582
+ let total ← gp rec_ "total"
583
+ bumpNum total "amount" amount
584
+ bumpNum total "reported" reported
585
+ bumpNum total "estimated" estimated
586
+ bumpNum total "calls" 1.0
587
+
588
+ let budget ← gp rec_ "budget"
589
+ let lim := numOf (← gp budget "limit") 0.0
590
+ let spent := numOf (← gp total "amount") 0.0
591
+ sp budget "spent" (Value.num spent)
592
+ sp budget "remaining"
593
+ (Value.num (if lim > 0.0 then (if lim - spent > 0.0 then lim - spent else 0.0) else 0.0))
594
+ if lim > 0.0 && spent >= lim then sp budget "exceeded" (Value.bool true)
595
+
596
+ let opname ← SdkUtility.opnameOf ctx
597
+ let actorV ← gp (← gp ctx "ctrl") "actor"
598
+ let actorS := match actorV with | Value.str a => a | _ => ""
599
+ let actor ← if actorS != "" then pure actorS else optStr o "actor" "anonymous"
600
+
601
+ let opB ← bucketOf (← gp rec_ "ops") ("_." ++ opname)
602
+ bumpNum opB "calls" 1.0
603
+ bumpNum opB "amount" amount
604
+ let acB ← bucketOf (← gp rec_ "actors") actor
605
+ bumpNum acB "calls" 1.0
606
+ bumpNum acB "amount" amount
607
+
608
+ let n ← seqR.modifyGet fun n => (n + 1, n + 1)
609
+ let last ← newMap #[("seq", Value.num n.toFloat), ("op", Value.str opname),
610
+ ("actor", Value.str actor), ("amount", Value.num amount),
611
+ ("currency", ← gp rec_ "currency"), ("source", ← gp p "source"),
612
+ ("attempts", ← gp p "attempts")]
613
+ sp rec_ "last" last
614
+ resetPending
615
+
616
+ pure { name := "cost"
617
+ , init := fun ctx opts => do
618
+ let om ← toOptsMap opts
619
+ optsR.set om
620
+ resetPending
621
+ if (← optActive opts) then do
622
+ let _ ← record ctx
623
+ let client ← clientOf ctx
624
+ let inner ← getFetcher client
625
+ setFetcher client fun c u f => do
626
+ -- The transport answers (response, error): a rejecting attempt
627
+ -- still costs, or a run of failures under retry would be free.
628
+ let (res, rerr) ← inner c u f
629
+ let (amount, source) ← priceOf c res
630
+ let p ← pendR.get
631
+ bumpNum p "attempts" 1.0
632
+ bumpNum p "amount" amount
633
+ bumpNum p (if source == "header" then "reported" else "estimated") amount
634
+ sp p "source" (Value.str source)
635
+ let rec_ ← record c
636
+ bumpNum (← gp rec_ "total") "attempts" 1.0
637
+ pure (res, rerr)
638
+ , hook := fun stage ctx => do
639
+ let o ← optsR.get
640
+ if stage == "PrePoint" then do
641
+ let lim ← optNum o "budget" 0.0
642
+ if lim > 0.0 then do
643
+ let rec_ ← record ctx
644
+ let spent := numOf (← gp (← gp rec_ "total") "amount") 0.0
645
+ if spent >= lim then do
646
+ sp (← gp rec_ "budget") "exceeded" (Value.bool true)
647
+ if (← optStr o "onBudget" "warn") == "deny" then do
648
+ let e ← SdkUtility.mkErr "cost_budget"
649
+ ("Cost budget of " ++ toString lim ++ " is spent")
650
+ let out ← SdkUtility.gpMap ctx "out"
651
+ sp out "point" e
652
+ else if stage == "PreDone" then do
653
+ -- A cache hit is a real call that cost nothing, so it commits
654
+ -- too: that is the point of ordering cost inside the cache.
655
+ commit ctx
656
+ else if stage == "PreUnexpected" then do
657
+ -- A failed operation never reaches PreDone, so without this its
658
+ -- attempts are priced and then discarded: repeated connection
659
+ -- failures would slip past an onBudget "deny" ceiling, and the
660
+ -- shared pending value would survive to be attributed to the
661
+ -- next successful call. A call that made NO attempt was refused
662
+ -- before the network and must not be counted.
663
+ let p ← pendR.get
664
+ if numOf (← gp p "attempts") 0.0 > 0.0 then commit ctx else resetPending }
665
+
482
666
  def streamingFeature : SIO Feature := do
483
667
  let optsR ← IO.mkRef (← emptyMap)
484
668
  pure { name := "streaming"
@@ -591,12 +775,13 @@ def makeFeature (name : String) : SIO Feature :=
591
775
  | "streaming" => streamingFeature
592
776
  | "proxy" => proxyFeature
593
777
  | "netsim" => netsimFeature
778
+ | "cost" => costFeature
594
779
  | _ => baseFeature
595
780
 
596
781
  /-- The catalog order: transport wrappers compose in this order. -/
597
782
  def featureNames : Array String :=
598
783
  #["log", "rbac", "idempotency", "clienttrack", "paging", "streaming",
599
784
  "metrics", "telemetry", "debug", "audit",
600
- "cache", "ratelimit", "timeout", "retry", "proxy", "netsim"]
785
+ "cost", "cache", "ratelimit", "timeout", "retry", "proxy", "netsim"]
601
786
 
602
787
  end SdkFeatures
@@ -61,11 +61,21 @@ function TestFeature:init(ctx, options)
61
61
  if type(restf) ~= "string" then
62
62
  return data
63
63
  end
64
- local key = string.match(restf, "^`body%.([^`%.]+)`$")
65
- if key == nil then
64
+ local path = string.match(restf, "^`body%.(.+)`$")
65
+ if path == nil then
66
66
  return data
67
67
  end
68
- return { [key] = data }
68
+ -- Multi-segment on purpose: GraphQL ops unwrap body.data.<field> (and
69
+ -- body.data.<field>.<entity> for mutations), not just one level.
70
+ local segs = {}
71
+ for seg in string.gmatch(path, "[^.]+") do
72
+ table.insert(segs, seg)
73
+ end
74
+ local out = data
75
+ for i = #segs, 1, -1 do
76
+ out = { [segs[i]] = out }
77
+ end
78
+ return out
69
79
  end
70
80
 
71
81
  local function respond(status, data, extra)
@@ -309,10 +319,14 @@ function TestFeature:build_args(ctx, op, args)
309
319
  local points = vs.getpath(ctx.config, "entity." .. ctx.entity:get_name() .. ".op." .. opname .. ".points")
310
320
  local point = vs.getelem(points, -1)
311
321
 
312
- -- Get required params.
322
+ -- Path AND query: a path-only read misses a query-addressed record
323
+ -- (e.g. GET /result?trace_id=), which has no path param at all.
313
324
  local params_path = vs.getpath(point, "args.params")
314
325
  local reqd_params = vs.select(params_path, { reqd = true })
315
- local reqd = vs.transform(reqd_params, { "`$EACH`", "", "`$KEY.name`" })
326
+ local reqd_from_params = vs.transform(reqd_params, { "`$EACH`", "", "`$KEY.name`" })
327
+ local query_path = vs.getpath(point, "args.query")
328
+ local reqd_query = vs.select(query_path, { reqd = true })
329
+ local reqd_from_query = vs.transform(reqd_query, { "`$EACH`", "", "`$KEY.name`" })
316
330
 
317
331
  local qand = {}
318
332
  local q = { ["`$AND`"] = qand }
@@ -322,8 +336,8 @@ function TestFeature:build_args(ctx, op, args)
322
336
  if keys ~= nil then
323
337
  for _, key in ipairs(keys) do
324
338
  local is_id = (key == "id")
325
- local selected = vs.select(reqd, key)
326
- local is_reqd = not vs.isempty(selected)
339
+ local is_reqd = not vs.isempty(vs.select(reqd_from_params, key))
340
+ or not vs.isempty(vs.select(reqd_from_query, key))
327
341
 
328
342
  if is_id or is_reqd then
329
343
  local v = ctx.utility.param(ctx, key)
@@ -441,7 +441,7 @@ describe("feature", function()
441
441
  -- retry ---------------------------------------------------------------
442
442
  if has_feature("retry") then describe("retry", function()
443
443
 
444
- it("retries transient failures then succeeds", function()
444
+ if has_feature("netsim") then it("retries transient failures then succeeds", function()
445
445
  local clock = make_clock()
446
446
  local h = make_client({ features = {
447
447
  { name = "netsim", options = { failTimes = 2, failStatus = 503 } },
@@ -450,9 +450,9 @@ describe("feature", function()
450
450
  } })
451
451
  assert.is_true(h.op({ op = "load" }).ok)
452
452
  assert.are.equal(2, h.client._retry.attempts)
453
- end)
453
+ end) end
454
454
 
455
- it("gives up after the budget", function()
455
+ if has_feature("netsim") then it("gives up after the budget", function()
456
456
  local clock = make_clock()
457
457
  local h = make_client({ features = {
458
458
  { name = "netsim", options = { failTimes = 9, failStatus = 500 } },
@@ -460,7 +460,7 @@ describe("feature", function()
460
460
  retries = 2, minDelay = 1, jitter = false, sleep = clock.sleep } },
461
461
  } })
462
462
  assert.are.equal(500, h.op({ op = "load" }).result.status)
463
- end)
463
+ end) end
464
464
 
465
465
  it("does not retry a non-retryable status", function()
466
466
  local rec = recording_server(function() return make_response(404) end)
@@ -487,7 +487,7 @@ describe("feature", function()
487
487
  assert.are.equal(3, n)
488
488
  end)
489
489
 
490
- it("honours a server Retry-After", function()
490
+ if has_feature("netsim") then it("honours a server Retry-After", function()
491
491
  local clock = make_clock()
492
492
  local h = make_client({ features = {
493
493
  { name = "netsim", options = { rateLimitTimes = 1, retryAfter = 2 } },
@@ -497,15 +497,15 @@ describe("feature", function()
497
497
  } })
498
498
  assert.is_true(h.op({ op = "load" }).ok)
499
499
  assert.are.equal(2000, clock.time())
500
- end)
500
+ end) end
501
501
 
502
- it("default jitter path still succeeds", function()
502
+ if has_feature("netsim") then it("default jitter path still succeeds", function()
503
503
  local h = make_client({ features = {
504
504
  { name = "netsim", options = { failTimes = 1 } },
505
505
  { name = "retry", options = { retries = 2, minDelay = 0 } },
506
506
  } })
507
507
  assert.is_true(h.op({ op = "load" }).ok)
508
- end)
508
+ end) end
509
509
 
510
510
  it("inactive retry does not wrap", function()
511
511
  local rec = recording_server(function() return make_response(503) end)
@@ -551,7 +551,7 @@ describe("feature", function()
551
551
  -- shared virtual clock stands in for elapsed transport time.
552
552
  if has_feature("timeout") then describe("timeout", function()
553
553
 
554
- it("a slow request times out", function()
554
+ if has_feature("netsim") then it("a slow request times out", function()
555
555
  local clock = make_clock()
556
556
  local h = make_client({ features = {
557
557
  { name = "netsim", options = { latency = 80, sleep = clock.sleep } },
@@ -560,7 +560,7 @@ describe("feature", function()
560
560
  local res = h.op({ op = "load" })
561
561
  assert.are.equal("timeout", res.error.code)
562
562
  assert.are.equal(1, h.client._timeout.count)
563
- end)
563
+ end) end
564
564
 
565
565
  it("a fast request passes through", function()
566
566
  local clock = make_clock()
@@ -819,7 +819,7 @@ describe("feature", function()
819
819
  -- metrics ---------------------------------------------------------------
820
820
  if has_feature("metrics") then describe("metrics", function()
821
821
 
822
- it("counts ok and err per op", function()
822
+ if has_feature("netsim") then it("counts ok and err per op", function()
823
823
  local h = make_client({ features = {
824
824
  { name = "netsim", options = { failTimes = 1, failStatus = 500 } },
825
825
  { name = "metrics", options = {} },
@@ -832,7 +832,7 @@ describe("feature", function()
832
832
  assert.are.equal(2, m.total.ok)
833
833
  assert.are.equal(1, m.total.err)
834
834
  assert.are.equal(2, m.ops["widget.load"].count)
835
- end)
835
+ end) end
836
836
 
837
837
  it("injected clock accumulates durations", function()
838
838
  local t = 0
@@ -872,14 +872,14 @@ describe("feature", function()
872
872
  assert.is_truthy(string.match(sent["traceparent"], "^00%-.+%-.+%-01$"))
873
873
  end)
874
874
 
875
- it("records a failed span on error", function()
875
+ if has_feature("netsim") then it("records a failed span on error", function()
876
876
  local h = make_client({ features = {
877
877
  { name = "netsim", options = { failTimes = 1, failStatus = 500 } },
878
878
  { name = "telemetry", options = {} },
879
879
  } })
880
880
  h.op({ op = "load" })
881
881
  assert.is_false(h.client._telemetry.spans[1].ok)
882
- end)
882
+ end) end
883
883
 
884
884
  it("default id generation and no exporter", function()
885
885
  local h = make_client({ features = { { name = "telemetry" } } })
@@ -925,14 +925,14 @@ describe("feature", function()
925
925
  assert.are.equal("<redacted>", seen[1].headers.authorization)
926
926
  end)
927
927
 
928
- it("captures failures", function()
928
+ if has_feature("netsim") then it("captures failures", function()
929
929
  local h = make_client({ features = {
930
930
  { name = "netsim", options = { failTimes = 1, failStatus = 500 } },
931
931
  { name = "debug", options = {} },
932
932
  } })
933
933
  h.op({ op = "load" })
934
934
  assert.is_false(h.client._debug.entries[1].ok)
935
- end)
935
+ end) end
936
936
 
937
937
  it("injected clock + custom redact", function()
938
938
  local h = make_client({ features = {
@@ -957,7 +957,7 @@ describe("feature", function()
957
957
  -- audit -----------------------------------------------------------------
958
958
  if has_feature("audit") then describe("audit", function()
959
959
 
960
- it("one record per op with sink + actor", function()
960
+ if has_feature("netsim") then it("one record per op with sink + actor", function()
961
961
  local sink = {}
962
962
  local h = make_client({ features = {
963
963
  { name = "netsim", options = { failTimes = 1, failStatus = 500 } },
@@ -972,7 +972,7 @@ describe("feature", function()
972
972
  assert.are.equal("svc", recs[1].actor)
973
973
  assert.are.equal("per-call", recs[2].actor)
974
974
  assert.are.equal(2, #sink)
975
- end)
975
+ end) end
976
976
 
977
977
  it("default actor is anonymous", function()
978
978
  local h = make_client({ features = { { name = "audit" } } })