ts-procedures 6.2.0 → 7.0.0-beta.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 (109) 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 +215 -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/call.d.ts +14 -2
  13. package/build/client/call.js +96 -9
  14. package/build/client/call.js.map +1 -1
  15. package/build/client/call.test.js +50 -1
  16. package/build/client/call.test.js.map +1 -1
  17. package/build/client/classify-error.d.ts +11 -0
  18. package/build/client/classify-error.js +49 -0
  19. package/build/client/classify-error.js.map +1 -0
  20. package/build/client/classify-error.test.d.ts +1 -0
  21. package/build/client/classify-error.test.js +55 -0
  22. package/build/client/classify-error.test.js.map +1 -0
  23. package/build/client/error-dispatch.d.ts +1 -1
  24. package/build/client/error-dispatch.js +1 -1
  25. package/build/client/errors.d.ts +55 -4
  26. package/build/client/errors.js +54 -7
  27. package/build/client/errors.js.map +1 -1
  28. package/build/client/errors.test.js +89 -4
  29. package/build/client/errors.test.js.map +1 -1
  30. package/build/client/fetch-adapter.d.ts +2 -1
  31. package/build/client/fetch-adapter.js +2 -1
  32. package/build/client/fetch-adapter.js.map +1 -1
  33. package/build/client/fetch-adapter.test.js +12 -0
  34. package/build/client/fetch-adapter.test.js.map +1 -1
  35. package/build/client/index.d.ts +5 -3
  36. package/build/client/index.js +15 -3
  37. package/build/client/index.js.map +1 -1
  38. package/build/client/resolve-options.d.ts +32 -1
  39. package/build/client/resolve-options.js +32 -16
  40. package/build/client/resolve-options.js.map +1 -1
  41. package/build/client/resolve-options.test.js +67 -6
  42. package/build/client/resolve-options.test.js.map +1 -1
  43. package/build/client/result-type.test-d.d.ts +1 -0
  44. package/build/client/result-type.test-d.js +28 -0
  45. package/build/client/result-type.test-d.js.map +1 -0
  46. package/build/client/safe-call.test.d.ts +1 -0
  47. package/build/client/safe-call.test.js +137 -0
  48. package/build/client/safe-call.test.js.map +1 -0
  49. package/build/client/stream.d.ts +1 -1
  50. package/build/client/stream.js +22 -8
  51. package/build/client/stream.js.map +1 -1
  52. package/build/client/stream.test.js +11 -1
  53. package/build/client/stream.test.js.map +1 -1
  54. package/build/client/types.d.ts +96 -3
  55. package/build/codegen/bundle-size.test.d.ts +1 -0
  56. package/build/codegen/bundle-size.test.js +68 -0
  57. package/build/codegen/bundle-size.test.js.map +1 -0
  58. package/build/codegen/e2e.test.js +103 -1
  59. package/build/codegen/e2e.test.js.map +1 -1
  60. package/build/codegen/emit-client-runtime.js +7 -0
  61. package/build/codegen/emit-client-runtime.js.map +1 -1
  62. package/build/codegen/emit-client-runtime.test.js +6 -2
  63. package/build/codegen/emit-client-runtime.test.js.map +1 -1
  64. package/build/codegen/emit-client-types.d.ts +7 -2
  65. package/build/codegen/emit-client-types.js +29 -8
  66. package/build/codegen/emit-client-types.js.map +1 -1
  67. package/build/codegen/emit-client-types.test.js +20 -8
  68. package/build/codegen/emit-client-types.test.js.map +1 -1
  69. package/build/codegen/emit-errors.d.ts +1 -1
  70. package/build/codegen/emit-errors.js +1 -1
  71. package/build/codegen/emit-index.js +1 -1
  72. package/build/codegen/emit-index.js.map +1 -1
  73. package/build/codegen/emit-scope.js +94 -26
  74. package/build/codegen/emit-scope.js.map +1 -1
  75. package/build/codegen/emit-scope.test.js +297 -2
  76. package/build/codegen/emit-scope.test.js.map +1 -1
  77. package/docs/client-and-codegen.md +77 -7
  78. package/docs/client-error-handling.md +357 -0
  79. package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
  80. package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
  81. package/package.json +1 -1
  82. package/src/client/augment-error-map.test-d.ts +22 -0
  83. package/src/client/call.test.ts +65 -1
  84. package/src/client/call.ts +111 -9
  85. package/src/client/classify-error.test.ts +65 -0
  86. package/src/client/classify-error.ts +59 -0
  87. package/src/client/error-dispatch.ts +1 -1
  88. package/src/client/errors.test.ts +108 -4
  89. package/src/client/errors.ts +70 -7
  90. package/src/client/fetch-adapter.test.ts +15 -0
  91. package/src/client/fetch-adapter.ts +5 -2
  92. package/src/client/index.ts +39 -3
  93. package/src/client/resolve-options.test.ts +83 -5
  94. package/src/client/resolve-options.ts +61 -16
  95. package/src/client/result-type.test-d.ts +51 -0
  96. package/src/client/safe-call.test.ts +157 -0
  97. package/src/client/stream.test.ts +13 -1
  98. package/src/client/stream.ts +25 -8
  99. package/src/client/types.ts +112 -3
  100. package/src/codegen/bundle-size.test.ts +74 -0
  101. package/src/codegen/e2e.test.ts +108 -1
  102. package/src/codegen/emit-client-runtime.test.ts +7 -2
  103. package/src/codegen/emit-client-runtime.ts +7 -0
  104. package/src/codegen/emit-client-types.test.ts +22 -7
  105. package/src/codegen/emit-client-types.ts +35 -10
  106. package/src/codegen/emit-errors.ts +1 -1
  107. package/src/codegen/emit-index.ts +1 -1
  108. package/src/codegen/emit-scope.test.ts +324 -2
  109. package/src/codegen/emit-scope.ts +98 -36
