@voxgig/sdkgen 4.4.0 → 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 (100) 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/Config_ocaml.ts +13 -1
  42. package/project/.sdk/src/cmp/ocaml/ReadmeRef_ocaml.ts +5 -0
  43. package/project/.sdk/src/cmp/perl/ReadmeRef_perl.ts +5 -0
  44. package/project/.sdk/src/cmp/php/ReadmeRef_php.ts +5 -0
  45. package/project/.sdk/src/cmp/py/ReadmeRef_py.ts +5 -0
  46. package/project/.sdk/src/cmp/rb/ReadmeRef_rb.ts +5 -0
  47. package/project/.sdk/src/cmp/rust/ReadmeRef_rust.ts +5 -0
  48. package/project/.sdk/src/cmp/scala/ReadmeRef_scala.ts +5 -0
  49. package/project/.sdk/src/cmp/seneca-provider/Extras_seneca-provider.ts +19 -1
  50. package/project/.sdk/src/cmp/seneca-provider/Main_seneca-provider.ts +27 -2
  51. package/project/.sdk/src/cmp/swift/ReadmeRef_swift.ts +5 -0
  52. package/project/.sdk/src/cmp/ts/Config_ts.ts +4 -1
  53. package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +9 -4
  54. package/project/.sdk/src/cmp/ts/ReadmeModel_ts.ts +8 -3
  55. package/project/.sdk/src/cmp/ts/ReadmeQuick_ts.ts +3 -2
  56. package/project/.sdk/src/cmp/ts/ReadmeRef_ts.ts +7 -2
  57. package/project/.sdk/src/cmp/ts/ReadmeTopQuick_ts.ts +3 -2
  58. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +5 -2
  59. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +5 -2
  60. package/project/.sdk/src/cmp/zig/ReadmeRef_zig.ts +5 -0
  61. package/project/.sdk/tm/clojure/src/sdk/features.clj +175 -0
  62. package/project/.sdk/tm/dart/test/feature_test.dart +54 -0
  63. package/project/.sdk/tm/go/feature/test_feature.go +17 -6
  64. package/project/.sdk/tm/go/test/feature_corpus_test.go +28 -3
  65. package/project/.sdk/tm/java/test/FeatureCorpusTest.java +13 -4
  66. package/project/.sdk/tm/js/src/feature/test/TestFeature.js +8 -1
  67. package/project/.sdk/tm/js/src/utility/MakeOptionsUtility.js +3 -1
  68. package/project/.sdk/tm/js/test/feature/Corpus.test.js +19 -4
  69. package/project/.sdk/tm/js/test/feature.test.js +19 -9
  70. package/project/.sdk/tm/lean/src/SdkFeatures.lean +186 -1
  71. package/project/.sdk/tm/lua/feature/test_feature.lua +21 -7
  72. package/project/.sdk/tm/lua/test/feature_test.lua +18 -18
  73. package/project/.sdk/tm/lua/utility/make_options.lua +2 -0
  74. package/project/.sdk/tm/ocaml/sdk_features.ml +229 -0
  75. package/project/.sdk/tm/perl/t/feature_corpus.t +14 -5
  76. package/project/.sdk/tm/php/feature/TestFeature.php +19 -8
  77. package/project/.sdk/tm/php/test/FeatureCorpusTest.php +18 -4
  78. package/project/.sdk/tm/php/utility/MakeOptions.php +2 -1
  79. package/project/.sdk/tm/py/pkg/feature/test_feature.py +15 -4
  80. package/project/.sdk/tm/py/pkg/utility/make_options.py +2 -0
  81. package/project/.sdk/tm/py/test/feature_harness.py +6 -0
  82. package/project/.sdk/tm/py/test/test_feature.py +40 -0
  83. package/project/.sdk/tm/py/test/test_feature_corpus.py +20 -3
  84. package/project/.sdk/tm/rb/test/feature_corpus_test.rb +18 -5
  85. package/project/.sdk/tm/rust/feature/cost.rs +25 -1
  86. package/project/.sdk/tm/ts/src/feature/test/TestFeature.ts +15 -1
  87. package/project/.sdk/tm/ts/src/utility/MakeOptionsUtility.ts +3 -1
  88. package/project/.sdk/tm/ts/src/utility/PrepareAuthUtility.ts +20 -0
  89. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +25 -4
  90. package/project/.sdk/tm/ts/test/feature.test.ts +19 -9
  91. package/project/sdkgen-package.json +1 -1
  92. package/src/cmp/FeatureDocs.ts +100 -0
  93. package/src/cmp/Readme.ts +2 -0
  94. package/src/cmp/ReadmeFeatures.ts +113 -0
  95. package/src/cmp/ReadmeRef.ts +4 -0
  96. package/src/cmp/ReadmeRefFeatures.ts +149 -0
  97. package/src/cmp/ReadmeTop.ts +48 -0
  98. package/src/helpers/opShape.ts +5 -1
  99. package/src/sdkgen.ts +28 -7
  100. package/src/utility.ts +21 -1
