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.
- 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 +215 -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/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 +15 -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 +96 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +68 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +103 -1
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +7 -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 +94 -26
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +297 -2
- 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/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 +39 -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 +112 -3
- package/src/codegen/bundle-size.test.ts +74 -0
- package/src/codegen/e2e.test.ts +108 -1
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +7 -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 +324 -2
- package/src/codegen/emit-scope.ts +98 -36
package/src/codegen/e2e.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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('
|
|
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
|
-
//
|
|
36
|
-
|
|
37
|
-
expect(result).
|
|
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
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 `
|
|
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 \`
|
|
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
|
+
})
|