@@ -422,7 +422,7 @@ describe('E2E: generateClient full pipeline', () => {
422
422
  expect(content).toContain('ProcedureCallOptions')
423
423
  })
424
424
 
425
- it('_client.ts is generated and contains createClient, createFetchAdapter, ClientRequestError', async () => {
425
+ it('_client.ts is generated and contains createClient, createFetchAdapter, ClientHttpError (+ deprecated ClientRequestError alias)', async () => {
426
426
  tmpDir = makeTmpDir()
427
427
  await generateClient({ envelope, outDir: tmpDir, selfContained: true })
428
428
 
@@ -430,6 +430,7 @@ describe('E2E: generateClient full pipeline', () => {
430
430
  const content = readFileSync(join(tmpDir, '_client.ts'), 'utf-8')
431
431
  expect(content).toContain('createClient')
432
432
  expect(content).toContain('createFetchAdapter')
433
+ expect(content).toContain('ClientHttpError')
433
434
  expect(content).toContain('ClientRequestError')
434
435
  })
435
436
 
@@ -584,6 +585,112 @@ void run
584
585
  }).not.toThrow()
585
586
  })
586
587
 
588
+ it('generated .safe callable type-checks against bundled Result/ResultNoTyped types', async () => {
589
+ // Use an envelope with two routes:
590
+ // - GetUser: has errors: ['ProcedureError'] → .safe returns Result<T, GetUserErrors>
591
+ // - ListUsers: has errors: [] → .safe returns ResultNoTyped<T>
592
+ // This verifies both the typed-error and no-typed-error branches of .safe.
593
+ tmpDir = makeTmpDir()
594
+ const safeEnvelope: DocEnvelope = {
595
+ basePath: '/api',
596
+ headers: [],
597
+ errors: [procedureErrorDoc],
598
+ routes: [
599
+ {
600
+ kind: 'rpc',
601
+ name: 'GetUser',
602
+ path: '/users/1',
603
+ method: 'post',
604
+ scope: 'users',
605
+ version: 1,
606
+ errors: ['ProcedureError'],
607
+ jsonSchema: {
608
+ body: {
609
+ type: 'object',
610
+ properties: { id: { type: 'string' } },
611
+ required: ['id'],
612
+ },
613
+ response: {
614
+ type: 'object',
615
+ properties: { name: { type: 'string' } },
616
+ required: ['name'],
617
+ },
618
+ },
619
+ } as RPCHttpRouteDoc,
620
+ {
621
+ kind: 'rpc',
622
+ name: 'ListUsers',
623
+ path: '/users',
624
+ method: 'post',
625
+ scope: 'users',
626
+ version: 1,
627
+ errors: [],
628
+ jsonSchema: {
629
+ body: { type: 'object' },
630
+ response: {
631
+ type: 'array',
632
+ items: {
633
+ type: 'object',
634
+ properties: { name: { type: 'string' } },
635
+ required: ['name'],
636
+ },
637
+ },
638
+ },
639
+ } as RPCHttpRouteDoc,
640
+ ],
641
+ }
642
+
643
+ await generateClient({ envelope: safeEnvelope, outDir: tmpDir, selfContained: true })
644
+
645
+ // Consumer exercises .safe on both routes and verifies the Result types
646
+ // are assignable (type-only assertions via declared variables).
647
+ const consumer = `
648
+ import type { Result, ResultNoTyped } from './_types'
649
+ import { createApiClient } from './index'
650
+ import { createFetchAdapter } from './_client'
651
+
652
+ const client = createApiClient({
653
+ adapter: createFetchAdapter(),
654
+ basePath: 'https://api.example.com',
655
+ })
656
+
657
+ // Throwing form still compiles
658
+ const _p1: Promise<{ name: string }> = client.users.GetUser({ id: '1' })
659
+ void _p1
660
+
661
+ // .safe on a route with declared errors returns Result<T, Errors>
662
+ const _p2 = client.users.GetUser.safe({ id: '1' })
663
+ const _p2check: Promise<Result<{ name: string }, unknown>> = _p2 as Promise<Result<{ name: string }, unknown>>
664
+ void _p2check
665
+
666
+ // .safe on a route without declared errors returns ResultNoTyped<T>
667
+ const _p3 = client.users.ListUsers.safe({})
668
+ const _p3check: Promise<ResultNoTyped<unknown>> = _p3 as Promise<ResultNoTyped<unknown>>
669
+ void _p3check
670
+ `
671
+ const { writeFileSync } = await import('node:fs')
672
+ writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
673
+
674
+ const tsconfig = {
675
+ compilerOptions: {
676
+ strict: true,
677
+ target: 'ES2022',
678
+ module: 'ES2022',
679
+ moduleResolution: 'bundler',
680
+ noEmit: true,
681
+ skipLibCheck: true,
682
+ },
683
+ include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', '_errors.ts', 'consumer.ts'],
684
+ }
685
+ writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
686
+
687
+ const { execSync } = await import('node:child_process')
688
+ const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
689
+ expect(() => {
690
+ execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
691
+ }).not.toThrow()
692
+ })
693
+
587
694
  it('augmented RequestMeta rejects wrong types (compile error)', async () => {
588
695
  tmpDir = makeTmpDir()
589
696
  await generateClient({ envelope, outDir: tmpDir, selfContained: true })
@@ -46,9 +46,14 @@ describe('emitClientRuntimeFile', () => {
46
46
  expect(result).toMatch(/export function createFetchAdapter/)
47
47
  })
48
48
 
49
- it('contains export class ClientRequestError', async () => {
49
+ it('contains export class ClientHttpError (renamed from ClientRequestError)', async () => {
50
50
  const result = await emitClientRuntimeFile()
51
- expect(result).toMatch(/export class ClientRequestError/)
51
+ expect(result).toMatch(/export class ClientHttpError/)
52
+ })
53
+
54
+ it('contains deprecated ClientRequestError alias', async () => {
55
+ const result = await emitClientRuntimeFile()
56
+ expect(result).toContain('ClientRequestError')
52
57
  })
53
58
 
54
59
  it('contains export class ClientPathParamError', async () => {
@@ -23,6 +23,12 @@ const TYPES_IMPORT = `import type {
23
23
  ErrorRegistry,
24
24
  ErrorFactory,
25
25
  ErrorResponseMeta,
26
+ ErrorClassifier,
27
+ ClassifyErrorContext,
28
+ ClassifiedError,
29
+ Result,
30
+ ClientErrorMap,
31
+ FrameworkFailure,
26
32
  } from './_types'`
27
33
 
28
34
  /**
@@ -31,6 +37,7 @@ const TYPES_IMPORT = `import type {
31
37
  */
32
38
  const SOURCE_FILES = [
33
39
  'errors.ts',
40
+ 'classify-error.ts',
34
41
  'error-dispatch.ts',
35
42
  'request-builder.ts',
36
43
  'resolve-options.ts',
@@ -19,21 +19,36 @@ describe('emitClientTypesFile', () => {
19
19
 
20
20
  it('no import statements', async () => {
21
21
  const result = await emitClientTypesFile()
22
- // types.ts has zero imports — the emitted file should be fully self-contained
22
+ // errors.ts and types.ts imports are stripped — the emitted file should be fully self-contained
23
23
  expect(result).not.toMatch(/^import\s/m)
24
24
  })
25
25
 
26
- it('output content (after header) matches src/client/types.ts verbatim', async () => {
26
+ it('includes errors.ts content before types.ts content', async () => {
27
27
  const result = await emitClientTypesFile()
28
28
 
29
29
  const __filename = fileURLToPath(import.meta.url)
30
30
  const __dirname = dirname(__filename)
31
31
  const packageRoot = resolve(__dirname, '../..')
32
- const typesPath = resolve(packageRoot, 'src/client/types.ts')
33
- const typesContent = await readFile(typesPath, 'utf-8')
34
32
 
35
- // The function prepends CODEGEN_HEADER + '\n' + '\n' before the file content
36
- const expected = [CODEGEN_HEADER, '', typesContent].join('\n')
37
- expect(result).toBe(expected)
33
+ // Check that both files' unique identifiers are present
34
+ // errors.ts contains class definitions
35
+ expect(result).toContain('class ClientHttpError')
36
+ expect(result).toContain('class ClientNetworkError')
37
+ // types.ts contains interfaces
38
+ expect(result).toContain('interface RequestMeta')
39
+ expect(result).toContain('interface ClientAdapter')
40
+
41
+ // errors.ts content should appear before types.ts content
42
+ const errorsIdx = result.indexOf('class ClientHttpError')
43
+ const typesIdx = result.indexOf('interface RequestMeta')
44
+ expect(errorsIdx).toBeLessThan(typesIdx)
45
+ })
46
+
47
+ it('includes Result, ResultNoTyped, ClientErrorMap from types.ts', async () => {
48
+ const result = await emitClientTypesFile()
49
+ expect(result).toContain('interface ClientErrorMap')
50
+ expect(result).toContain('type FrameworkFailure')
51
+ expect(result).toContain('type Result<T, ETyped>')
52
+ expect(result).toContain('type ResultNoTyped<T>')
38
53
  })
39
54
  })
@@ -4,8 +4,26 @@ import { readFile, access } from 'node:fs/promises'
4
4
  import { CODEGEN_HEADER } from './constants.js'
5
5
 
6
6
  /**
7
- * Reads `src/client/types.ts` from the package root and returns it as the
8
- * content of a `_types.ts` file, prepended with the auto-generated header.
7
+ * Strips all `import` statements from source content.
8
+ * These are inter-file imports that become unnecessary in a single bundled file.
9
+ *
10
+ * Note: the regex requires a `from` clause, so side-effect imports like
11
+ * `import './polyfill.js'` are NOT stripped. This is intentional — src/client/
12
+ * has no side-effect imports.
13
+ */
14
+ function stripImports(content: string): string {
15
+ // Handle both single-line and multi-line import statements
16
+ return content.replace(/^import\s[\s\S]*?from\s+['"][^'"]+['"]\s*;?\s*$/gm, '')
17
+ }
18
+
19
+ /**
20
+ * Reads `src/client/errors.ts` and `src/client/types.ts` from the package
21
+ * root and returns them bundled as the content of a `_types.ts` file,
22
+ * prepended with the auto-generated header.
23
+ *
24
+ * `errors.ts` is included first (with imports stripped) because `types.ts`
25
+ * imports the error classes from it. Together they form a fully self-contained
26
+ * `_types.ts` with no external dependencies.
9
27
  *
10
28
  * This enables a self-contained codegen mode where consumers don't need
11
29
  * `ts-procedures` as a runtime dependency.
@@ -15,13 +33,20 @@ export async function emitClientTypesFile(): Promise<string> {
15
33
  const __dirname = dirname(__filename)
16
34
  // Works from both src/codegen/ (source) and build/codegen/ (compiled)
17
35
  const packageRoot = resolve(__dirname, '../..')
36
+ const errorsPath = resolve(packageRoot, 'src/client/errors.ts')
18
37
  const typesPath = resolve(packageRoot, 'src/client/types.ts')
19
- await access(typesPath).catch(() => {
20
- throw new Error(
21
- `[ts-procedures-codegen] Cannot locate src/client/types.ts at expected path: ${typesPath}. ` +
22
- `Ensure ts-procedures is installed correctly.`
23
- )
24
- })
25
- const content = await readFile(typesPath, 'utf-8')
26
- return [CODEGEN_HEADER, '', content].join('\n')
38
+
39
+ for (const [label, filePath] of [['errors.ts', errorsPath], ['types.ts', typesPath]] as const) {
40
+ await access(filePath).catch(() => {
41
+ throw new Error(
42
+ `[ts-procedures-codegen] Cannot locate src/client/${label} at expected path: ${filePath}. ` +
43
+ `Ensure ts-procedures is installed correctly.`
44
+ )
45
+ })
46
+ }
47
+
48
+ const errorsContent = stripImports(await readFile(errorsPath, 'utf-8')).trim()
49
+ const typesContent = stripImports(await readFile(typesPath, 'utf-8')).trim()
50
+
51
+ return [CODEGEN_HEADER, '', errorsContent, '', typesContent, ''].join('\n')
27
52
  }
@@ -26,7 +26,7 @@ export interface EmitErrorsOptions {
26
26
  *
27
27
  * The registry `<ServiceName>ErrorRegistry` maps body `name` values to
28
28
  * classes, consumed by the client's `dispatchTypedError` to produce typed
29
- * errors instead of generic `ClientRequestError` instances.
29
+ * errors instead of generic `ClientHttpError` instances.
30
30
  *
31
31
  * When `namespaceTypes` is on, everything is wrapped in `export namespace
32
32
  * <ServiceName>Errors { ... }`. Returns `undefined` if no errors have schemas.
@@ -112,7 +112,7 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
112
112
  ` * Creates a typed client for this service with the generated error`,
113
113
  ` * registry pre-configured. Non-2xx responses whose body \`name\` matches`,
114
114
  ` * a registered error are thrown as typed class instances instead of`,
115
- ` * generic \`ClientRequestError\`s.`,
115
+ ` * generic \`ClientHttpError\`s.`,
116
116
  ` */`,
117
117
  `export function ${clientFactoryName}(`,
118
118
  ` config: Omit<CreateClientConfig<ReturnType<typeof ${factoryName}>>, 'scopes' | 'errorRegistry'>`,
@@ -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 } from 'ts-procedures/client'")
275
+ expect(output).toContain("import type { ClientInstance, ProcedureCallOptions, Result, ResultNoTyped } from 'ts-procedures/client'")
276
276
  expect(output).not.toContain('TypedStream')
277
277
  })
278
278
 
@@ -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 } from 'ts-procedures/client'")
349
+ expect(output).toContain("import type { ClientInstance, ProcedureCallOptions, TypedStream, Result, ResultNoTyped } from 'ts-procedures/client'")
350
350
  })
351
351
 
352
352
  it('unwraps SSE envelope for yieldType', async () => {
@@ -622,3 +622,325 @@ describe('emitScopeFile', () => {
622
622
  })
623
623
  })
624
624
  })
625
+
626
+ // ---------------------------------------------------------------------------
627
+ // .safe sibling on RPC callables
628
+ // ---------------------------------------------------------------------------
629
+
630
+ const rpcGroupWithErrors: ScopeGroup = {
631
+ scopeKey: 'users',
632
+ camelCase: 'users',
633
+ routes: [
634
+ {
635
+ kind: 'rpc',
636
+ name: 'GetUser',
637
+ path: '/users/1',
638
+ method: 'post',
639
+ scope: 'users',
640
+ version: 1,
641
+ errors: ['NotFound'],
642
+ jsonSchema: {
643
+ body: {
644
+ type: 'object',
645
+ properties: { id: { type: 'string' } },
646
+ required: ['id'],
647
+ },
648
+ response: {
649
+ type: 'object',
650
+ properties: { name: { type: 'string' } },
651
+ required: ['name'],
652
+ },
653
+ },
654
+ } satisfies RPCHttpRouteDoc,
655
+ ],
656
+ }
657
+
658
+ const rpcGroupNoErrors: ScopeGroup = {
659
+ scopeKey: 'users',
660
+ camelCase: 'users',
661
+ routes: [
662
+ {
663
+ kind: 'rpc',
664
+ name: 'GetUser',
665
+ path: '/users/1',
666
+ method: 'post',
667
+ scope: 'users',
668
+ version: 1,
669
+ jsonSchema: {
670
+ body: {
671
+ type: 'object',
672
+ properties: { id: { type: 'string' } },
673
+ required: ['id'],
674
+ },
675
+ response: {
676
+ type: 'object',
677
+ properties: { name: { type: 'string' } },
678
+ required: ['name'],
679
+ },
680
+ },
681
+ } satisfies RPCHttpRouteDoc,
682
+ ],
683
+ }
684
+
685
+ describe('emitScopeFile .safe sibling on RPC', () => {
686
+ it('emits .safe property on RPC callable when route has errors', async () => {
687
+ const out = await emitScopeFile(rpcGroupWithErrors, {
688
+ namespaceTypes: true,
689
+ errorKeys: new Set(['NotFound']),
690
+ serviceName: 'Api',
691
+ })
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>>/)
698
+ })
699
+
700
+ it('emits .safe with ResultNoTyped when route has no errors', async () => {
701
+ const out = await emitScopeFile(rpcGroupNoErrors, {
702
+ namespaceTypes: true,
703
+ serviceName: 'Api',
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>>/)
711
+ })
712
+
713
+ it('imports Result and ResultNoTyped from client path', async () => {
714
+ const out = await emitScopeFile(rpcGroupNoErrors, {
715
+ serviceName: 'Api',
716
+ })
717
+ expect(out).toContain('Result')
718
+ expect(out).toContain('ResultNoTyped')
719
+ expect(out).toContain("from 'ts-procedures/client'")
720
+ })
721
+
722
+ it('namespace mode: safe return type uses route Errors namespace alias', async () => {
723
+ const out = await emitScopeFile(rpcGroupWithErrors, {
724
+ namespaceTypes: true,
725
+ errorKeys: new Set(['NotFound']),
726
+ serviceName: 'Api',
727
+ })
728
+ // 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>>/)
730
+ })
731
+
732
+ it('flat mode: safe return type uses route Errors type alias', async () => {
733
+ const out = await emitScopeFile(rpcGroupWithErrors, {
734
+ namespaceTypes: false,
735
+ errorKeys: new Set(['NotFound']),
736
+ serviceName: 'Api',
737
+ })
738
+ // errorsRef in flat mode is the injected GetUserErrors type alias
739
+ expect(out).toMatch(/Promise<Result<GetUserResponse, GetUserErrors>>/)
740
+ })
741
+
742
+ it('safe sibling calls client.safeCall with typed error param when errors present', async () => {
743
+ const out = await emitScopeFile(rpcGroupWithErrors, {
744
+ errorKeys: new Set(['NotFound']),
745
+ serviceName: 'Api',
746
+ })
747
+ expect(out).toContain('client.safeCall')
748
+ })
749
+
750
+ it('safe sibling calls client.safeCall without typed error param when no errors', async () => {
751
+ const out = await emitScopeFile(rpcGroupNoErrors, {
752
+ serviceName: 'Api',
753
+ })
754
+ expect(out).toContain('client.safeCall')
755
+ })
756
+ })
757
+
758
+ // ---------------------------------------------------------------------------
759
+ // .safe sibling on API callables
760
+ // ---------------------------------------------------------------------------
761
+
762
+ const apiGroupWithErrors: ScopeGroup = {
763
+ scopeKey: 'posts',
764
+ camelCase: 'posts',
765
+ routes: [
766
+ {
767
+ kind: 'api',
768
+ name: 'UpdatePost',
769
+ path: '/posts/:id',
770
+ method: 'put',
771
+ fullPath: '/api/posts/:id',
772
+ scope: 'posts',
773
+ errors: ['NotFound'],
774
+ jsonSchema: {
775
+ pathParams: {
776
+ type: 'object',
777
+ properties: { id: { type: 'string' } },
778
+ required: ['id'],
779
+ },
780
+ body: {
781
+ type: 'object',
782
+ properties: { title: { type: 'string' } },
783
+ required: ['title'],
784
+ },
785
+ response: {
786
+ type: 'object',
787
+ properties: { id: { type: 'string' }, title: { type: 'string' } },
788
+ required: ['id', 'title'],
789
+ },
790
+ },
791
+ } satisfies APIHttpRouteDoc,
792
+ ],
793
+ }
794
+
795
+ const apiGroupNoErrors: ScopeGroup = {
796
+ scopeKey: 'posts',
797
+ camelCase: 'posts',
798
+ routes: [
799
+ {
800
+ kind: 'api',
801
+ name: 'UpdatePost',
802
+ path: '/posts/:id',
803
+ method: 'put',
804
+ fullPath: '/api/posts/:id',
805
+ scope: 'posts',
806
+ jsonSchema: {
807
+ pathParams: {
808
+ type: 'object',
809
+ properties: { id: { type: 'string' } },
810
+ required: ['id'],
811
+ },
812
+ body: {
813
+ type: 'object',
814
+ properties: { title: { type: 'string' } },
815
+ required: ['title'],
816
+ },
817
+ response: {
818
+ type: 'object',
819
+ properties: { id: { type: 'string' }, title: { type: 'string' } },
820
+ required: ['id', 'title'],
821
+ },
822
+ },
823
+ } satisfies APIHttpRouteDoc,
824
+ ],
825
+ }
826
+
827
+ describe('emitScopeFile .safe sibling on API', () => {
828
+ it('emits .safe property on API callable when route has errors', async () => {
829
+ const out = await emitScopeFile(apiGroupWithErrors, {
830
+ namespaceTypes: true,
831
+ errorKeys: new Set(['NotFound']),
832
+ serviceName: 'Api',
833
+ })
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>>/)
840
+ })
841
+
842
+ it('emits .safe with ResultNoTyped when API route has no errors', async () => {
843
+ const out = await emitScopeFile(apiGroupNoErrors, {
844
+ namespaceTypes: true,
845
+ serviceName: 'Api',
846
+ })
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>>/)
853
+ })
854
+
855
+ it('namespace mode: safe return type uses route Errors namespace alias', async () => {
856
+ const out = await emitScopeFile(apiGroupWithErrors, {
857
+ namespaceTypes: true,
858
+ errorKeys: new Set(['NotFound']),
859
+ serviceName: 'Api',
860
+ })
861
+ // 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>>/)
863
+ })
864
+
865
+ it('flat mode: safe return type uses route Errors type alias', async () => {
866
+ const out = await emitScopeFile(apiGroupWithErrors, {
867
+ namespaceTypes: false,
868
+ errorKeys: new Set(['NotFound']),
869
+ serviceName: 'Api',
870
+ })
871
+ // errorsRef in flat mode is the injected UpdatePostErrors type alias
872
+ expect(out).toMatch(/Promise<Result<UpdatePostResponse, UpdatePostErrors>>/)
873
+ })
874
+
875
+ it('safe sibling calls client.safeCall when API route has errors', async () => {
876
+ const out = await emitScopeFile(apiGroupWithErrors, {
877
+ errorKeys: new Set(['NotFound']),
878
+ serviceName: 'Api',
879
+ })
880
+ expect(out).toContain('client.safeCall')
881
+ })
882
+
883
+ it('safe sibling calls client.safeCall when API route has no errors', async () => {
884
+ const out = await emitScopeFile(apiGroupNoErrors, {
885
+ serviceName: 'Api',
886
+ })
887
+ expect(out).toContain('client.safeCall')
888
+ })
889
+
890
+ it('callable uses fullPath not path in both main and safe sibling', async () => {
891
+ const out = await emitScopeFile(apiGroupWithErrors, {
892
+ errorKeys: new Set(['NotFound']),
893
+ serviceName: 'Api',
894
+ })
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'")
900
+ })
901
+ })
902
+
903
+ // ---------------------------------------------------------------------------
904
+ // Stream callables omit .safe sibling (regression guard for Task 14)
905
+ // ---------------------------------------------------------------------------
906
+
907
+ describe('emitScopeFile streams omit .safe sibling', () => {
908
+ it('does not emit .safe on stream callables', async () => {
909
+ const out = await emitScopeFile(streamGroup, {
910
+ serviceName: 'Api',
911
+ })
912
+
913
+ // Stream callable should be emitted as a plain method
914
+ expect(out).toContain('WatchEvents(')
915
+ // No .safe property on stream callables
916
+ expect(out).not.toContain('WatchEvents.safe')
917
+ // Object.assign wrapper is not used for stream routes
918
+ expect(out).not.toMatch(/WatchEvents\s*:\s*Object\.assign/)
919
+ })
920
+
921
+ it('stream callable returns TypedStream, not Result', async () => {
922
+ const out = await emitScopeFile(streamGroup, {
923
+ serviceName: 'Api',
924
+ })
925
+
926
+ // Confirm stream callable returns TypedStream
927
+ expect(out).toContain('TypedStream<')
928
+ // Should not have Result or ResultNoTyped in the return type signature for streams
929
+ // (streams have their own three-way failure surface)
930
+ expect(out).toMatch(/WatchEvents\([^)]*\)\s*:\s*TypedStream/)
931
+ })
932
+
933
+ it('stream callable with namespace types still omits .safe', async () => {
934
+ const out = await emitScopeFile(streamGroup, {
935
+ namespaceTypes: true,
936
+ serviceName: 'Api',
937
+ })
938
+
939
+ // Stream callable should use namespace-qualified types
940
+ expect(out).toContain('Events.WatchEvents.Params')
941
+ expect(out).toContain('TypedStream<Events.WatchEvents.Yield, Events.WatchEvents.Return>')
942
+ // But still no .safe property
943
+ expect(out).not.toContain('WatchEvents.safe')
944
+ expect(out).not.toMatch(/WatchEvents\s*:\s*Object\.assign/)
945
+ })
946
+ })