ts-procedures 6.2.0 → 7.0.0-beta.1

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 (113) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
  3. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
  4. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +253 -3
  5. package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
  6. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
  7. package/agent_config/copilot/copilot-instructions.md +3 -0
  8. package/agent_config/cursor/cursorrules +3 -0
  9. package/build/client/augment-error-map.test-d.d.ts +10 -0
  10. package/build/client/augment-error-map.test-d.js +14 -0
  11. package/build/client/augment-error-map.test-d.js.map +1 -0
  12. package/build/client/bind-callable.test.d.ts +1 -0
  13. package/build/client/bind-callable.test.js +132 -0
  14. package/build/client/bind-callable.test.js.map +1 -0
  15. package/build/client/call.d.ts +14 -2
  16. package/build/client/call.js +96 -9
  17. package/build/client/call.js.map +1 -1
  18. package/build/client/call.test.js +50 -1
  19. package/build/client/call.test.js.map +1 -1
  20. package/build/client/classify-error.d.ts +11 -0
  21. package/build/client/classify-error.js +49 -0
  22. package/build/client/classify-error.js.map +1 -0
  23. package/build/client/classify-error.test.d.ts +1 -0
  24. package/build/client/classify-error.test.js +55 -0
  25. package/build/client/classify-error.test.js.map +1 -0
  26. package/build/client/error-dispatch.d.ts +1 -1
  27. package/build/client/error-dispatch.js +1 -1
  28. package/build/client/errors.d.ts +55 -4
  29. package/build/client/errors.js +54 -7
  30. package/build/client/errors.js.map +1 -1
  31. package/build/client/errors.test.js +89 -4
  32. package/build/client/errors.test.js.map +1 -1
  33. package/build/client/fetch-adapter.d.ts +2 -1
  34. package/build/client/fetch-adapter.js +2 -1
  35. package/build/client/fetch-adapter.js.map +1 -1
  36. package/build/client/fetch-adapter.test.js +12 -0
  37. package/build/client/fetch-adapter.test.js.map +1 -1
  38. package/build/client/index.d.ts +5 -3
  39. package/build/client/index.js +29 -3
  40. package/build/client/index.js.map +1 -1
  41. package/build/client/resolve-options.d.ts +32 -1
  42. package/build/client/resolve-options.js +32 -16
  43. package/build/client/resolve-options.js.map +1 -1
  44. package/build/client/resolve-options.test.js +67 -6
  45. package/build/client/resolve-options.test.js.map +1 -1
  46. package/build/client/result-type.test-d.d.ts +1 -0
  47. package/build/client/result-type.test-d.js +28 -0
  48. package/build/client/result-type.test-d.js.map +1 -0
  49. package/build/client/safe-call.test.d.ts +1 -0
  50. package/build/client/safe-call.test.js +137 -0
  51. package/build/client/safe-call.test.js.map +1 -0
  52. package/build/client/stream.d.ts +1 -1
  53. package/build/client/stream.js +22 -8
  54. package/build/client/stream.js.map +1 -1
  55. package/build/client/stream.test.js +11 -1
  56. package/build/client/stream.test.js.map +1 -1
  57. package/build/client/types.d.ts +117 -3
  58. package/build/codegen/bundle-size.test.d.ts +1 -0
  59. package/build/codegen/bundle-size.test.js +70 -0
  60. package/build/codegen/bundle-size.test.js.map +1 -0
  61. package/build/codegen/e2e.test.js +108 -7
  62. package/build/codegen/e2e.test.js.map +1 -1
  63. package/build/codegen/emit-client-runtime.js +8 -0
  64. package/build/codegen/emit-client-runtime.js.map +1 -1
  65. package/build/codegen/emit-client-runtime.test.js +6 -2
  66. package/build/codegen/emit-client-runtime.test.js.map +1 -1
  67. package/build/codegen/emit-client-types.d.ts +7 -2
  68. package/build/codegen/emit-client-types.js +29 -8
  69. package/build/codegen/emit-client-types.js.map +1 -1
  70. package/build/codegen/emit-client-types.test.js +20 -8
  71. package/build/codegen/emit-client-types.test.js.map +1 -1
  72. package/build/codegen/emit-errors.d.ts +1 -1
  73. package/build/codegen/emit-errors.js +1 -1
  74. package/build/codegen/emit-index.js +1 -1
  75. package/build/codegen/emit-index.js.map +1 -1
  76. package/build/codegen/emit-scope.js +37 -25
  77. package/build/codegen/emit-scope.js.map +1 -1
  78. package/build/codegen/emit-scope.test.js +310 -14
  79. package/build/codegen/emit-scope.test.js.map +1 -1
  80. package/docs/client-and-codegen.md +77 -7
  81. package/docs/client-error-handling.md +357 -0
  82. package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
  83. package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
  84. package/package.json +1 -1
  85. package/src/client/augment-error-map.test-d.ts +22 -0
  86. package/src/client/bind-callable.test.ts +137 -0
  87. package/src/client/call.test.ts +65 -1
  88. package/src/client/call.ts +111 -9
  89. package/src/client/classify-error.test.ts +65 -0
  90. package/src/client/classify-error.ts +59 -0
  91. package/src/client/error-dispatch.ts +1 -1
  92. package/src/client/errors.test.ts +108 -4
  93. package/src/client/errors.ts +70 -7
  94. package/src/client/fetch-adapter.test.ts +15 -0
  95. package/src/client/fetch-adapter.ts +5 -2
  96. package/src/client/index.ts +60 -3
  97. package/src/client/resolve-options.test.ts +83 -5
  98. package/src/client/resolve-options.ts +61 -16
  99. package/src/client/result-type.test-d.ts +51 -0
  100. package/src/client/safe-call.test.ts +157 -0
  101. package/src/client/stream.test.ts +13 -1
  102. package/src/client/stream.ts +25 -8
  103. package/src/client/types.ts +137 -3
  104. package/src/codegen/bundle-size.test.ts +76 -0
  105. package/src/codegen/e2e.test.ts +113 -7
  106. package/src/codegen/emit-client-runtime.test.ts +7 -2
  107. package/src/codegen/emit-client-runtime.ts +8 -0
  108. package/src/codegen/emit-client-types.test.ts +22 -7
  109. package/src/codegen/emit-client-types.ts +35 -10
  110. package/src/codegen/emit-errors.ts +1 -1
  111. package/src/codegen/emit-index.ts +1 -1
  112. package/src/codegen/emit-scope.test.ts +337 -14
  113. package/src/codegen/emit-scope.ts +39 -35
