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.
- package/README.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +253 -3
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/build/client/augment-error-map.test-d.d.ts +10 -0
- package/build/client/augment-error-map.test-d.js +14 -0
- package/build/client/augment-error-map.test-d.js.map +1 -0
- package/build/client/bind-callable.test.d.ts +1 -0
- package/build/client/bind-callable.test.js +132 -0
- package/build/client/bind-callable.test.js.map +1 -0
- package/build/client/call.d.ts +14 -2
- package/build/client/call.js +96 -9
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +50 -1
- package/build/client/call.test.js.map +1 -1
- package/build/client/classify-error.d.ts +11 -0
- package/build/client/classify-error.js +49 -0
- package/build/client/classify-error.js.map +1 -0
- package/build/client/classify-error.test.d.ts +1 -0
- package/build/client/classify-error.test.js +55 -0
- package/build/client/classify-error.test.js.map +1 -0
- package/build/client/error-dispatch.d.ts +1 -1
- package/build/client/error-dispatch.js +1 -1
- package/build/client/errors.d.ts +55 -4
- package/build/client/errors.js +54 -7
- package/build/client/errors.js.map +1 -1
- package/build/client/errors.test.js +89 -4
- package/build/client/errors.test.js.map +1 -1
- package/build/client/fetch-adapter.d.ts +2 -1
- package/build/client/fetch-adapter.js +2 -1
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/fetch-adapter.test.js +12 -0
- package/build/client/fetch-adapter.test.js.map +1 -1
- package/build/client/index.d.ts +5 -3
- package/build/client/index.js +29 -3
- package/build/client/index.js.map +1 -1
- package/build/client/resolve-options.d.ts +32 -1
- package/build/client/resolve-options.js +32 -16
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +67 -6
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/result-type.test-d.d.ts +1 -0
- package/build/client/result-type.test-d.js +28 -0
- package/build/client/result-type.test-d.js.map +1 -0
- package/build/client/safe-call.test.d.ts +1 -0
- package/build/client/safe-call.test.js +137 -0
- package/build/client/safe-call.test.js.map +1 -0
- package/build/client/stream.d.ts +1 -1
- package/build/client/stream.js +22 -8
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +11 -1
- package/build/client/stream.test.js.map +1 -1
- package/build/client/types.d.ts +117 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +70 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +108 -7
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +8 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-client-runtime.test.js +6 -2
- package/build/codegen/emit-client-runtime.test.js.map +1 -1
- package/build/codegen/emit-client-types.d.ts +7 -2
- package/build/codegen/emit-client-types.js +29 -8
- package/build/codegen/emit-client-types.js.map +1 -1
- package/build/codegen/emit-client-types.test.js +20 -8
- package/build/codegen/emit-client-types.test.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +1 -1
- package/build/codegen/emit-errors.js +1 -1
- package/build/codegen/emit-index.js +1 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +37 -25
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +310 -14
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/docs/client-and-codegen.md +77 -7
- package/docs/client-error-handling.md +357 -0
- package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
- package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
- package/package.json +1 -1
- package/src/client/augment-error-map.test-d.ts +22 -0
- package/src/client/bind-callable.test.ts +137 -0
- package/src/client/call.test.ts +65 -1
- package/src/client/call.ts +111 -9
- package/src/client/classify-error.test.ts +65 -0
- package/src/client/classify-error.ts +59 -0
- package/src/client/error-dispatch.ts +1 -1
- package/src/client/errors.test.ts +108 -4
- package/src/client/errors.ts +70 -7
- package/src/client/fetch-adapter.test.ts +15 -0
- package/src/client/fetch-adapter.ts +5 -2
- package/src/client/index.ts +60 -3
- package/src/client/resolve-options.test.ts +83 -5
- package/src/client/resolve-options.ts +61 -16
- package/src/client/result-type.test-d.ts +51 -0
- package/src/client/safe-call.test.ts +157 -0
- package/src/client/stream.test.ts +13 -1
- package/src/client/stream.ts +25 -8
- package/src/client/types.ts +137 -3
- package/src/codegen/bundle-size.test.ts +76 -0
- package/src/codegen/e2e.test.ts +113 -7
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +8 -0
- package/src/codegen/emit-client-types.test.ts +22 -7
- package/src/codegen/emit-client-types.ts +35 -10
- package/src/codegen/emit-errors.ts +1 -1
- package/src/codegen/emit-index.ts +1 -1
- package/src/codegen/emit-scope.test.ts +337 -14
- 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
431
|
-
expect(output).toContain('
|
|
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
|
-
|
|
458
|
-
expect(output).toContain('
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
595
|
-
expect(output).toContain('
|
|
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
|
-
|
|
601
|
-
expect(output).toContain('
|
|
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}
|
|
252
|
-
`
|
|
253
|
-
`
|
|
254
|
-
`
|
|
255
|
-
`
|
|
256
|
-
`
|
|
257
|
-
`
|
|
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
|
|
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}
|
|
329
|
-
`
|
|
330
|
-
`
|
|
331
|
-
`
|
|
332
|
-
`
|
|
333
|
-
`
|
|
334
|
-
`
|
|
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
|
|
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}'`
|