ts-procedures 5.16.0 → 6.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.
- package/README.md +2 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +163 -5
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +169 -13
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
- package/agent_config/copilot/copilot-instructions.md +77 -12
- package/agent_config/cursor/cursorrules +77 -12
- package/build/client/call.d.ts +2 -1
- package/build/client/call.js +9 -1
- package/build/client/call.js.map +1 -1
- package/build/client/error-dispatch.d.ts +13 -0
- package/build/client/error-dispatch.js +26 -0
- package/build/client/error-dispatch.js.map +1 -0
- package/build/client/error-dispatch.test.d.ts +1 -0
- package/build/client/error-dispatch.test.js +56 -0
- package/build/client/error-dispatch.test.js.map +1 -0
- package/build/client/fetch-adapter.js +10 -4
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/index.d.ts +2 -1
- package/build/client/index.js +5 -1
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +2 -1
- package/build/client/stream.js +13 -3
- package/build/client/stream.js.map +1 -1
- package/build/client/typed-error-dispatch.test.d.ts +1 -0
- package/build/client/typed-error-dispatch.test.js +168 -0
- package/build/client/typed-error-dispatch.test.js.map +1 -0
- package/build/client/types.d.ts +37 -0
- package/build/codegen/e2e.test.js +9 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +4 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +17 -6
- package/build/codegen/emit-errors.integration.test.d.ts +1 -0
- package/build/codegen/emit-errors.integration.test.js +162 -0
- package/build/codegen/emit-errors.integration.test.js.map +1 -0
- package/build/codegen/emit-errors.js +50 -39
- package/build/codegen/emit-errors.js.map +1 -1
- package/build/codegen/emit-errors.test.js +75 -78
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.d.ts +7 -0
- package/build/codegen/emit-index.js +26 -4
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +55 -23
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.d.ts +8 -0
- package/build/codegen/emit-scope.js +82 -7
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/pipeline.js +22 -2
- package/build/codegen/pipeline.js.map +1 -1
- package/build/implementations/http/doc-registry.d.ts +21 -0
- package/build/implementations/http/doc-registry.js +51 -78
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +8 -6
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +240 -0
- package/build/implementations/http/error-taxonomy.js +230 -0
- package/build/implementations/http/error-taxonomy.js.map +1 -0
- package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/error-taxonomy.test.js +399 -0
- package/build/implementations/http/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +39 -8
- package/build/implementations/http/express-rpc/index.js +39 -8
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-api/index.d.ts +38 -1
- package/build/implementations/http/hono-api/index.js +32 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-rpc/index.d.ts +34 -7
- package/build/implementations/http/hono-rpc/index.js +31 -4
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-stream/index.d.ts +40 -3
- package/build/implementations/http/hono-stream/index.js +37 -10
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +45 -18
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/on-request-error.test.d.ts +1 -0
- package/build/implementations/http/on-request-error.test.js +173 -0
- package/build/implementations/http/on-request-error.test.js.map +1 -0
- package/build/implementations/http/route-errors.test.d.ts +1 -0
- package/build/implementations/http/route-errors.test.js +140 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +30 -2
- package/docs/client-and-codegen.md +105 -12
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +135 -4
- package/docs/streaming.md +3 -1
- package/package.json +7 -2
- package/src/client/call.ts +10 -1
- package/src/client/error-dispatch.test.ts +72 -0
- package/src/client/error-dispatch.ts +27 -0
- package/src/client/fetch-adapter.ts +11 -5
- package/src/client/index.ts +9 -0
- package/src/client/stream.ts +14 -3
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +42 -0
- package/src/codegen/e2e.test.ts +9 -4
- package/src/codegen/emit-client-runtime.ts +4 -0
- package/src/codegen/emit-errors.integration.test.ts +183 -0
- package/src/codegen/emit-errors.test.ts +91 -87
- package/src/codegen/emit-errors.ts +123 -41
- package/src/codegen/emit-index.test.ts +68 -24
- package/src/codegen/emit-index.ts +66 -4
- package/src/codegen/emit-scope.ts +124 -7
- package/src/codegen/pipeline.ts +25 -2
- package/src/implementations/http/README.md +19 -4
- package/src/implementations/http/doc-registry.test.ts +10 -6
- package/src/implementations/http/doc-registry.ts +63 -80
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +337 -0
- package/src/implementations/http/express-rpc/README.md +21 -22
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
- package/src/implementations/http/express-rpc/index.ts +75 -14
- package/src/implementations/http/hono-api/README.md +284 -0
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
- package/src/implementations/http/hono-api/index.ts +76 -1
- package/src/implementations/http/hono-rpc/README.md +18 -19
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
- package/src/implementations/http/hono-rpc/index.ts +65 -9
- package/src/implementations/http/hono-stream/README.md +44 -25
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
- package/src/implementations/http/hono-stream/index.test.ts +54 -18
- package/src/implementations/http/hono-stream/index.ts +83 -13
- package/src/implementations/http/on-request-error.test.ts +201 -0
- package/src/implementations/http/route-errors.test.ts +177 -0
- package/src/implementations/types.ts +30 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verifies the generated error classes actually work at runtime — instantiated,
|
|
3
|
+
* thrown, and `instanceof`-checkable. We do this by generating a client from a
|
|
4
|
+
* synthetic DocEnvelope, writing it to a temp dir, dynamically importing the
|
|
5
|
+
* generated `_errors.ts`, and exercising the classes.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest'
|
|
8
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
9
|
+
import { tmpdir } from 'node:os'
|
|
10
|
+
import { join } from 'node:path'
|
|
11
|
+
import { execSync } from 'node:child_process'
|
|
12
|
+
import { generateClient } from './index.js'
|
|
13
|
+
import type { DocEnvelope } from '../implementations/types.js'
|
|
14
|
+
|
|
15
|
+
describe('generated _errors.ts — runtime behavior', () => {
|
|
16
|
+
const envelope: DocEnvelope = {
|
|
17
|
+
basePath: '/api',
|
|
18
|
+
headers: [],
|
|
19
|
+
errors: [
|
|
20
|
+
{
|
|
21
|
+
name: 'UseCaseError',
|
|
22
|
+
statusCode: 422,
|
|
23
|
+
description: 'Business rule violation.',
|
|
24
|
+
schema: {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties: {
|
|
27
|
+
name: { type: 'string', const: 'UseCaseError' },
|
|
28
|
+
message: { type: 'string' },
|
|
29
|
+
},
|
|
30
|
+
required: ['name', 'message'],
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
name: 'AuthError',
|
|
35
|
+
statusCode: 401,
|
|
36
|
+
description: 'Authentication required.',
|
|
37
|
+
schema: {
|
|
38
|
+
type: 'object',
|
|
39
|
+
properties: {
|
|
40
|
+
name: { type: 'string', const: 'AuthError' },
|
|
41
|
+
message: { type: 'string' },
|
|
42
|
+
},
|
|
43
|
+
required: ['name', 'message'],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
],
|
|
47
|
+
routes: [
|
|
48
|
+
{
|
|
49
|
+
kind: 'api',
|
|
50
|
+
name: 'Ping',
|
|
51
|
+
scope: 'default',
|
|
52
|
+
path: '/ping',
|
|
53
|
+
fullPath: '/api/ping',
|
|
54
|
+
method: 'get',
|
|
55
|
+
jsonSchema: { response: { type: 'object', properties: {} } },
|
|
56
|
+
},
|
|
57
|
+
],
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function makeTmp(): string {
|
|
61
|
+
return mkdtempSync(join(tmpdir(), 'ts-proc-errors-'))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
it('emits classes that extend a shared base and are instanceof-checkable', async () => {
|
|
65
|
+
const outDir = makeTmp()
|
|
66
|
+
try {
|
|
67
|
+
await generateClient({ envelope, outDir, namespaceTypes: false, selfContained: true })
|
|
68
|
+
|
|
69
|
+
// Compile the generated files with tsc so the classes become runnable JS.
|
|
70
|
+
const tsconfig = {
|
|
71
|
+
compilerOptions: {
|
|
72
|
+
target: 'ES2022',
|
|
73
|
+
module: 'ESNext',
|
|
74
|
+
moduleResolution: 'bundler',
|
|
75
|
+
strict: true,
|
|
76
|
+
outDir: './out',
|
|
77
|
+
},
|
|
78
|
+
include: ['_types.ts', '_client.ts', '_errors.ts', 'index.ts', 'default.ts'],
|
|
79
|
+
}
|
|
80
|
+
writeFileSync(join(outDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
|
|
81
|
+
|
|
82
|
+
const tscPath = join(process.cwd(), 'node_modules/.bin/tsc')
|
|
83
|
+
try {
|
|
84
|
+
execSync(`${tscPath} --project ${join(outDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
85
|
+
} catch (e) {
|
|
86
|
+
const err = e as { stdout?: Buffer; stderr?: Buffer }
|
|
87
|
+
const stdout = err.stdout?.toString() ?? ''
|
|
88
|
+
const stderr = err.stderr?.toString() ?? ''
|
|
89
|
+
throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Dynamic import of the compiled output.
|
|
93
|
+
const errorsUrl = `file://${join(outDir, 'out', '_errors.js')}`
|
|
94
|
+
const mod = await import(errorsUrl)
|
|
95
|
+
|
|
96
|
+
// Default serviceName is 'Api' so base class is ApiProcedureError and
|
|
97
|
+
// registry is ApiErrorRegistry.
|
|
98
|
+
expect(typeof mod.ApiProcedureError).toBe('function')
|
|
99
|
+
expect(typeof mod.UseCaseError).toBe('function')
|
|
100
|
+
expect(typeof mod.AuthError).toBe('function')
|
|
101
|
+
|
|
102
|
+
// Classes extend the base — `instanceof` works in both directions.
|
|
103
|
+
const useCase = mod.UseCaseError.fromResponse(
|
|
104
|
+
{ name: 'UseCaseError', message: 'boom' },
|
|
105
|
+
{ status: 422, procedureName: 'DoThing', scope: 'things' }
|
|
106
|
+
)
|
|
107
|
+
expect(useCase).toBeInstanceOf(mod.UseCaseError)
|
|
108
|
+
expect(useCase).toBeInstanceOf(mod.ApiProcedureError)
|
|
109
|
+
expect(useCase).toBeInstanceOf(Error)
|
|
110
|
+
expect(useCase.status).toBe(422)
|
|
111
|
+
expect(useCase.procedureName).toBe('DoThing')
|
|
112
|
+
expect(useCase.scope).toBe('things')
|
|
113
|
+
expect(useCase.message).toBe('boom')
|
|
114
|
+
expect(useCase.body.message).toBe('boom')
|
|
115
|
+
|
|
116
|
+
// Registry is a plain object keyed by class name.
|
|
117
|
+
expect(mod.ApiErrorRegistry.UseCaseError).toBe(mod.UseCaseError)
|
|
118
|
+
expect(mod.ApiErrorRegistry.AuthError).toBe(mod.AuthError)
|
|
119
|
+
|
|
120
|
+
// Subclasses don't accidentally share identity (distinct constructors).
|
|
121
|
+
const auth = mod.AuthError.fromResponse(
|
|
122
|
+
{ name: 'AuthError', message: 'nope' },
|
|
123
|
+
{ status: 401, procedureName: 'Secret', scope: 'auth' }
|
|
124
|
+
)
|
|
125
|
+
expect(auth).toBeInstanceOf(mod.AuthError)
|
|
126
|
+
expect(auth).not.toBeInstanceOf(mod.UseCaseError)
|
|
127
|
+
} finally {
|
|
128
|
+
rmSync(outDir, { recursive: true, force: true })
|
|
129
|
+
}
|
|
130
|
+
}, 30000)
|
|
131
|
+
|
|
132
|
+
it('namespaceTypes: true wraps classes in a namespace and registry is reachable via qualified name', async () => {
|
|
133
|
+
const outDir = makeTmp()
|
|
134
|
+
try {
|
|
135
|
+
await generateClient({
|
|
136
|
+
envelope,
|
|
137
|
+
outDir,
|
|
138
|
+
namespaceTypes: true,
|
|
139
|
+
selfContained: true,
|
|
140
|
+
serviceName: 'Demo',
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
const tsconfig = {
|
|
144
|
+
compilerOptions: {
|
|
145
|
+
target: 'ES2022',
|
|
146
|
+
module: 'ESNext',
|
|
147
|
+
moduleResolution: 'bundler',
|
|
148
|
+
strict: true,
|
|
149
|
+
outDir: './out',
|
|
150
|
+
},
|
|
151
|
+
include: ['_types.ts', '_client.ts', '_errors.ts', 'index.ts', 'default.ts'],
|
|
152
|
+
}
|
|
153
|
+
writeFileSync(join(outDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
|
|
154
|
+
|
|
155
|
+
const tscPath = join(process.cwd(), 'node_modules/.bin/tsc')
|
|
156
|
+
try {
|
|
157
|
+
execSync(`${tscPath} --project ${join(outDir, 'tsconfig.json')}`, { stdio: 'pipe' })
|
|
158
|
+
} catch (e) {
|
|
159
|
+
const err = e as { stdout?: Buffer; stderr?: Buffer }
|
|
160
|
+
const stdout = err.stdout?.toString() ?? ''
|
|
161
|
+
const stderr = err.stderr?.toString() ?? ''
|
|
162
|
+
throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const errorsUrl = `file://${join(outDir, 'out', '_errors.js')}`
|
|
166
|
+
const mod = await import(errorsUrl)
|
|
167
|
+
|
|
168
|
+
const ns = mod.DemoErrors
|
|
169
|
+
expect(typeof ns.UseCaseError).toBe('function')
|
|
170
|
+
expect(typeof ns.DemoErrorRegistry).toBe('object')
|
|
171
|
+
expect(ns.DemoErrorRegistry.UseCaseError).toBe(ns.UseCaseError)
|
|
172
|
+
|
|
173
|
+
const useCase = ns.UseCaseError.fromResponse(
|
|
174
|
+
{ name: 'UseCaseError', message: 'x' },
|
|
175
|
+
{ status: 422, procedureName: 'P', scope: 's' }
|
|
176
|
+
)
|
|
177
|
+
expect(useCase).toBeInstanceOf(ns.UseCaseError)
|
|
178
|
+
expect(useCase).toBeInstanceOf(ns.DemoProcedureError)
|
|
179
|
+
} finally {
|
|
180
|
+
rmSync(outDir, { recursive: true, force: true })
|
|
181
|
+
}
|
|
182
|
+
}, 30000)
|
|
183
|
+
})
|
|
@@ -49,36 +49,66 @@ const errorDocWithoutSchema: ErrorDoc = {
|
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
|
|
51
51
|
describe('emitErrorsFile', () => {
|
|
52
|
-
it('generates
|
|
52
|
+
it('generates a runtime class per error with schema', async () => {
|
|
53
53
|
const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc])
|
|
54
54
|
expect(result).toBeDefined()
|
|
55
|
-
expect(result).toContain('export
|
|
56
|
-
expect(result).toContain('export
|
|
55
|
+
expect(result).toContain('export class ProcedureError')
|
|
56
|
+
expect(result).toContain('export class ProcedureValidationError')
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
-
it('
|
|
60
|
-
const result = await emitErrorsFile([procedureErrorDoc
|
|
59
|
+
it('emits a body type for each class', async () => {
|
|
60
|
+
const result = await emitErrorsFile([procedureErrorDoc])
|
|
61
|
+
expect(result).toContain('export type ProcedureErrorBody =')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('each class has a static fromResponse factory', async () => {
|
|
65
|
+
const result = await emitErrorsFile([procedureErrorDoc])
|
|
66
|
+
expect(result).toContain('static fromResponse(')
|
|
67
|
+
expect(result).toContain("name: 'ProcedureError'")
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('emits a shared base class the errors extend', async () => {
|
|
71
|
+
const result = await emitErrorsFile([procedureErrorDoc])
|
|
61
72
|
expect(result).toBeDefined()
|
|
62
|
-
expect(result).
|
|
73
|
+
expect(result).toMatch(/export class ProcedureErrorBase<TBody = unknown> extends Error/)
|
|
74
|
+
expect(result).toContain('extends ProcedureErrorBase<ProcedureErrorBody>')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('emits a discriminated union type', async () => {
|
|
78
|
+
const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc])
|
|
79
|
+
expect(result).toContain(
|
|
80
|
+
'export type ProcedureErrorUnion = ProcedureError | ProcedureValidationError'
|
|
81
|
+
)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('emits a runtime registry keyed by class name', async () => {
|
|
85
|
+
const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc])
|
|
86
|
+
expect(result).toContain('export const ErrorRegistry = {')
|
|
87
|
+
expect(result).toMatch(/ProcedureError,/)
|
|
88
|
+
expect(result).toMatch(/ProcedureValidationError,/)
|
|
63
89
|
})
|
|
64
90
|
|
|
65
91
|
it('includes JSDoc with statusCode and description', async () => {
|
|
66
92
|
const result = await emitErrorsFile([procedureErrorDoc])
|
|
67
|
-
expect(result).
|
|
68
|
-
|
|
93
|
+
expect(result).toContain(
|
|
94
|
+
'/** An error thrown from within a procedure handler via ctx.error(). (HTTP 500) */'
|
|
95
|
+
)
|
|
69
96
|
})
|
|
70
97
|
|
|
71
|
-
it('includes JSDoc before the
|
|
98
|
+
it('includes JSDoc before the concrete class declaration', async () => {
|
|
72
99
|
const result = await emitErrorsFile([procedureErrorDoc])
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
100
|
+
const jsdocIdx = result!.indexOf(
|
|
101
|
+
'/** An error thrown from within a procedure handler via ctx.error().'
|
|
102
|
+
)
|
|
103
|
+
// Match only the concrete class (ProcedureError), not the base class
|
|
104
|
+
// (ProcedureErrorBase which is a prefix-collision).
|
|
105
|
+
const concreteClassIdx = result!.indexOf('export class ProcedureError extends')
|
|
106
|
+
expect(jsdocIdx).toBeGreaterThan(-1)
|
|
107
|
+
expect(concreteClassIdx).toBeGreaterThan(jsdocIdx)
|
|
77
108
|
})
|
|
78
109
|
|
|
79
110
|
it('includes the auto-generated header comment', async () => {
|
|
80
111
|
const result = await emitErrorsFile([procedureErrorDoc])
|
|
81
|
-
expect(result).toBeDefined()
|
|
82
112
|
expect(result).toContain('// Auto-generated by ts-procedures-codegen — do not edit')
|
|
83
113
|
})
|
|
84
114
|
|
|
@@ -87,116 +117,90 @@ describe('emitErrorsFile', () => {
|
|
|
87
117
|
expect(result).toBeUndefined()
|
|
88
118
|
})
|
|
89
119
|
|
|
90
|
-
it('returns undefined for empty errors array', async () => {
|
|
120
|
+
it('returns undefined for an empty errors array', async () => {
|
|
91
121
|
const result = await emitErrorsFile([])
|
|
92
122
|
expect(result).toBeUndefined()
|
|
93
123
|
})
|
|
94
124
|
|
|
95
|
-
it('skips errors without schema,
|
|
96
|
-
const result = await emitErrorsFile([
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
expect(result).
|
|
102
|
-
|
|
103
|
-
expect(result).toContain('
|
|
104
|
-
expect(result).not.toContain('UnknownError')
|
|
105
|
-
})
|
|
106
|
-
|
|
107
|
-
it('union type has single type when only one error has schema', async () => {
|
|
108
|
-
const result = await emitErrorsFile([procedureErrorDoc])
|
|
109
|
-
expect(result).toBeDefined()
|
|
110
|
-
expect(result).toContain('export type ProcedureErrorUnion = ProcedureError')
|
|
111
|
-
})
|
|
112
|
-
|
|
113
|
-
it('union type does not include errros without schemas', async () => {
|
|
114
|
-
const result = await emitErrorsFile([procedureErrorDoc, errorDocWithoutSchema])
|
|
115
|
-
expect(result).toBeDefined()
|
|
125
|
+
it('skips errors without schema, emits classes for the rest', async () => {
|
|
126
|
+
const result = await emitErrorsFile([
|
|
127
|
+
procedureErrorDoc,
|
|
128
|
+
errorDocWithoutSchema,
|
|
129
|
+
validationErrorDoc,
|
|
130
|
+
])
|
|
131
|
+
expect(result).toContain('export class ProcedureError')
|
|
132
|
+
expect(result).toContain('export class ProcedureValidationError')
|
|
133
|
+
expect(result).not.toContain('export class UnknownError')
|
|
116
134
|
expect(result).not.toContain('UnknownError')
|
|
117
|
-
expect(result).toContain('export type ProcedureErrorUnion = ProcedureError')
|
|
118
135
|
})
|
|
119
136
|
|
|
120
|
-
it('
|
|
121
|
-
// Just verify it does not crash when options are passed — behavior is tested in emit-types.test.ts
|
|
137
|
+
it('accepts ajsc options without crashing', async () => {
|
|
122
138
|
const result = await emitErrorsFile([procedureErrorDoc], { ajsc: { enumStyle: 'union' } })
|
|
123
139
|
expect(result).toBeDefined()
|
|
124
|
-
expect(result).toContain('export
|
|
140
|
+
expect(result).toContain('export class ProcedureError')
|
|
125
141
|
})
|
|
126
142
|
|
|
127
143
|
describe('serviceName', () => {
|
|
128
|
-
it('uses Errors namespace by default', async () => {
|
|
129
|
-
const result = await emitErrorsFile([procedureErrorDoc
|
|
144
|
+
it('uses Errors namespace by default in namespace mode', async () => {
|
|
145
|
+
const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: true })
|
|
130
146
|
expect(result).toContain('export namespace Errors {')
|
|
131
147
|
})
|
|
132
148
|
|
|
133
|
-
it('prefixes namespace with serviceName
|
|
134
|
-
const result = await emitErrorsFile([procedureErrorDoc
|
|
149
|
+
it('prefixes namespace with serviceName', async () => {
|
|
150
|
+
const result = await emitErrorsFile([procedureErrorDoc], {
|
|
151
|
+
namespaceTypes: true,
|
|
152
|
+
serviceName: 'Auth',
|
|
153
|
+
})
|
|
135
154
|
expect(result).toContain('export namespace AuthErrors {')
|
|
136
155
|
expect(result).not.toContain('export namespace Errors {')
|
|
137
156
|
})
|
|
138
157
|
|
|
139
|
-
it('PascalCases a kebab-case serviceName
|
|
140
|
-
const result = await emitErrorsFile([procedureErrorDoc], {
|
|
158
|
+
it('PascalCases a kebab-case serviceName', async () => {
|
|
159
|
+
const result = await emitErrorsFile([procedureErrorDoc], {
|
|
160
|
+
namespaceTypes: true,
|
|
161
|
+
serviceName: 'user-service',
|
|
162
|
+
})
|
|
141
163
|
expect(result).toContain('export namespace UserServiceErrors {')
|
|
142
164
|
})
|
|
143
165
|
|
|
144
|
-
it('
|
|
145
|
-
const result = await emitErrorsFile([procedureErrorDoc], {
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
expect(result).
|
|
153
|
-
expect(result).toContain('export type AuthProcedureErrorUnion = ProcedureError | ProcedureValidationError')
|
|
154
|
-
expect(result).not.toContain('export type ProcedureErrorUnion =')
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('prefixes ProcedureErrorUnion inside namespace when serviceName is set', async () => {
|
|
158
|
-
const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: true, serviceName: 'Auth' })
|
|
159
|
-
expect(result).toBeDefined()
|
|
160
|
-
expect(result).toContain('AuthProcedureErrorUnion')
|
|
161
|
-
expect(result).not.toMatch(/\bexport type ProcedureErrorUnion\b/)
|
|
166
|
+
it('prefixes base class, union, and registry when serviceName is set', async () => {
|
|
167
|
+
const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], {
|
|
168
|
+
serviceName: 'Auth',
|
|
169
|
+
})
|
|
170
|
+
expect(result).toMatch(/export class AuthProcedureError<TBody = unknown> extends Error/)
|
|
171
|
+
expect(result).toContain(
|
|
172
|
+
'export type AuthProcedureErrorUnion = ProcedureError | ProcedureValidationError'
|
|
173
|
+
)
|
|
174
|
+
expect(result).toContain('export const AuthErrorRegistry = {')
|
|
162
175
|
})
|
|
163
176
|
|
|
164
|
-
it('leaves
|
|
165
|
-
const result = await emitErrorsFile([procedureErrorDoc]
|
|
166
|
-
expect(result).
|
|
177
|
+
it('leaves names unprefixed when no serviceName', async () => {
|
|
178
|
+
const result = await emitErrorsFile([procedureErrorDoc])
|
|
179
|
+
expect(result).toMatch(/export class ProcedureErrorBase<TBody = unknown> extends Error/)
|
|
167
180
|
expect(result).toContain('export type ProcedureErrorUnion = ProcedureError')
|
|
181
|
+
expect(result).toContain('export const ErrorRegistry = {')
|
|
168
182
|
})
|
|
169
183
|
})
|
|
170
184
|
|
|
171
185
|
describe('namespaceTypes', () => {
|
|
172
|
-
it('wraps
|
|
173
|
-
const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], {
|
|
174
|
-
|
|
186
|
+
it('wraps classes and registry in export namespace', async () => {
|
|
187
|
+
const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], {
|
|
188
|
+
namespaceTypes: true,
|
|
189
|
+
})
|
|
175
190
|
expect(result).toContain('export namespace Errors {')
|
|
176
|
-
expect(result).toContain('export
|
|
177
|
-
expect(result).toContain('export
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
it('places union inside the namespace', async () => {
|
|
181
|
-
const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], { namespaceTypes: true })
|
|
182
|
-
expect(result).toBeDefined()
|
|
183
|
-
expect(result).toContain('export type ProcedureErrorUnion = ProcedureError | ProcedureValidationError')
|
|
184
|
-
// Union should be inside the namespace (indented)
|
|
185
|
-
const lines = result!.split('\n')
|
|
186
|
-
const unionLine = lines.find((l) => l.includes('ProcedureErrorUnion'))
|
|
187
|
-
expect(unionLine).toMatch(/^\s+export type ProcedureErrorUnion/)
|
|
191
|
+
expect(result).toContain('export class ProcedureError')
|
|
192
|
+
expect(result).toContain('export const ErrorRegistry = {')
|
|
188
193
|
})
|
|
189
194
|
|
|
190
|
-
it('
|
|
195
|
+
it('contents are indented inside the namespace block', async () => {
|
|
191
196
|
const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: true })
|
|
192
|
-
|
|
193
|
-
expect(
|
|
197
|
+
const classLine = result!.split('\n').find((l) => l.includes('export class ProcedureError'))
|
|
198
|
+
expect(classLine).toMatch(/^\s{2}export class ProcedureError/)
|
|
194
199
|
})
|
|
195
200
|
|
|
196
201
|
it('flat mode does not wrap in namespace', async () => {
|
|
197
202
|
const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: false })
|
|
198
|
-
expect(result).
|
|
199
|
-
expect(result).not.toContain('export namespace Errors')
|
|
203
|
+
expect(result).not.toContain('export namespace')
|
|
200
204
|
})
|
|
201
205
|
})
|
|
202
206
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { jsonSchemaToTypeBody, type AjscOptions } from './emit-types.js'
|
|
2
2
|
import type { ErrorDoc } from '../implementations/types.js'
|
|
3
3
|
import { CODEGEN_HEADER } from './constants.js'
|
|
4
4
|
import { toPascalCase } from './naming.js'
|
|
@@ -11,14 +11,25 @@ export interface EmitErrorsOptions {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* Generates a TypeScript file with error
|
|
14
|
+
* Generates a TypeScript file with runtime error classes (plus a registry
|
|
15
|
+
* object for dispatch) derived from `DocEnvelope.errors`.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* For each error with a schema, emits:
|
|
18
|
+
* - `interface <Name>Body { ... }` — typed response body
|
|
19
|
+
* - `class <Name> extends <ServiceName>ProcedureError<<Name>Body>` with
|
|
20
|
+
* constructor + static `fromResponse(body, meta)`
|
|
18
21
|
*
|
|
19
|
-
*
|
|
20
|
-
* (
|
|
21
|
-
*
|
|
22
|
+
* The shared base `<ServiceName>ProcedureError` is emitted so consumers can
|
|
23
|
+
* `catch (e) { if (e instanceof ApiErrors.ApiProcedureError) ... }` to handle
|
|
24
|
+
* any service error in one block. `instanceof` works across bundler boundaries
|
|
25
|
+
* because the generated file holds the sole class definition at runtime.
|
|
26
|
+
*
|
|
27
|
+
* The registry `<ServiceName>ErrorRegistry` maps body `name` values to
|
|
28
|
+
* classes, consumed by the client's `dispatchTypedError` to produce typed
|
|
29
|
+
* errors instead of generic `ClientRequestError` instances.
|
|
30
|
+
*
|
|
31
|
+
* When `namespaceTypes` is on, everything is wrapped in `export namespace
|
|
32
|
+
* <ServiceName>Errors { ... }`. Returns `undefined` if no errors have schemas.
|
|
22
33
|
*/
|
|
23
34
|
export async function emitErrorsFile(
|
|
24
35
|
errors: ErrorDoc[],
|
|
@@ -27,54 +38,125 @@ export async function emitErrorsFile(
|
|
|
27
38
|
const { ajsc: ajscOpts, namespaceTypes = false, serviceName } = options ?? {}
|
|
28
39
|
const servicePrefix = serviceName ? toPascalCase(serviceName) : ''
|
|
29
40
|
const namespaceName = servicePrefix ? `${servicePrefix}Errors` : 'Errors'
|
|
41
|
+
const baseClassName = servicePrefix ? `${servicePrefix}ProcedureError` : 'ProcedureErrorBase'
|
|
30
42
|
const unionName = servicePrefix ? `${servicePrefix}ProcedureErrorUnion` : 'ProcedureErrorUnion'
|
|
43
|
+
const registryName = servicePrefix ? `${servicePrefix}ErrorRegistry` : 'ErrorRegistry'
|
|
31
44
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
e.schema != null
|
|
45
|
+
const errorsWithSchema = errors.filter(
|
|
46
|
+
(e): e is ErrorDoc & { schema: Record<string, unknown> } => e.schema != null
|
|
35
47
|
)
|
|
36
48
|
|
|
37
49
|
if (errorsWithSchema.length === 0) {
|
|
38
50
|
return undefined
|
|
39
51
|
}
|
|
40
52
|
|
|
41
|
-
|
|
53
|
+
// Compute the typed body for each error by converting its schema to a TS type.
|
|
54
|
+
const entries: Array<{
|
|
55
|
+
doc: (typeof errorsWithSchema)[number]
|
|
56
|
+
bodyType: string
|
|
57
|
+
}> = []
|
|
58
|
+
for (const doc of errorsWithSchema) {
|
|
59
|
+
const bodyType = await jsonSchemaToTypeBody(doc.schema, ajscOpts)
|
|
60
|
+
entries.push({ doc, bodyType: bodyType ?? 'unknown' })
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const lines: string[] = []
|
|
64
|
+
const indent = namespaceTypes ? ' ' : ''
|
|
42
65
|
|
|
43
66
|
if (namespaceTypes) {
|
|
44
|
-
|
|
67
|
+
lines.push(`export namespace ${namespaceName} {`)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Shared base class — lets consumers catch any service error with one check.
|
|
71
|
+
lines.push(
|
|
72
|
+
`${indent}/** Base class for every generated error in this service. Catch with \`instanceof\`. */`,
|
|
73
|
+
`${indent}export class ${baseClassName}<TBody = unknown> extends Error {`,
|
|
74
|
+
`${indent} readonly status: number`,
|
|
75
|
+
`${indent} readonly procedureName: string`,
|
|
76
|
+
`${indent} readonly scope: string`,
|
|
77
|
+
`${indent} readonly body: TBody`,
|
|
78
|
+
`${indent} constructor(args: {`,
|
|
79
|
+
`${indent} name: string`,
|
|
80
|
+
`${indent} message: string`,
|
|
81
|
+
`${indent} status: number`,
|
|
82
|
+
`${indent} procedureName: string`,
|
|
83
|
+
`${indent} scope: string`,
|
|
84
|
+
`${indent} body: TBody`,
|
|
85
|
+
`${indent} }) {`,
|
|
86
|
+
`${indent} super(args.message)`,
|
|
87
|
+
`${indent} this.name = args.name`,
|
|
88
|
+
`${indent} this.status = args.status`,
|
|
89
|
+
`${indent} this.procedureName = args.procedureName`,
|
|
90
|
+
`${indent} this.scope = args.scope`,
|
|
91
|
+
`${indent} this.body = args.body`,
|
|
92
|
+
`${indent} Object.setPrototypeOf(this, new.target.prototype)`,
|
|
93
|
+
`${indent} }`,
|
|
94
|
+
`${indent}}`,
|
|
95
|
+
''
|
|
96
|
+
)
|
|
45
97
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
typeLines.push('')
|
|
55
|
-
}
|
|
56
|
-
}
|
|
98
|
+
// Per-error body interface + class with fromResponse static factory.
|
|
99
|
+
for (const { doc, bodyType } of entries) {
|
|
100
|
+
const bodyInterfaceName = `${doc.name}Body`
|
|
101
|
+
lines.push(
|
|
102
|
+
`${indent}/** Response body for ${doc.name}. */`,
|
|
103
|
+
`${indent}export type ${bodyInterfaceName} = ${bodyType}`,
|
|
104
|
+
''
|
|
105
|
+
)
|
|
57
106
|
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
} else {
|
|
63
|
-
for (const error of errorsWithSchema) {
|
|
64
|
-
const typeDecl = await jsonSchemaToTypeString(error.name, error.schema, ajscOpts)
|
|
65
|
-
if (typeDecl != null) {
|
|
66
|
-
typeLines.push(`/** ${error.description} (HTTP ${error.statusCode}) */`)
|
|
67
|
-
typeLines.push(typeDecl)
|
|
68
|
-
typeLines.push('')
|
|
69
|
-
}
|
|
70
|
-
}
|
|
107
|
+
const statusLiteral = doc.statusCode
|
|
108
|
+
const descLine = doc.description
|
|
109
|
+
? `${indent}/** ${doc.description} (HTTP ${statusLiteral}) */`
|
|
110
|
+
: `${indent}/** HTTP ${statusLiteral} */`
|
|
71
111
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
112
|
+
lines.push(
|
|
113
|
+
descLine,
|
|
114
|
+
`${indent}export class ${doc.name} extends ${baseClassName}<${bodyInterfaceName}> {`,
|
|
115
|
+
`${indent} static readonly errorName = '${doc.name}' as const`,
|
|
116
|
+
`${indent} static readonly statusCode = ${statusLiteral}`,
|
|
117
|
+
`${indent} static fromResponse(`,
|
|
118
|
+
`${indent} body: ${bodyInterfaceName},`,
|
|
119
|
+
`${indent} meta: { status: number; procedureName: string; scope: string }`,
|
|
120
|
+
`${indent} ): ${doc.name} {`,
|
|
121
|
+
`${indent} const message =`,
|
|
122
|
+
`${indent} body && typeof (body as { message?: unknown }).message === 'string'`,
|
|
123
|
+
`${indent} ? (body as { message: string }).message`,
|
|
124
|
+
`${indent} : '${doc.name}'`,
|
|
125
|
+
`${indent} return new ${doc.name}({`,
|
|
126
|
+
`${indent} name: '${doc.name}',`,
|
|
127
|
+
`${indent} message,`,
|
|
128
|
+
`${indent} status: meta.status,`,
|
|
129
|
+
`${indent} procedureName: meta.procedureName,`,
|
|
130
|
+
`${indent} scope: meta.scope,`,
|
|
131
|
+
`${indent} body,`,
|
|
132
|
+
`${indent} })`,
|
|
133
|
+
`${indent} }`,
|
|
134
|
+
`${indent}}`,
|
|
135
|
+
''
|
|
136
|
+
)
|
|
75
137
|
}
|
|
76
138
|
|
|
77
|
-
|
|
139
|
+
// Union type — every generated error class instance.
|
|
140
|
+
const unionMembers = entries.map((e) => e.doc.name).join(' | ')
|
|
141
|
+
lines.push(
|
|
142
|
+
`${indent}/** Union of every generated error in this service. */`,
|
|
143
|
+
`${indent}export type ${unionName} = ${unionMembers}`,
|
|
144
|
+
''
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
// Registry for runtime dispatch — keyed by body.name.
|
|
148
|
+
lines.push(
|
|
149
|
+
`${indent}/** Runtime registry consumed by the client to dispatch by \`body.name\`. */`,
|
|
150
|
+
`${indent}export const ${registryName} = {`,
|
|
151
|
+
...entries.map((e) => `${indent} ${e.doc.name},`),
|
|
152
|
+
`${indent}} as const`,
|
|
153
|
+
''
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
if (namespaceTypes) {
|
|
157
|
+
lines.push('}')
|
|
158
|
+
lines.push('')
|
|
159
|
+
}
|
|
78
160
|
|
|
79
|
-
return [CODEGEN_HEADER, '',
|
|
161
|
+
return [CODEGEN_HEADER, '', lines.join('\n')].join('\n')
|
|
80
162
|
}
|