@@ -291,10 +291,10 @@ describe('emitScopeFile', () => {
291
291
  expect(output).toContain('export function bindUsersScope(client: ClientInstance)')
292
292
  })
293
293
 
294
- it('callable uses client.call with kind: rpc', async () => {
294
+ it('callable uses client.bindCallable with kind: rpc', async () => {
295
295
  const output = await emitScopeFile(rpcGroup)
296
296
  expect(output).toContain("kind: 'rpc'")
297
- expect(output).toContain('client.call')
297
+ expect(output).toContain('client.bindCallable<')
298
298
  })
299
299
 
300
300
  it('callable includes JSDoc with method and path', async () => {
@@ -323,10 +323,10 @@ describe('emitScopeFile', () => {
323
323
  expect(output).toContain('export type UpdatePostResponse')
324
324
  })
325
325
 
326
- it('callable uses client.call with kind: api', async () => {
326
+ it('callable uses client.bindCallable with kind: api', async () => {
327
327
  const output = await emitScopeFile(apiGroup)
328
328
  expect(output).toContain("kind: 'api'")
329
- expect(output).toContain('client.call')
329
+ expect(output).toContain('client.bindCallable<')
330
330
  })
331
331
 
332
332
  it('callable includes JSDoc with method and fullPath', async () => {
@@ -427,8 +427,8 @@ describe('emitScopeFile', () => {
427
427
 
428
428
  it('callable references fully qualified namespace types', async () => {
429
429
  const output = await emitScopeFile(rpcGroup, { namespaceTypes: true })
430
- expect(output).toContain('params: Users.GetUser.Params')
431
- expect(output).toContain('Promise<Users.GetUser.Response>')
430
+ // New emission: GetUser: client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>({...})
431
+ expect(output).toContain('client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>')
432
432
  })
433
433
 
434
434
  it('still emits the bind function', async () => {
@@ -454,8 +454,8 @@ describe('emitScopeFile', () => {
454
454
 
455
455
  it('callable references fully qualified namespace types', async () => {
456
456
  const output = await emitScopeFile(apiGroup, { namespaceTypes: true })
457
- expect(output).toContain('params: Posts.UpdatePost.Params')
458
- expect(output).toContain('Promise<Posts.UpdatePost.Response>')
457
+ // New emission: UpdatePost: client.bindCallable<Posts.UpdatePost.Params, Posts.UpdatePost.Response>({...})
458
+ expect(output).toContain('client.bindCallable<Posts.UpdatePost.Params, Posts.UpdatePost.Response>')
459
459
  })
460
460
  })
461
461
 
@@ -528,12 +528,14 @@ describe('emitScopeFile', () => {
528
528
 
529
529
  it('v1 callable has no version suffix', async () => {
530
530
  const output = await emitScopeFile(rpcVersionedGroup)
531
- expect(output).toContain('GetUser(params: GetUserParams')
531
+ // New emission: GetUser: client.bindCallable<GetUserParams, GetUserResponse>({...})
532
+ expect(output).toContain('GetUser: client.bindCallable<GetUserParams, GetUserResponse>')
532
533
  })
533
534
 
534
535
  it('v2 callable uses V2 suffix', async () => {
535
536
  const output = await emitScopeFile(rpcVersionedGroup)
536
- expect(output).toContain('GetUserV2(params: GetUserV2Params')
537
+ // New emission: GetUserV2: client.bindCallable<GetUserV2Params, GetUserV2Response>({...})
538
+ expect(output).toContain('GetUserV2: client.bindCallable<GetUserV2Params, GetUserV2Response>')
537
539
  })
538
540
 
539
541
  it('v1 callable uses its own path', async () => {
@@ -591,14 +593,14 @@ describe('emitScopeFile', () => {
591
593
 
592
594
  it('v1 callable references unversioned namespace types', async () => {
593
595
  const output = await emitScopeFile(rpcVersionedGroup, { namespaceTypes: true })
594
- expect(output).toContain('params: Users.GetUser.Params')
595
- expect(output).toContain('Promise<Users.GetUser.Response>')
596
+ // New emission: GetUser: client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>({...})
597
+ expect(output).toContain('client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>')
596
598
  })
597
599
 
598
600
  it('v2 callable references versioned namespace types', async () => {
599
601
  const output = await emitScopeFile(rpcVersionedGroup, { namespaceTypes: true })
600
- expect(output).toContain('params: Users.GetUserV2.Params')
601
- expect(output).toContain('Promise<Users.GetUserV2.Response>')
602
+ // New emission: GetUserV2: client.bindCallable<Users.GetUserV2.Params, Users.GetUserV2.Response>({...})
603
+ expect(output).toContain('client.bindCallable<Users.GetUserV2.Params, Users.GetUserV2.Response>')
602
604
  })
603
605
  })
604
606
  })
@@ -622,3 +624,324 @@ describe('emitScopeFile', () => {
622
624
  })
623
625
  })
624
626
  })
627
+
628
+ // ---------------------------------------------------------------------------
629
+ // .safe sibling on RPC callables
630
+ // ---------------------------------------------------------------------------
631
+
632
+ const rpcGroupWithErrors: ScopeGroup = {
633
+ scopeKey: 'users',
634
+ camelCase: 'users',
635
+ routes: [
636
+ {
637
+ kind: 'rpc',
638
+ name: 'GetUser',
639
+ path: '/users/1',
640
+ method: 'post',
641
+ scope: 'users',
642
+ version: 1,
643
+ errors: ['NotFound'],
644
+ jsonSchema: {
645
+ body: {
646
+ type: 'object',
647
+ properties: { id: { type: 'string' } },
648
+ required: ['id'],
649
+ },
650
+ response: {
651
+ type: 'object',
652
+ properties: { name: { type: 'string' } },
653
+ required: ['name'],
654
+ },
655
+ },
656
+ } satisfies RPCHttpRouteDoc,
657
+ ],
658
+ }
659
+
660
+ const rpcGroupNoErrors: ScopeGroup = {
661
+ scopeKey: 'users',
662
+ camelCase: 'users',
663
+ routes: [
664
+ {
665
+ kind: 'rpc',
666
+ name: 'GetUser',
667
+ path: '/users/1',
668
+ method: 'post',
669
+ scope: 'users',
670
+ version: 1,
671
+ jsonSchema: {
672
+ body: {
673
+ type: 'object',
674
+ properties: { id: { type: 'string' } },
675
+ required: ['id'],
676
+ },
677
+ response: {
678
+ type: 'object',
679
+ properties: { name: { type: 'string' } },
680
+ required: ['name'],
681
+ },
682
+ },
683
+ } satisfies RPCHttpRouteDoc,
684
+ ],
685
+ }
686
+
687
+ describe('emitScopeFile .safe sibling on RPC', () => {
688
+ it('emits bindCallableTyped for RPC callable when route has errors', async () => {
689
+ const out = await emitScopeFile(rpcGroupWithErrors, {
690
+ namespaceTypes: true,
691
+ errorKeys: new Set(['NotFound']),
692
+ serviceName: 'Api',
693
+ })
694
+ // With errors: uses bindCallableTyped<Params, Response, Errors>
695
+ expect(out).toContain('client.bindCallableTyped<')
696
+ // No Object.assign in generated output
697
+ expect(out).not.toContain('Object.assign')
698
+ })
699
+
700
+ it('emits bindCallable for RPC callable when route has no errors', async () => {
701
+ const out = await emitScopeFile(rpcGroupNoErrors, {
702
+ namespaceTypes: true,
703
+ serviceName: 'Api',
704
+ })
705
+ // Without errors: uses bindCallable<Params, Response>
706
+ expect(out).toContain('client.bindCallable<')
707
+ // No Object.assign in generated output
708
+ expect(out).not.toContain('Object.assign')
709
+ })
710
+
711
+ it('scope files no longer directly import Result/ResultNoTyped (inferred from helper)', async () => {
712
+ const out = await emitScopeFile(rpcGroupNoErrors, {
713
+ serviceName: 'Api',
714
+ })
715
+ // Result/ResultNoTyped are inferred from ClientInstance.bindCallable return type
716
+ expect(out).not.toContain('ResultNoTyped')
717
+ expect(out).not.toMatch(/import.*Result.*from/)
718
+ expect(out).toContain("from 'ts-procedures/client'")
719
+ })
720
+
721
+ it('namespace mode: uses route Errors namespace alias as third type arg', async () => {
722
+ const out = await emitScopeFile(rpcGroupWithErrors, {
723
+ namespaceTypes: true,
724
+ errorKeys: new Set(['NotFound']),
725
+ serviceName: 'Api',
726
+ })
727
+ // errorsRef in namespace mode is the route's Errors type alias: Scope.Route.Errors
728
+ expect(out).toContain('client.bindCallableTyped<Users.GetUser.Params, Users.GetUser.Response, Users.GetUser.Errors>')
729
+ })
730
+
731
+ it('flat mode: uses route Errors type alias as third type arg', async () => {
732
+ const out = await emitScopeFile(rpcGroupWithErrors, {
733
+ namespaceTypes: false,
734
+ errorKeys: new Set(['NotFound']),
735
+ serviceName: 'Api',
736
+ })
737
+ // errorsRef in flat mode is the injected GetUserErrors type alias
738
+ expect(out).toContain('client.bindCallableTyped<GetUserParams, GetUserResponse, GetUserErrors>')
739
+ })
740
+
741
+ it('route with errors uses bindCallableTyped', async () => {
742
+ const out = await emitScopeFile(rpcGroupWithErrors, {
743
+ errorKeys: new Set(['NotFound']),
744
+ serviceName: 'Api',
745
+ })
746
+ expect(out).toContain('client.bindCallableTyped<')
747
+ expect(out).not.toContain('client.bindCallable<')
748
+ })
749
+
750
+ it('route without errors uses bindCallable (not bindCallableTyped)', async () => {
751
+ const out = await emitScopeFile(rpcGroupNoErrors, {
752
+ serviceName: 'Api',
753
+ })
754
+ expect(out).toContain('client.bindCallable<')
755
+ expect(out).not.toContain('client.bindCallableTyped<')
756
+ })
757
+ })
758
+
759
+ // ---------------------------------------------------------------------------
760
+ // .safe sibling on API callables
761
+ // ---------------------------------------------------------------------------
762
+
763
+ const apiGroupWithErrors: ScopeGroup = {
764
+ scopeKey: 'posts',
765
+ camelCase: 'posts',
766
+ routes: [
767
+ {
768
+ kind: 'api',
769
+ name: 'UpdatePost',
770
+ path: '/posts/:id',
771
+ method: 'put',
772
+ fullPath: '/api/posts/:id',
773
+ scope: 'posts',
774
+ errors: ['NotFound'],
775
+ jsonSchema: {
776
+ pathParams: {
777
+ type: 'object',
778
+ properties: { id: { type: 'string' } },
779
+ required: ['id'],
780
+ },
781
+ body: {
782
+ type: 'object',
783
+ properties: { title: { type: 'string' } },
784
+ required: ['title'],
785
+ },
786
+ response: {
787
+ type: 'object',
788
+ properties: { id: { type: 'string' }, title: { type: 'string' } },
789
+ required: ['id', 'title'],
790
+ },
791
+ },
792
+ } satisfies APIHttpRouteDoc,
793
+ ],
794
+ }
795
+
796
+ const apiGroupNoErrors: ScopeGroup = {
797
+ scopeKey: 'posts',
798
+ camelCase: 'posts',
799
+ routes: [
800
+ {
801
+ kind: 'api',
802
+ name: 'UpdatePost',
803
+ path: '/posts/:id',
804
+ method: 'put',
805
+ fullPath: '/api/posts/:id',
806
+ scope: 'posts',
807
+ jsonSchema: {
808
+ pathParams: {
809
+ type: 'object',
810
+ properties: { id: { type: 'string' } },
811
+ required: ['id'],
812
+ },
813
+ body: {
814
+ type: 'object',
815
+ properties: { title: { type: 'string' } },
816
+ required: ['title'],
817
+ },
818
+ response: {
819
+ type: 'object',
820
+ properties: { id: { type: 'string' }, title: { type: 'string' } },
821
+ required: ['id', 'title'],
822
+ },
823
+ },
824
+ } satisfies APIHttpRouteDoc,
825
+ ],
826
+ }
827
+
828
+ describe('emitScopeFile .safe sibling on API', () => {
829
+ it('emits bindCallableTyped for API callable when route has errors', async () => {
830
+ const out = await emitScopeFile(apiGroupWithErrors, {
831
+ namespaceTypes: true,
832
+ errorKeys: new Set(['NotFound']),
833
+ serviceName: 'Api',
834
+ })
835
+ // With errors: uses bindCallableTyped<Params, Response, Errors>
836
+ expect(out).toContain('client.bindCallableTyped<')
837
+ // No Object.assign in generated output
838
+ expect(out).not.toContain('Object.assign')
839
+ })
840
+
841
+ it('emits bindCallable for API callable when route has no errors', async () => {
842
+ const out = await emitScopeFile(apiGroupNoErrors, {
843
+ namespaceTypes: true,
844
+ serviceName: 'Api',
845
+ })
846
+ // Without errors: uses bindCallable<Params, Response>
847
+ expect(out).toContain('client.bindCallable<')
848
+ // No Object.assign in generated output
849
+ expect(out).not.toContain('Object.assign')
850
+ })
851
+
852
+ it('namespace mode: uses route Errors namespace alias as third type arg', async () => {
853
+ const out = await emitScopeFile(apiGroupWithErrors, {
854
+ namespaceTypes: true,
855
+ errorKeys: new Set(['NotFound']),
856
+ serviceName: 'Api',
857
+ })
858
+ // errorsRef in namespace mode is the route's Errors type alias: Scope.Route.Errors
859
+ expect(out).toContain('client.bindCallableTyped<Posts.UpdatePost.Params, Posts.UpdatePost.Response, Posts.UpdatePost.Errors>')
860
+ })
861
+
862
+ it('flat mode: uses route Errors type alias as third type arg', async () => {
863
+ const out = await emitScopeFile(apiGroupWithErrors, {
864
+ namespaceTypes: false,
865
+ errorKeys: new Set(['NotFound']),
866
+ serviceName: 'Api',
867
+ })
868
+ // errorsRef in flat mode is the injected UpdatePostErrors type alias
869
+ expect(out).toContain('client.bindCallableTyped<UpdatePostParams, UpdatePostResponse, UpdatePostErrors>')
870
+ })
871
+
872
+ it('route with errors uses bindCallableTyped', async () => {
873
+ const out = await emitScopeFile(apiGroupWithErrors, {
874
+ errorKeys: new Set(['NotFound']),
875
+ serviceName: 'Api',
876
+ })
877
+ expect(out).toContain('client.bindCallableTyped<')
878
+ expect(out).not.toContain('client.bindCallable<')
879
+ })
880
+
881
+ it('route without errors uses bindCallable (not bindCallableTyped)', async () => {
882
+ const out = await emitScopeFile(apiGroupNoErrors, {
883
+ serviceName: 'Api',
884
+ })
885
+ expect(out).toContain('client.bindCallable<')
886
+ expect(out).not.toContain('client.bindCallableTyped<')
887
+ })
888
+
889
+ it('callable uses fullPath not path in the descriptor', async () => {
890
+ const out = await emitScopeFile(apiGroupWithErrors, {
891
+ errorKeys: new Set(['NotFound']),
892
+ serviceName: 'Api',
893
+ })
894
+ // The descriptor in the helper call should reference the fullPath
895
+ expect(out).toContain("path: '/api/posts/:id'")
896
+ expect(out).not.toContain("path: '/posts/:id'")
897
+ })
898
+ })
899
+
900
+ // ---------------------------------------------------------------------------
901
+ // Stream callables omit .safe sibling (regression guard for Task 14)
902
+ // ---------------------------------------------------------------------------
903
+
904
+ describe('emitScopeFile streams omit .safe sibling', () => {
905
+ it('does not emit .safe on stream callables', async () => {
906
+ const out = await emitScopeFile(streamGroup, {
907
+ serviceName: 'Api',
908
+ })
909
+
910
+ // Stream callable should be emitted as a plain method
911
+ expect(out).toContain('WatchEvents(')
912
+ // No .safe property on stream callables
913
+ expect(out).not.toContain('WatchEvents.safe')
914
+ // Object.assign wrapper is not used for stream routes
915
+ expect(out).not.toMatch(/WatchEvents\s*:\s*Object\.assign/)
916
+ // bindCallable helpers are not used for stream routes
917
+ expect(out).not.toMatch(/WatchEvents\s*:\s*client\.bindCallable/)
918
+ })
919
+
920
+ it('stream callable returns TypedStream, not Result', async () => {
921
+ const out = await emitScopeFile(streamGroup, {
922
+ serviceName: 'Api',
923
+ })
924
+
925
+ // Confirm stream callable returns TypedStream
926
+ expect(out).toContain('TypedStream<')
927
+ // Should not have Result or ResultNoTyped in the return type signature for streams
928
+ // (streams have their own three-way failure surface)
929
+ expect(out).toMatch(/WatchEvents\([^)]*\)\s*:\s*TypedStream/)
930
+ })
931
+
932
+ it('stream callable with namespace types still omits .safe', async () => {
933
+ const out = await emitScopeFile(streamGroup, {
934
+ namespaceTypes: true,
935
+ serviceName: 'Api',
936
+ })
937
+
938
+ // Stream callable should use namespace-qualified types
939
+ expect(out).toContain('Events.WatchEvents.Params')
940
+ expect(out).toContain('TypedStream<Events.WatchEvents.Yield, Events.WatchEvents.Return>')
941
+ // But still no .safe property
942
+ expect(out).not.toContain('WatchEvents.safe')
943
+ expect(out).not.toMatch(/WatchEvents\s*:\s*Object\.assign/)
944
+ // bindCallable helpers are not used for stream routes
945
+ expect(out).not.toMatch(/WatchEvents\s*:\s*client\.bindCallable/)
946
+ })
947
+ })
@@ -246,28 +246,29 @@ async function emitRpcRoute(route: RPCHttpRouteDoc, ctx: EmitRouteContext): Prom
246
246
  const responseTypeName = refs['Response'] ?? 'unknown'
247
247
  const scopeStr = Array.isArray(route.scope) ? route.scope.join('-') : route.scope
248
248
 
249
+ const errorUnion = buildErrorUnion(route.errors, ctx)
250
+ const hasErrors = errorUnion !== null
251
+ const errorsRef = ctx.namespaceTypes
252
+ ? `${ctx.scopePascal}.${pascal}.Errors`
253
+ : `${pascal}Errors`
254
+ const helperCall = hasErrors
255
+ ? `client.bindCallableTyped<${paramsTypeName}, ${responseTypeName}, ${errorsRef}>`
256
+ : `client.bindCallable<${paramsTypeName}, ${responseTypeName}>`
257
+
249
258
  const callable = [
250
259
  ` /** ${route.method.toUpperCase()} ${route.path} */`,
251
- ` ${pascal}(params: ${paramsTypeName}, options?: ProcedureCallOptions): Promise<${responseTypeName}> {`,
252
- ` return client.call<${responseTypeName}>({`,
253
- ` name: '${pascal}',`,
254
- ` scope: '${scopeStr}',`,
255
- ` path: '${route.path}',`,
256
- ` method: '${route.method}',`,
257
- ` kind: 'rpc',`,
258
- ` params,`,
259
- ` }, options)`,
260
- ` },`,
260
+ ` ${pascal}: ${helperCall}({`,
261
+ ` name: '${pascal}',`,
262
+ ` scope: '${scopeStr}',`,
263
+ ` path: '${route.path}',`,
264
+ ` method: '${route.method}',`,
265
+ ` kind: 'rpc',`,
266
+ ` }),`,
261
267
  ].join('\n')
262
268
 
263
- const hasErrors = injectRouteErrors(
264
- declarations,
265
- pascal,
266
- buildErrorUnion(route.errors, ctx),
267
- ctx.namespaceTypes
268
- )
269
+ const hasErrorsInjected = injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
269
270
 
270
- return { typeDeclarations: declarations, callable, hasStream: false, hasErrors }
271
+ return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
271
272
  }
272
273
 
273
274
  async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
@@ -323,28 +324,30 @@ async function emitApiRoute(route: APIHttpRouteDoc, ctx: EmitRouteContext): Prom
323
324
  const responseTypeName = refs['Response'] ?? 'unknown'
324
325
  const scopeStr = route.scope ?? 'default'
325
326
 
327
+ const errorUnion = buildErrorUnion(route.errors, ctx)
328
+ const hasErrors = errorUnion !== null
329
+ const errorsRef = ctx.namespaceTypes
330
+ ? `${ctx.scopePascal}.${pascal}.Errors`
331
+ : `${pascal}Errors`
332
+ const helperCall = hasErrors
333
+ ? `client.bindCallableTyped<${paramsTypeName}, ${responseTypeName}, ${errorsRef}>`
334
+ : `client.bindCallable<${paramsTypeName}, ${responseTypeName}>`
335
+
336
+ // Property key uses route.name verbatim (preserving the prior API emission contract).
326
337
  const callable = [
327
338
  ` /** ${route.method.toUpperCase()} ${route.fullPath} */`,
328
- ` ${route.name}(params: ${paramsTypeName}, options?: ProcedureCallOptions): Promise<${responseTypeName}> {`,
329
- ` return client.call<${responseTypeName}>({`,
330
- ` name: '${route.name}',`,
331
- ` scope: '${scopeStr}',`,
332
- ` path: '${route.fullPath}',`,
333
- ` method: '${route.method}',`,
334
- ` kind: 'api',`,
335
- ` params,`,
336
- ` }, options)`,
337
- ` },`,
339
+ ` ${route.name}: ${helperCall}({`,
340
+ ` name: '${route.name}',`,
341
+ ` scope: '${scopeStr}',`,
342
+ ` path: '${route.fullPath}',`,
343
+ ` method: '${route.method}',`,
344
+ ` kind: 'api',`,
345
+ ` }),`,
338
346
  ].join('\n')
339
347
 
340
- const hasErrors = injectRouteErrors(
341
- declarations,
342
- pascal,
343
- buildErrorUnion(route.errors, ctx),
344
- ctx.namespaceTypes
345
- )
348
+ const hasErrorsInjected = injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
346
349
 
347
- return { typeDeclarations: declarations, callable, hasStream: false, hasErrors }
350
+ return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
348
351
  }
349
352
 
350
353
  async function emitStreamRoute(route: StreamHttpRouteDoc, ctx: EmitRouteContext): Promise<RouteChunks> {
@@ -456,7 +459,8 @@ export async function emitScopeFile(
456
459
  if (chunks.hasErrors) scopeHasErrors = true
457
460
  }
458
461
 
459
- // Build client import line
462
+ // Build client import line — Result/ResultNoTyped are no longer referenced
463
+ // directly in scope files; the bindCallable helpers infer them from ClientInstance.
460
464
  const clientImports = hasStream
461
465
  ? `import type { ClientInstance, ProcedureCallOptions, TypedStream } from '${clientImportPath}'`
462
466
  : `import type { ClientInstance, ProcedureCallOptions } from '${clientImportPath}'`