ts-procedures 7.0.0-beta.0 → 7.0.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.
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { createClient } from './index.js'
3
+ import { ClientHttpError, ClientNetworkError } from './errors.js'
4
+ import type { ClientAdapter } from './types.js'
5
+
6
+ const okAdapter: ClientAdapter = {
7
+ request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })),
8
+ stream: vi.fn(async () => { throw new Error('n/a') }),
9
+ }
10
+
11
+ describe('ClientInstance.bindCallable', () => {
12
+ it('returns a callable that throws on failure', async () => {
13
+ const client = createClient({
14
+ adapter: { request: vi.fn(async () => { throw new TypeError('x') }), stream: vi.fn(async () => { throw new Error('n/a') }) },
15
+ basePath: 'https://api.x',
16
+ scopes: (c) => ({
17
+ getUser: c.bindCallable<{ id: string }, { id: string }>({
18
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
19
+ }),
20
+ }),
21
+ })
22
+ await expect(client.getUser({ id: '1' })).rejects.toBeInstanceOf(ClientNetworkError)
23
+ })
24
+
25
+ it('exposes .safe returning ResultNoTyped', async () => {
26
+ const client = createClient({
27
+ adapter: { request: vi.fn(async () => ({ status: 500, headers: {}, body: {} })), stream: vi.fn(async () => { throw new Error('n/a') }) },
28
+ basePath: 'https://api.x',
29
+ scopes: (c) => ({
30
+ getUser: c.bindCallable<{ id: string }, { id: string }>({
31
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
32
+ }),
33
+ }),
34
+ })
35
+ const r = await client.getUser.safe({ id: '1' })
36
+ expect(r.ok).toBe(false)
37
+ if (!r.ok) {
38
+ expect(r.kind).toBe('http')
39
+ expect(r.error).toBeInstanceOf(ClientHttpError)
40
+ }
41
+ })
42
+
43
+ it('preserves descriptor.name as fn.name for stack traces', () => {
44
+ const client = createClient({
45
+ adapter: okAdapter,
46
+ basePath: 'https://api.x',
47
+ scopes: (c) => ({
48
+ getUser: c.bindCallable<{ id: string }, { id: string }>({
49
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
50
+ }),
51
+ }),
52
+ })
53
+ expect(client.getUser.name).toBe('GetUser')
54
+ })
55
+
56
+ it('resolves the response value on success', async () => {
57
+ const client = createClient({
58
+ adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })), stream: vi.fn(async () => { throw new Error('n/a') }) },
59
+ basePath: 'https://api.x',
60
+ scopes: (c) => ({
61
+ getUser: c.bindCallable<{ id: string }, { id: string }>({
62
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
63
+ }),
64
+ }),
65
+ })
66
+ const result = await client.getUser({ id: '1' })
67
+ expect(result).toEqual({ id: '42' })
68
+ })
69
+
70
+ it('.safe resolves with ok: true on success', async () => {
71
+ const client = createClient({
72
+ adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })), stream: vi.fn(async () => { throw new Error('n/a') }) },
73
+ basePath: 'https://api.x',
74
+ scopes: (c) => ({
75
+ getUser: c.bindCallable<{ id: string }, { id: string }>({
76
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
77
+ }),
78
+ }),
79
+ })
80
+ const r = await client.getUser.safe({ id: '1' })
81
+ expect(r.ok).toBe(true)
82
+ if (r.ok) {
83
+ expect(r.value).toEqual({ id: '42' })
84
+ }
85
+ })
86
+ })
87
+
88
+ describe('ClientInstance.bindCallableTyped', () => {
89
+ it('exposes .safe returning Result with typed errors', async () => {
90
+ class ApiUseCaseError extends Error {
91
+ constructor(public status: number) { super('use-case') }
92
+ }
93
+ const client = createClient({
94
+ adapter: { request: vi.fn(async () => ({ status: 500, headers: {}, body: {} })), stream: vi.fn(async () => { throw new Error('n/a') }) },
95
+ basePath: 'https://api.x',
96
+ scopes: (c) => ({
97
+ getUser: c.bindCallableTyped<{ id: string }, { id: string }, ApiUseCaseError>({
98
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
99
+ }),
100
+ }),
101
+ })
102
+ const r = await client.getUser.safe({ id: '1' })
103
+ expect(r.ok).toBe(false)
104
+ // Falls through to 'http' since no registry was wired — the test verifies
105
+ // the .safe surface exists, not the typed dispatch path.
106
+ if (!r.ok) {
107
+ expect(['http', 'typed', 'network', 'timeout', 'aborted', 'parse', 'usage', 'unknown']).toContain(r.kind)
108
+ }
109
+ })
110
+
111
+ it('preserves descriptor.name as fn.name for stack traces', () => {
112
+ const client = createClient({
113
+ adapter: okAdapter,
114
+ basePath: 'https://api.x',
115
+ scopes: (c) => ({
116
+ getUser: c.bindCallableTyped<{ id: string }, { id: string }, Error>({
117
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
118
+ }),
119
+ }),
120
+ })
121
+ expect(client.getUser.name).toBe('GetUser')
122
+ })
123
+
124
+ it('resolves the response value on success', async () => {
125
+ const client = createClient({
126
+ adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '99' } })), stream: vi.fn(async () => { throw new Error('n/a') }) },
127
+ basePath: 'https://api.x',
128
+ scopes: (c) => ({
129
+ getUser: c.bindCallableTyped<{ id: string }, { id: string }, Error>({
130
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
131
+ }),
132
+ }),
133
+ })
134
+ const result = await client.getUser({ id: '1' })
135
+ expect(result).toEqual({ id: '99' })
136
+ })
137
+ })
@@ -8,6 +8,7 @@ import type {
8
8
  ProcedureCallOptions,
9
9
  TypedStream,
10
10
  Result,
11
+ ResultNoTyped,
11
12
  } from './types.js'