@@ -134,6 +134,12 @@ void tests() {
134
134
  if (hasFeature('retry')) {
135
135
  describe('retry', () {
136
136
  test('retries transient failures then succeeds', (t) async {
137
+ // Drives netsim as the simulated network: runnable only when this
138
+ // SDK was generated with it (the harness skips absent features).
139
+ if (!hasFeature('netsim')) {
140
+ t.skip('this SDK was generated without the netsim feature');
141
+ return;
142
+ }
137
143
  final clock = makeClock();
138
144
  final h = makeClient(features: [
139
145
  {'name': 'netsim', 'options': {'failTimes': 2, 'failStatus': 503}},
@@ -152,6 +158,12 @@ void tests() {
152
158
  });
153
159
 
154
160
  test('gives up after the budget', (t) async {
161
+ // Drives netsim as the simulated network: runnable only when this
162
+ // SDK was generated with it (the harness skips absent features).
163
+ if (!hasFeature('netsim')) {
164
+ t.skip('this SDK was generated without the netsim feature');
165
+ return;
166
+ }
155
167
  final clock = makeClock();
156
168
  final h = makeClient(features: [
157
169
  {'name': 'netsim', 'options': {'failTimes': 9, 'failStatus': 500}},
@@ -203,6 +215,12 @@ void tests() {
203
215
  });
204
216
 
205
217
  test('honours a server Retry-After', (t) async {
218
+ // Drives netsim as the simulated network: runnable only when this
219
+ // SDK was generated with it (the harness skips absent features).
220
+ if (!hasFeature('netsim')) {
221
+ t.skip('this SDK was generated without the netsim feature');
222
+ return;
223
+ }
206
224
  final clock = makeClock();
207
225
  final h = makeClient(features: [
208
226
  {'name': 'netsim', 'options': {'rateLimitTimes': 1, 'retryAfter': 2}},
@@ -222,6 +240,12 @@ void tests() {
222
240
  });
223
241
 
224
242
  test('default jitter path still succeeds', (t) async {
243
+ // Drives netsim as the simulated network: runnable only when this
244
+ // SDK was generated with it (the harness skips absent features).
245
+ if (!hasFeature('netsim')) {
246
+ t.skip('this SDK was generated without the netsim feature');
247
+ return;
248
+ }
225
249
  final h = makeClient(features: [
226
250
  {'name': 'netsim', 'options': {'failTimes': 1}},
227
251
  {'name': 'retry', 'options': {'retries': 2, 'minDelay': 0}},
@@ -235,6 +259,12 @@ void tests() {
235
259
  if (hasFeature('timeout')) {
236
260
  describe('timeout', () {
237
261
  test('a slow request times out', (t) async {
262
+ // Drives netsim as the simulated network: runnable only when this
263
+ // SDK was generated with it (the harness skips absent features).
264
+ if (!hasFeature('netsim')) {
265
+ t.skip('this SDK was generated without the netsim feature');
266
+ return;
267
+ }
238
268
  final h = makeClient(features: [
239
269
  {'name': 'netsim', 'options': {'latency': 80}},
240
270
  {'name': 'timeout', 'options': {'ms': 10}},
@@ -470,6 +500,12 @@ void tests() {
470
500
  if (hasFeature('metrics')) {
471
501
  describe('metrics', () {
472
502
  test('counts ok and err per op', (t) async {
503
+ // Drives netsim as the simulated network: runnable only when this
504
+ // SDK was generated with it (the harness skips absent features).
505
+ if (!hasFeature('netsim')) {
506
+ t.skip('this SDK was generated without the netsim feature');
507
+ return;
508
+ }
473
509
  final h = makeClient(features: [
474
510
  {'name': 'netsim', 'options': {'failTimes': 1, 'failStatus': 500}},
475
511
  {'name': 'metrics', 'options': {}},
@@ -509,6 +545,12 @@ void tests() {
509
545
  });
510
546
 
511
547
  test('records a failed span on error', (t) async {
548
+ // Drives netsim as the simulated network: runnable only when this
549
+ // SDK was generated with it (the harness skips absent features).
550
+ if (!hasFeature('netsim')) {
551
+ t.skip('this SDK was generated without the netsim feature');
552
+ return;
553
+ }
512
554
  final h = makeClient(features: [
513
555
  {'name': 'netsim', 'options': {'failTimes': 1, 'failStatus': 500}},
514
556
  {'name': 'telemetry', 'options': {}},
@@ -542,6 +584,12 @@ void tests() {
542
584
  });
543
585
 
544
586
  test('captures failures', (t) async {
587
+ // Drives netsim as the simulated network: runnable only when this
588
+ // SDK was generated with it (the harness skips absent features).
589
+ if (!hasFeature('netsim')) {
590
+ t.skip('this SDK was generated without the netsim feature');
591
+ return;
592
+ }
545
593
  final h = makeClient(features: [
546
594
  {'name': 'netsim', 'options': {'failTimes': 1, 'failStatus': 500}},
547
595
  {'name': 'debug', 'options': {}},
@@ -556,6 +604,12 @@ void tests() {
556
604
  if (hasFeature('audit')) {
557
605
  describe('audit', () {
558
606
  test('one record per op with sink + actor', (t) async {
607
+ // Drives netsim as the simulated network: runnable only when this
608
+ // SDK was generated with it (the harness skips absent features).
609
+ if (!hasFeature('netsim')) {
610
+ t.skip('this SDK was generated without the netsim feature');
611
+ return;
612
+ }
559
613
  final sink = [];
560
614
  final h = makeClient(features: [
561
615
  {'name': 'netsim', 'options': {'failTimes': 1, 'failStatus': 500}},
@@ -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)
@@ -20,15 +20,17 @@ import (
20
20
  "errors"
21
21
  "fmt"
22
22
  "reflect"
23
+ "sort"
23
24
  "strings"
24
25
  "testing"
25
26
 
26
27
  sdk "GOMODULE"
27
28
  )
28
29
 
29
- // Features with a corpus section. A name here with no section is a skip, not
30
- // a failure: an SDK generated without the feature has nothing to run.
31
- 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.
32
34
 
33
35
  // The standard operation names, in the order the runner prefers them. Every
34
36
  // entity declares every CRUD method, so an op that the API does not define
@@ -184,6 +186,23 @@ func fcCandidates(client *sdk.ProjectNameSDK) []fcOp {
184
186
  })
185
187
  }
186
188
  }
189
+
190
+ // SAFE OPS FIRST — see the ts harness for the reasoning: the cache stores
191
+ // only successful GETs, so an SDK whose first usable op is a `create`
192
+ // (POST) can never satisfy "a hit served from cache costs nothing".
193
+ safe := map[string]int{"list": 0, "load": 1}
194
+ rank := func(o fcOp) int {
195
+ if r, ok := safe[strings.ToLower(o.method)]; ok {
196
+ return r
197
+ }
198
+ return 2
199
+ }
200
+ sort.SliceStable(out, func(i, j int) bool {
201
+ if rank(out[i]) != rank(out[j]) {
202
+ return rank(out[i]) < rank(out[j])
203
+ }
204
+ return out[i].key < out[j].key
205
+ })
187
206
  return out
188
207
  }
189
208
 
@@ -432,6 +451,12 @@ func TestFeatureCorpus(t *testing.T) {
432
451
  "corpus cannot exercise a feature without one")
433
452
  }
434
453
 
454
+ featureCorpusNames := make([]string, 0, len(featureSection))
455
+ for name := range featureSection {
456
+ featureCorpusNames = append(featureCorpusNames, name)
457
+ }
458
+ sort.Strings(featureCorpusNames)
459
+
435
460
  for _, name := range featureCorpusNames {
436
461
  name := name
437
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 =
@@ -182,6 +183,14 @@ public class FeatureCorpusTest {
182
183
  out.add(new Op(e.getKey() + "." + opname, accessor, call));
183
184
  }
184
185
  }
186
+
187
+ // SAFE OPS FIRST — see the ts harness for the reasoning: the cache stores
188
+ // only successful GETs, so an SDK whose first usable op is a `create`
189
+ // (POST) can never satisfy "a hit served from cache costs nothing".
190
+ java.util.Map<String, Integer> safe = java.util.Map.of("list", 0, "load", 1);
191
+ out.sort(java.util.Comparator
192
+ .<Op>comparingInt(o -> safe.getOrDefault(o.key.substring(o.key.indexOf('.') + 1), 2))
193
+ .thenComparing(o -> o.key));
185
194
  return out;
186
195
  }
187
196
 
@@ -369,7 +378,7 @@ public class FeatureCorpusTest {
369
378
  "this project's test.json has no `feature` section - recompile the "
370
379
  + "corpus (create-sdkgen .sdk/test/feature/) to run these cases");
371
380
 
372
- for (String name : FEATURE_CORPUS_NAMES) {
381
+ for (String name : new TreeMap<>(features).keySet()) {
373
382
  Object sectionRaw = features.get(name);
374
383
  if (!(sectionRaw instanceof Map)) {
375
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
@@ -109,7 +119,12 @@ function candidates(client) {
109
119
  out.push({ key: entity + '.' + op, accessor: accessor[entity], entity, op })
110
120
  }
111
121
  }
112
- return out
122
+ // SAFE OPS FIRST — see the ts harness for the reasoning: the cache stores
123
+ // only successful GETs, so an SDK whose first usable op is a `create`
124
+ // (POST) can never satisfy "a hit served from cache costs nothing".
125
+ const SAFE = { list: 0, load: 1 }
126
+ return out.sort((a, b) =>
127
+ ((SAFE[a.op] ?? 2) - (SAFE[b.op] ?? 2)) || a.key.localeCompare(b.key))
113
128
  }
114
129
 
115
130
 
@@ -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