12
13
 
13
14
  // ── createClient ──────────────────────────────────────────
@@ -72,6 +73,26 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
72
73
  })
73
74
  },
74
75
 
76
+ bindCallable<TParams, TResponse>(descriptor: Omit<CallDescriptor, 'params'>) {
77
+ const call = (params: TParams, options?: ProcedureCallOptions) =>
78
+ instance.call<TResponse>({ ...descriptor, params }, options)
79
+ Object.defineProperty(call, 'name', { value: descriptor.name, configurable: true })
80
+ return Object.assign(call, {
81
+ safe: (params: TParams, options?: ProcedureCallOptions) =>
82
+ instance.safeCall<TResponse>({ ...descriptor, params }, options) as Promise<ResultNoTyped<TResponse>>,
83
+ })
84
+ },
85
+
86
+ bindCallableTyped<TParams, TResponse, ETyped>(descriptor: Omit<CallDescriptor, 'params'>) {
87
+ const call = (params: TParams, options?: ProcedureCallOptions) =>
88
+ instance.call<TResponse>({ ...descriptor, params }, options)
89
+ Object.defineProperty(call, 'name', { value: descriptor.name, configurable: true })
90
+ return Object.assign(call, {
91
+ safe: (params: TParams, options?: ProcedureCallOptions) =>
92
+ instance.safeCall<TResponse, ETyped>({ ...descriptor, params }, options),
93
+ })
94
+ },
95
+
75
96
  stream<TYield, TReturn>(
76
97
  descriptor: StreamDescriptor,
77
98
  options?: ProcedureCallOptions,
@@ -237,6 +237,31 @@ export interface ClientInstance {
237
237
  options?: ProcedureCallOptions,
238
238
  ): Promise<Result<TResponse, ETyped>>
239
239
  stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
240
+ /**
241
+ * Wires a callable with a `.safe` sibling for routes without declared typed errors.
242
+ * Used by codegen to produce a compact one-line callable per route.
243
+ *
244
+ * The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
245
+ */
246
+ bindCallable<TParams, TResponse>(
247
+ descriptor: Omit<CallDescriptor, 'params'>,
248
+ ): {
249
+ (params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
250
+ safe(params: TParams, options?: ProcedureCallOptions): Promise<ResultNoTyped<TResponse>>
251
+ }
252
+ /**
253
+ * Wires a callable with a `.safe` sibling for routes with declared typed errors.
254
+ * The `.safe` form returns `Result<TResponse, ETyped>` where `ETyped` is the
255
+ * union of route-declared error classes.
256
+ *
257
+ * The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
258
+ */
259
+ bindCallableTyped<TParams, TResponse, ETyped>(
260
+ descriptor: Omit<CallDescriptor, 'params'>,
261
+ ): {
262
+ (params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
263
+ safe(params: TParams, options?: ProcedureCallOptions): Promise<Result<TResponse, ETyped>>
264
+ }
240
265
  }
241
266
 
242
267
  // ── createClient Config ──────────────────────────────────
@@ -59,7 +59,9 @@ describe('bundle size budget', () => {
59
59
  // and link back to spec section "Tests / item 10". For now this guard
60
60
  // catches catastrophic regressions only, not subtle bloat.
61
61
  //
62
- // PERROUTEDELTA_BASELINE: 613.2
62
+ // PERROUTEDELTA_BASELINE: 613.2 (7.0.0-beta.0, Object.assign inline emission)
63
+ // After bindCallable refactor (beta.1): baseline dropped to 218.8 (~64% reduction)
64
+ // — Object.assign + duplicated descriptor removed; one helper call per route.
63
65
  expect(perRouteDelta).toBeLessThan(1500)
64
66
  })
65
67
 
@@ -223,12 +223,12 @@ describe('E2E: generateClient full pipeline', () => {
223
223
  expect(content).toContain('bindUsersScope')
224
224
  })
225
225
 
226
- it('users.ts uses client.call for RPC route', async () => {
226
+ it('users.ts uses client.bindCallable for RPC route', async () => {
227
227
  tmpDir = makeTmpDir()
228
228
  await generateClient({ envelope, outDir: tmpDir })
229
229
 
230
230
  const content = readFileSync(join(tmpDir, 'users.ts'), 'utf-8')
231
- expect(content).toContain('client.call')
231
+ expect(content).toContain('client.bindCallable<')
232
232
  })
233
233
 
234
234
  // ── events.ts — Stream route ───────────────────────────────────────────────
@@ -764,10 +764,9 @@ void run
764
764
  await generateClient({ envelope, outDir: tmpDir, namespaceTypes: true })
765
765
 
766
766
  const content = readFileSync(join(tmpDir, 'users.ts'), 'utf-8')
767
- expect(content).toContain('params: Users.GetUser.Params')
768
- expect(content).toContain('Promise<Users.GetUser.Response>')
769
- expect(content).toContain('params: Users.UpdateUser.Params')
770
- expect(content).toContain('Promise<Users.UpdateUser.Response>')
767
+ // New emission: client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>({...})
768
+ expect(content).toContain('client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>')
769
+ expect(content).toContain('client.bindCallable<Users.UpdateUser.Params, Users.UpdateUser.Response>')
771
770
  })
772
771
 
773
772
  it('events.ts wraps stream types in namespace', async () => {
@@ -27,6 +27,7 @@ const TYPES_IMPORT = `import type {
27
27
  ClassifyErrorContext,
28
28
  ClassifiedError,
29
29
  Result,
30
+ ResultNoTyped,
30
31
  ClientErrorMap,
31
32
  FrameworkFailure,
32
33
  } from './_types'`
@@ -272,7 +272,7 @@ describe('emitScopeFile', () => {
272
272
 
273
273
  it('imports ClientInstance and ProcedureCallOptions but not TypedStream', async () => {
274
274
  const output = await emitScopeFile(rpcGroup)
275
- expect(output).toContain("import type { ClientInstance, ProcedureCallOptions, Result, ResultNoTyped } from 'ts-procedures/client'")
275
+ expect(output).toContain("import type { ClientInstance, ProcedureCallOptions } from 'ts-procedures/client'")
276
276
  expect(output).not.toContain('TypedStream')
277
277
  })
278
278
 
@@ -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 () => {
@@ -346,7 +346,7 @@ describe('emitScopeFile', () => {
346
346
  it('imports TypedStream when stream routes are present', async () => {
347
347
  const output = await emitScopeFile(streamGroup)
348
348
  expect(output).toContain('TypedStream')
349
- expect(output).toContain("import type { ClientInstance, ProcedureCallOptions, TypedStream, Result, ResultNoTyped } from 'ts-procedures/client'")
349
+ expect(output).toContain("import type { ClientInstance, ProcedureCallOptions, TypedStream } from 'ts-procedures/client'")
350
350
  })
351
351
 
352
352
  it('unwraps SSE envelope for yieldType', 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
  })
@@ -683,75 +685,74 @@ const rpcGroupNoErrors: ScopeGroup = {
683
685
  }
684
686
 
685
687
  describe('emitScopeFile .safe sibling on RPC', () => {
686
- it('emits .safe property on RPC callable when route has errors', async () => {
688
+ it('emits bindCallableTyped for RPC callable when route has errors', async () => {
687
689
  const out = await emitScopeFile(rpcGroupWithErrors, {
688
690
  namespaceTypes: true,
689
691
  errorKeys: new Set(['NotFound']),
690
692
  serviceName: 'Api',
691
693
  })
692
- // The callable should use Object.assign
693
- expect(out).toMatch(/Object\.assign/)
694
- // The safe variant should be a method
695
- expect(out).toMatch(/\bsafe\(/)
696
- // The safe variant's return type should be Result<Response, RouteErrors> (namespace-qualified)
697
- expect(out).toMatch(/Promise<Result<.*\.Response,.*\.Errors>>/)
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
698
  })
699
699
 
700
- it('emits .safe with ResultNoTyped when route has no errors', async () => {
700
+ it('emits bindCallable for RPC callable when route has no errors', async () => {
701
701
  const out = await emitScopeFile(rpcGroupNoErrors, {
702
702
  namespaceTypes: true,
703
703
  serviceName: 'Api',
704
704
  })
705
- // The callable should use Object.assign
706
- expect(out).toMatch(/Object\.assign/)
707
- // The safe variant should be a method
708
- expect(out).toMatch(/\bsafe\(/)
709
- // Without declared errors, use ResultNoTyped (namespace-qualified)
710
- expect(out).toMatch(/Promise<ResultNoTyped<.*\.Response>>/)
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')
711
709
  })
712
710
 
713
- it('imports Result and ResultNoTyped from client path', async () => {
711
+ it('scope files no longer directly import Result/ResultNoTyped (inferred from helper)', async () => {
714
712
  const out = await emitScopeFile(rpcGroupNoErrors, {
715
713
  serviceName: 'Api',
716
714
  })
717
- expect(out).toContain('Result')
718
- expect(out).toContain('ResultNoTyped')
715
+ // Result/ResultNoTyped are inferred from ClientInstance.bindCallable return type
716
+ expect(out).not.toContain('ResultNoTyped')
717
+ expect(out).not.toMatch(/import.*Result.*from/)
719
718
  expect(out).toContain("from 'ts-procedures/client'")
720
719
  })
721
720
 
722
- it('namespace mode: safe return type uses route Errors namespace alias', async () => {
721
+ it('namespace mode: uses route Errors namespace alias as third type arg', async () => {
723
722
  const out = await emitScopeFile(rpcGroupWithErrors, {
724
723
  namespaceTypes: true,
725
724
  errorKeys: new Set(['NotFound']),
726
725
  serviceName: 'Api',
727
726
  })
728
727
  // errorsRef in namespace mode is the route's Errors type alias: Scope.Route.Errors
729
- expect(out).toMatch(/Promise<Result<Users\.GetUser\.Response, Users\.GetUser\.Errors>>/)
728
+ expect(out).toContain('client.bindCallableTyped<Users.GetUser.Params, Users.GetUser.Response, Users.GetUser.Errors>')
730
729
  })
731
730
 
732
- it('flat mode: safe return type uses route Errors type alias', async () => {
731
+ it('flat mode: uses route Errors type alias as third type arg', async () => {
733
732
  const out = await emitScopeFile(rpcGroupWithErrors, {
734
733
  namespaceTypes: false,
735
734
  errorKeys: new Set(['NotFound']),
736
735
  serviceName: 'Api',
737
736
  })
738
737
  // errorsRef in flat mode is the injected GetUserErrors type alias
739
- expect(out).toMatch(/Promise<Result<GetUserResponse, GetUserErrors>>/)
738
+ expect(out).toContain('client.bindCallableTyped<GetUserParams, GetUserResponse, GetUserErrors>')
740
739
  })
741
740
 
742
- it('safe sibling calls client.safeCall with typed error param when errors present', async () => {
741
+ it('route with errors uses bindCallableTyped', async () => {
743
742
  const out = await emitScopeFile(rpcGroupWithErrors, {
744
743
  errorKeys: new Set(['NotFound']),
745
744
  serviceName: 'Api',
746
745
  })
747
- expect(out).toContain('client.safeCall')
746
+ expect(out).toContain('client.bindCallableTyped<')
747
+ expect(out).not.toContain('client.bindCallable<')
748
748
  })
749
749
 
750
- it('safe sibling calls client.safeCall without typed error param when no errors', async () => {
750
+ it('route without errors uses bindCallable (not bindCallableTyped)', async () => {
751
751
  const out = await emitScopeFile(rpcGroupNoErrors, {
752
752
  serviceName: 'Api',
753
753
  })
754
- expect(out).toContain('client.safeCall')
754
+ expect(out).toContain('client.bindCallable<')
755
+ expect(out).not.toContain('client.bindCallableTyped<')
755
756
  })
756
757
  })
757
758
 
@@ -825,78 +826,74 @@ const apiGroupNoErrors: ScopeGroup = {
825
826
  }
826
827
 
827
828
  describe('emitScopeFile .safe sibling on API', () => {
828
- it('emits .safe property on API callable when route has errors', async () => {
829
+ it('emits bindCallableTyped for API callable when route has errors', async () => {
829
830
  const out = await emitScopeFile(apiGroupWithErrors, {
830
831
  namespaceTypes: true,
831
832
  errorKeys: new Set(['NotFound']),
832
833
  serviceName: 'Api',
833
834
  })
834
- // The callable should use Object.assign
835
- expect(out).toMatch(/Object\.assign/)
836
- // The safe variant should be a method
837
- expect(out).toMatch(/\bsafe\(/)
838
- // The safe variant's return type should be Result<Response, RouteErrors> (namespace-qualified)
839
- expect(out).toMatch(/Promise<Result<.*\.Response,.*\.Errors>>/)
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')
840
839
  })
841
840
 
842
- it('emits .safe with ResultNoTyped when API route has no errors', async () => {
841
+ it('emits bindCallable for API callable when route has no errors', async () => {
843
842
  const out = await emitScopeFile(apiGroupNoErrors, {
844
843
  namespaceTypes: true,
845
844
  serviceName: 'Api',
846
845
  })
847
- // The callable should use Object.assign
848
- expect(out).toMatch(/Object\.assign/)
849
- // The safe variant should be a method
850
- expect(out).toMatch(/\bsafe\(/)
851
- // Without declared errors, use ResultNoTyped (namespace-qualified)
852
- expect(out).toMatch(/Promise<ResultNoTyped<.*\.Response>>/)
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')
853
850
  })
854
851
 
855
- it('namespace mode: safe return type uses route Errors namespace alias', async () => {
852
+ it('namespace mode: uses route Errors namespace alias as third type arg', async () => {
856
853
  const out = await emitScopeFile(apiGroupWithErrors, {
857
854
  namespaceTypes: true,
858
855
  errorKeys: new Set(['NotFound']),
859
856
  serviceName: 'Api',
860
857
  })
861
858
  // errorsRef in namespace mode is the route's Errors type alias: Scope.Route.Errors
862
- expect(out).toMatch(/Promise<Result<Posts\.UpdatePost\.Response, Posts\.UpdatePost\.Errors>>/)
859
+ expect(out).toContain('client.bindCallableTyped<Posts.UpdatePost.Params, Posts.UpdatePost.Response, Posts.UpdatePost.Errors>')
863
860
  })
864
861
 
865
- it('flat mode: safe return type uses route Errors type alias', async () => {
862
+ it('flat mode: uses route Errors type alias as third type arg', async () => {
866
863
  const out = await emitScopeFile(apiGroupWithErrors, {
867
864
  namespaceTypes: false,
868
865
  errorKeys: new Set(['NotFound']),
869
866
  serviceName: 'Api',
870
867
  })
871
868
  // errorsRef in flat mode is the injected UpdatePostErrors type alias
872
- expect(out).toMatch(/Promise<Result<UpdatePostResponse, UpdatePostErrors>>/)
869
+ expect(out).toContain('client.bindCallableTyped<UpdatePostParams, UpdatePostResponse, UpdatePostErrors>')
873
870
  })
874
871
 
875
- it('safe sibling calls client.safeCall when API route has errors', async () => {
872
+ it('route with errors uses bindCallableTyped', async () => {
876
873
  const out = await emitScopeFile(apiGroupWithErrors, {
877
874
  errorKeys: new Set(['NotFound']),
878
875
  serviceName: 'Api',
879
876
  })
880
- expect(out).toContain('client.safeCall')
877
+ expect(out).toContain('client.bindCallableTyped<')
878
+ expect(out).not.toContain('client.bindCallable<')
881
879
  })
882
880
 
883
- it('safe sibling calls client.safeCall when API route has no errors', async () => {
881
+ it('route without errors uses bindCallable (not bindCallableTyped)', async () => {
884
882
  const out = await emitScopeFile(apiGroupNoErrors, {
885
883
  serviceName: 'Api',
886
884
  })
887
- expect(out).toContain('client.safeCall')
885
+ expect(out).toContain('client.bindCallable<')
886
+ expect(out).not.toContain('client.bindCallableTyped<')
888
887
  })
889
888
 
890
- it('callable uses fullPath not path in both main and safe sibling', async () => {
889
+ it('callable uses fullPath not path in the descriptor', async () => {
891
890
  const out = await emitScopeFile(apiGroupWithErrors, {
892
891
  errorKeys: new Set(['NotFound']),
893
892
  serviceName: 'Api',
894
893
  })
895
- // Both the main call and safeCall should reference the fullPath
896
- const safeCallIdx = out.indexOf('client.safeCall')
897
- expect(safeCallIdx).toBeGreaterThan(-1)
898
- const afterSafeCall = out.slice(safeCallIdx)
899
- expect(afterSafeCall).toContain("path: '/api/posts/:id'")
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'")
900
897
  })
901
898
  })
902
899
 
@@ -916,6 +913,8 @@ describe('emitScopeFile streams omit .safe sibling', () => {
916
913
  expect(out).not.toContain('WatchEvents.safe')
917
914
  // Object.assign wrapper is not used for stream routes
918
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/)
919
918
  })
920
919
 
921
920
  it('stream callable returns TypedStream, not Result', async () => {
@@ -942,5 +941,7 @@ describe('emitScopeFile streams omit .safe sibling', () => {
942
941
  // But still no .safe property
943
942
  expect(out).not.toContain('WatchEvents.safe')
944
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/)
945
946
  })
946
947
  })