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
|
@@ -7,6 +7,11 @@ import type {
|
|
|
7
7
|
ErrorDoc,
|
|
8
8
|
HeaderDoc,
|
|
9
9
|
} from '../types.js'
|
|
10
|
+
import {
|
|
11
|
+
ErrorTaxonomy,
|
|
12
|
+
defaultErrorTaxonomy,
|
|
13
|
+
taxonomyToErrorDocs,
|
|
14
|
+
} from './error-taxonomy.js'
|
|
10
15
|
|
|
11
16
|
export type {
|
|
12
17
|
AnyHttpRouteDoc,
|
|
@@ -18,6 +23,27 @@ export type {
|
|
|
18
23
|
HeaderDoc,
|
|
19
24
|
} from '../types.js'
|
|
20
25
|
|
|
26
|
+
/**
|
|
27
|
+
* `ProcedureRegistrationError` is thrown at procedure-definition time (never at
|
|
28
|
+
* request time), so it doesn't appear in the runtime taxonomy. It is documented
|
|
29
|
+
* here so consumers still see it in the error catalog.
|
|
30
|
+
*/
|
|
31
|
+
const PROCEDURE_REGISTRATION_ERROR_DOC: ErrorDoc = {
|
|
32
|
+
name: 'ProcedureRegistrationError',
|
|
33
|
+
statusCode: 500,
|
|
34
|
+
description:
|
|
35
|
+
'An invalid schema or configuration was detected at procedure registration time.',
|
|
36
|
+
schema: {
|
|
37
|
+
type: 'object',
|
|
38
|
+
properties: {
|
|
39
|
+
name: { type: 'string', const: 'ProcedureRegistrationError' },
|
|
40
|
+
procedureName: { type: 'string' },
|
|
41
|
+
message: { type: 'string' },
|
|
42
|
+
},
|
|
43
|
+
required: ['name', 'procedureName', 'message'],
|
|
44
|
+
},
|
|
45
|
+
}
|
|
46
|
+
|
|
21
47
|
export class DocRegistry {
|
|
22
48
|
private readonly basePath: string
|
|
23
49
|
private readonly headers: HeaderDoc[]
|
|
@@ -56,88 +82,45 @@ export class DocRegistry {
|
|
|
56
82
|
return envelope as T
|
|
57
83
|
}
|
|
58
84
|
|
|
59
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Framework error defaults for the DocEnvelope — derived from
|
|
87
|
+
* {@link defaultErrorTaxonomy} so the documented shape cannot drift from what
|
|
88
|
+
* the HTTP builders actually emit at runtime. `ProcedureRegistrationError` is
|
|
89
|
+
* appended because it's thrown at registration time (never at request time)
|
|
90
|
+
* and therefore lives only in the catalog, not the runtime taxonomy.
|
|
91
|
+
*/
|
|
60
92
|
static defaultErrors(): ErrorDoc[] {
|
|
61
93
|
return [
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
procedureName: { type: 'string' },
|
|
86
|
-
message: { type: 'string' },
|
|
87
|
-
errors: {
|
|
88
|
-
type: 'array',
|
|
89
|
-
items: {
|
|
90
|
-
type: 'object',
|
|
91
|
-
properties: {
|
|
92
|
-
instancePath: { type: 'string' },
|
|
93
|
-
message: { type: 'string' },
|
|
94
|
-
},
|
|
95
|
-
},
|
|
96
|
-
},
|
|
97
|
-
},
|
|
98
|
-
required: ['name', 'procedureName', 'message'],
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
{
|
|
102
|
-
name: 'ProcedureYieldValidationError',
|
|
103
|
-
statusCode: 500,
|
|
104
|
-
description:
|
|
105
|
-
'Schema validation failed for a yielded value in a streaming procedure.',
|
|
106
|
-
schema: {
|
|
107
|
-
type: 'object',
|
|
108
|
-
properties: {
|
|
109
|
-
name: { type: 'string', const: 'ProcedureYieldValidationError' },
|
|
110
|
-
procedureName: { type: 'string' },
|
|
111
|
-
message: { type: 'string' },
|
|
112
|
-
errors: {
|
|
113
|
-
type: 'array',
|
|
114
|
-
items: {
|
|
115
|
-
type: 'object',
|
|
116
|
-
properties: {
|
|
117
|
-
instancePath: { type: 'string' },
|
|
118
|
-
message: { type: 'string' },
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
},
|
|
122
|
-
},
|
|
123
|
-
required: ['name', 'procedureName', 'message'],
|
|
124
|
-
},
|
|
125
|
-
},
|
|
126
|
-
{
|
|
127
|
-
name: 'ProcedureRegistrationError',
|
|
128
|
-
statusCode: 500,
|
|
129
|
-
description:
|
|
130
|
-
'An invalid schema or configuration was detected at procedure registration time.',
|
|
131
|
-
schema: {
|
|
132
|
-
type: 'object',
|
|
133
|
-
properties: {
|
|
134
|
-
name: { type: 'string', const: 'ProcedureRegistrationError' },
|
|
135
|
-
procedureName: { type: 'string' },
|
|
136
|
-
message: { type: 'string' },
|
|
137
|
-
},
|
|
138
|
-
required: ['name', 'procedureName', 'message'],
|
|
139
|
-
},
|
|
140
|
-
},
|
|
94
|
+
...taxonomyToErrorDocs(defaultErrorTaxonomy),
|
|
95
|
+
PROCEDURE_REGISTRATION_ERROR_DOC,
|
|
96
|
+
]
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Convenience constructor that seeds `config.errors` from a taxonomy so the
|
|
101
|
+
* DocEnvelope automatically documents every error class registered with the
|
|
102
|
+
* HTTP builders. Framework defaults (including `ProcedureRegistrationError`)
|
|
103
|
+
* are included unless `includeDefaults: false` is passed.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* const registry = DocRegistry.fromTaxonomy(appErrors, { basePath: '/api' })
|
|
107
|
+
* .from(apiApp)
|
|
108
|
+
*/
|
|
109
|
+
static fromTaxonomy(
|
|
110
|
+
taxonomy: ErrorTaxonomy,
|
|
111
|
+
config?: Omit<DocRegistryConfig, 'errors'> & { includeDefaults?: boolean }
|
|
112
|
+
): DocRegistry {
|
|
113
|
+
const { includeDefaults = true, ...rest } = config ?? {}
|
|
114
|
+
const errors: ErrorDoc[] = [
|
|
115
|
+
...taxonomyToErrorDocs(taxonomy),
|
|
116
|
+
...(includeDefaults ? DocRegistry.defaultErrors() : []),
|
|
141
117
|
]
|
|
118
|
+
// Dedupe by name — user entries take precedence over defaults with the
|
|
119
|
+
// same key, matching runtime resolution order.
|
|
120
|
+
const seen = new Set<string>()
|
|
121
|
+
const deduped = errors.filter((e) =>
|
|
122
|
+
seen.has(e.name) ? false : (seen.add(e.name), true)
|
|
123
|
+
)
|
|
124
|
+
return new DocRegistry({ ...rest, errors: deduped })
|
|
142
125
|
}
|
|
143
126
|
}
|
|
@@ -0,0 +1,438 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
ProcedureError,
|
|
4
|
+
ProcedureValidationError,
|
|
5
|
+
ProcedureYieldValidationError,
|
|
6
|
+
} from '../../errors.js'
|
|
7
|
+
import type { TProcedureRegistration } from '../../index.js'
|
|
8
|
+
import {
|
|
9
|
+
defineErrorTaxonomy,
|
|
10
|
+
resolveErrorResponse,
|
|
11
|
+
defaultErrorTaxonomy,
|
|
12
|
+
} from './error-taxonomy.js'
|
|
13
|
+
|
|
14
|
+
class UseCaseError extends Error {
|
|
15
|
+
constructor(
|
|
16
|
+
readonly externalMsg: string,
|
|
17
|
+
readonly internalMsg: string
|
|
18
|
+
) {
|
|
19
|
+
super(externalMsg)
|
|
20
|
+
this.name = 'UseCaseError'
|
|
21
|
+
Object.setPrototypeOf(this, UseCaseError.prototype)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class AuthError extends Error {
|
|
26
|
+
constructor(readonly reason: 'unauthenticated' | 'forbidden') {
|
|
27
|
+
super(reason)
|
|
28
|
+
this.name = 'AuthError'
|
|
29
|
+
Object.setPrototypeOf(this, AuthError.prototype)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const fakeProcedure = { name: 'Test', config: {}, handler: async () => {} } as unknown as TProcedureRegistration
|
|
34
|
+
|
|
35
|
+
describe('defineErrorTaxonomy', () => {
|
|
36
|
+
test('validates exactly one discriminator per entry', () => {
|
|
37
|
+
expect(() =>
|
|
38
|
+
defineErrorTaxonomy({
|
|
39
|
+
Bad: { statusCode: 400 } as any,
|
|
40
|
+
})
|
|
41
|
+
).toThrow(/exactly one of/)
|
|
42
|
+
|
|
43
|
+
expect(() =>
|
|
44
|
+
defineErrorTaxonomy({
|
|
45
|
+
Bad: {
|
|
46
|
+
class: Error,
|
|
47
|
+
match: (e: unknown): e is Error => e instanceof Error,
|
|
48
|
+
statusCode: 400,
|
|
49
|
+
} as any,
|
|
50
|
+
})
|
|
51
|
+
).toThrow(/exactly one of/)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('accepts a valid entry', () => {
|
|
55
|
+
const t = defineErrorTaxonomy({
|
|
56
|
+
UseCaseError: { class: UseCaseError, statusCode: 422 },
|
|
57
|
+
})
|
|
58
|
+
expect(t.UseCaseError.statusCode).toBe(422)
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('resolveErrorResponse', () => {
|
|
63
|
+
test('class match uses toResponse output', () => {
|
|
64
|
+
const taxonomy = defineErrorTaxonomy({
|
|
65
|
+
UseCaseError: {
|
|
66
|
+
class: UseCaseError,
|
|
67
|
+
statusCode: 422,
|
|
68
|
+
toResponse: (err) => ({ name: 'UseCaseError', message: err.externalMsg }),
|
|
69
|
+
},
|
|
70
|
+
})
|
|
71
|
+
const resolved = resolveErrorResponse({
|
|
72
|
+
err: new UseCaseError('external', 'internal'),
|
|
73
|
+
userTaxonomy: taxonomy,
|
|
74
|
+
procedure: fakeProcedure,
|
|
75
|
+
raw: {},
|
|
76
|
+
})
|
|
77
|
+
expect(resolved?.statusCode).toBe(422)
|
|
78
|
+
expect(resolved?.body).toEqual({ name: 'UseCaseError', message: 'external' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
test('match predicate catches 3rd-party errors', () => {
|
|
82
|
+
const mongoLike = Object.assign(new Error('dup'), { name: 'MongoServerError', code: 11000 })
|
|
83
|
+
const taxonomy = defineErrorTaxonomy({
|
|
84
|
+
MongoDuplicateKey: {
|
|
85
|
+
match: (e): e is Error => e instanceof Error && (e as any).code === 11000,
|
|
86
|
+
statusCode: 409,
|
|
87
|
+
toResponse: () => ({ name: 'Conflict' }),
|
|
88
|
+
},
|
|
89
|
+
})
|
|
90
|
+
const resolved = resolveErrorResponse({
|
|
91
|
+
err: mongoLike,
|
|
92
|
+
userTaxonomy: taxonomy,
|
|
93
|
+
procedure: fakeProcedure,
|
|
94
|
+
raw: {},
|
|
95
|
+
})
|
|
96
|
+
expect(resolved?.statusCode).toBe(409)
|
|
97
|
+
expect(resolved?.body).toEqual({ name: 'Conflict' })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
test('default toResponse emits { name, message } from entry key', () => {
|
|
101
|
+
const taxonomy = defineErrorTaxonomy({
|
|
102
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
103
|
+
})
|
|
104
|
+
const resolved = resolveErrorResponse({
|
|
105
|
+
err: new AuthError('unauthenticated'),
|
|
106
|
+
userTaxonomy: taxonomy,
|
|
107
|
+
procedure: fakeProcedure,
|
|
108
|
+
raw: {},
|
|
109
|
+
})
|
|
110
|
+
expect(resolved?.body).toEqual({ name: 'AuthError', message: 'unauthenticated' })
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('first matching entry wins — subclass declared before base', () => {
|
|
114
|
+
const taxonomy = defineErrorTaxonomy({
|
|
115
|
+
ProcedureValidationError: {
|
|
116
|
+
class: ProcedureValidationError,
|
|
117
|
+
statusCode: 400,
|
|
118
|
+
toResponse: () => ({ kind: 'validation' }),
|
|
119
|
+
},
|
|
120
|
+
ProcedureError: {
|
|
121
|
+
class: ProcedureError,
|
|
122
|
+
statusCode: 500,
|
|
123
|
+
toResponse: () => ({ kind: 'base' }),
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
const resolved = resolveErrorResponse({
|
|
127
|
+
err: new ProcedureValidationError('Test', 'bad', []),
|
|
128
|
+
userTaxonomy: taxonomy,
|
|
129
|
+
procedure: fakeProcedure,
|
|
130
|
+
includeDefaults: false,
|
|
131
|
+
raw: {},
|
|
132
|
+
})
|
|
133
|
+
expect(resolved?.statusCode).toBe(400)
|
|
134
|
+
expect(resolved?.body).toEqual({ name: 'ProcedureValidationError', kind: 'validation' })
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('topological sort fixes a subclass that was declared after its base', () => {
|
|
138
|
+
// Under first-match-wins without sorting, the base would catch the
|
|
139
|
+
// subclass. defineErrorTaxonomy reorders to keep the subclass entry first.
|
|
140
|
+
const taxonomy = defineErrorTaxonomy({
|
|
141
|
+
ProcedureError: {
|
|
142
|
+
class: ProcedureError,
|
|
143
|
+
statusCode: 500,
|
|
144
|
+
toResponse: () => ({ kind: 'base' }),
|
|
145
|
+
},
|
|
146
|
+
ProcedureValidationError: {
|
|
147
|
+
class: ProcedureValidationError,
|
|
148
|
+
statusCode: 400,
|
|
149
|
+
toResponse: () => ({ kind: 'validation' }),
|
|
150
|
+
},
|
|
151
|
+
})
|
|
152
|
+
const resolved = resolveErrorResponse({
|
|
153
|
+
err: new ProcedureValidationError('Test', 'bad', []),
|
|
154
|
+
userTaxonomy: taxonomy,
|
|
155
|
+
procedure: fakeProcedure,
|
|
156
|
+
includeDefaults: false,
|
|
157
|
+
raw: {},
|
|
158
|
+
})
|
|
159
|
+
expect(resolved?.statusCode).toBe(400)
|
|
160
|
+
expect(resolved?.body).toEqual({ name: 'ProcedureValidationError', kind: 'validation' })
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
test('falls through to unknownError when nothing matches', () => {
|
|
164
|
+
const resolved = resolveErrorResponse({
|
|
165
|
+
err: new Error('boom'),
|
|
166
|
+
userTaxonomy: defineErrorTaxonomy({
|
|
167
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
168
|
+
}),
|
|
169
|
+
includeDefaults: false,
|
|
170
|
+
unknownError: {
|
|
171
|
+
statusCode: 500,
|
|
172
|
+
toResponse: () => ({ name: 'InternalServerError' }),
|
|
173
|
+
},
|
|
174
|
+
procedure: fakeProcedure,
|
|
175
|
+
raw: {},
|
|
176
|
+
})
|
|
177
|
+
expect(resolved?.statusCode).toBe(500)
|
|
178
|
+
expect(resolved?.body).toEqual({ name: 'InternalServerError' })
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('returns null when nothing matches and no unknownError', () => {
|
|
182
|
+
const resolved = resolveErrorResponse({
|
|
183
|
+
err: new Error('boom'),
|
|
184
|
+
userTaxonomy: defineErrorTaxonomy({
|
|
185
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
186
|
+
}),
|
|
187
|
+
includeDefaults: false,
|
|
188
|
+
procedure: fakeProcedure,
|
|
189
|
+
raw: {},
|
|
190
|
+
})
|
|
191
|
+
expect(resolved).toBeNull()
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
test('default taxonomy catches ProcedureValidationError with status 400', () => {
|
|
195
|
+
const resolved = resolveErrorResponse({
|
|
196
|
+
err: new ProcedureValidationError('Test', 'bad', []),
|
|
197
|
+
procedure: fakeProcedure,
|
|
198
|
+
raw: {},
|
|
199
|
+
})
|
|
200
|
+
expect(resolved?.statusCode).toBe(400)
|
|
201
|
+
expect((resolved?.body as any).name).toBe('ProcedureValidationError')
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
test('default taxonomy catches ProcedureYieldValidationError with status 500', () => {
|
|
205
|
+
const resolved = resolveErrorResponse({
|
|
206
|
+
err: new ProcedureYieldValidationError('Test', 'bad yield', []),
|
|
207
|
+
procedure: fakeProcedure,
|
|
208
|
+
raw: {},
|
|
209
|
+
})
|
|
210
|
+
expect(resolved?.statusCode).toBe(500)
|
|
211
|
+
expect((resolved?.body as any).name).toBe('ProcedureYieldValidationError')
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
test('includeDefaults: false disables the default taxonomy', () => {
|
|
215
|
+
const resolved = resolveErrorResponse({
|
|
216
|
+
err: new ProcedureValidationError('Test', 'bad', []),
|
|
217
|
+
includeDefaults: false,
|
|
218
|
+
procedure: fakeProcedure,
|
|
219
|
+
raw: {},
|
|
220
|
+
})
|
|
221
|
+
expect(resolved).toBeNull()
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
test('user entry overrides default for the same class', () => {
|
|
225
|
+
const resolved = resolveErrorResponse({
|
|
226
|
+
err: new ProcedureValidationError('Test', 'bad', []),
|
|
227
|
+
userTaxonomy: defineErrorTaxonomy({
|
|
228
|
+
ProcedureValidationError: {
|
|
229
|
+
class: ProcedureValidationError,
|
|
230
|
+
statusCode: 418,
|
|
231
|
+
toResponse: () => ({ overridden: true }),
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
procedure: fakeProcedure,
|
|
235
|
+
raw: {},
|
|
236
|
+
})
|
|
237
|
+
expect(resolved?.statusCode).toBe(418)
|
|
238
|
+
expect(resolved?.body).toEqual({ name: 'ProcedureValidationError', overridden: true })
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('onCatch is awaited via runOnCatch', async () => {
|
|
242
|
+
const calls: string[] = []
|
|
243
|
+
const taxonomy = defineErrorTaxonomy({
|
|
244
|
+
UseCaseError: {
|
|
245
|
+
class: UseCaseError,
|
|
246
|
+
statusCode: 422,
|
|
247
|
+
onCatch: async (err) => {
|
|
248
|
+
await new Promise((r) => setTimeout(r, 5))
|
|
249
|
+
calls.push(err.internalMsg)
|
|
250
|
+
},
|
|
251
|
+
},
|
|
252
|
+
})
|
|
253
|
+
const resolved = resolveErrorResponse({
|
|
254
|
+
err: new UseCaseError('ext', 'internal-log'),
|
|
255
|
+
userTaxonomy: taxonomy,
|
|
256
|
+
procedure: fakeProcedure,
|
|
257
|
+
raw: {},
|
|
258
|
+
})
|
|
259
|
+
expect(calls).toEqual([])
|
|
260
|
+
await resolved!.runOnCatch()
|
|
261
|
+
expect(calls).toEqual(['internal-log'])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('unknownError onCatch is awaited', async () => {
|
|
265
|
+
const calls: unknown[] = []
|
|
266
|
+
const resolved = resolveErrorResponse({
|
|
267
|
+
err: new Error('boom'),
|
|
268
|
+
includeDefaults: false,
|
|
269
|
+
unknownError: {
|
|
270
|
+
toResponse: () => ({}),
|
|
271
|
+
onCatch: async (err) => {
|
|
272
|
+
await Promise.resolve()
|
|
273
|
+
calls.push(err)
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
procedure: fakeProcedure,
|
|
277
|
+
raw: {},
|
|
278
|
+
})
|
|
279
|
+
await resolved!.runOnCatch()
|
|
280
|
+
expect(calls).toHaveLength(1)
|
|
281
|
+
expect((calls[0] as Error).message).toBe('boom')
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
test('onCatch receives procedure, key and raw', async () => {
|
|
285
|
+
let received: any
|
|
286
|
+
const taxonomy = defineErrorTaxonomy({
|
|
287
|
+
AuthError: {
|
|
288
|
+
class: AuthError,
|
|
289
|
+
statusCode: 401,
|
|
290
|
+
onCatch: (_err, ctx) => {
|
|
291
|
+
received = ctx
|
|
292
|
+
},
|
|
293
|
+
},
|
|
294
|
+
})
|
|
295
|
+
const resolved = resolveErrorResponse({
|
|
296
|
+
err: new AuthError('forbidden'),
|
|
297
|
+
userTaxonomy: taxonomy,
|
|
298
|
+
procedure: fakeProcedure,
|
|
299
|
+
raw: { marker: 'hono-context' },
|
|
300
|
+
})
|
|
301
|
+
await resolved!.runOnCatch()
|
|
302
|
+
expect(received.procedure).toBe(fakeProcedure)
|
|
303
|
+
expect(received.key).toBe('AuthError')
|
|
304
|
+
expect(received.raw).toEqual({ marker: 'hono-context' })
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test('defaultErrorTaxonomy exposes all four framework error mappings', () => {
|
|
308
|
+
expect(defaultErrorTaxonomy.ProcedureValidationError.statusCode).toBe(400)
|
|
309
|
+
expect(defaultErrorTaxonomy.ProcedureYieldValidationError.statusCode).toBe(500)
|
|
310
|
+
expect(defaultErrorTaxonomy.ProcedureError.statusCode).toBe(500)
|
|
311
|
+
})
|
|
312
|
+
|
|
313
|
+
test('user taxonomy matches cause inside a ProcedureError wrapper', () => {
|
|
314
|
+
// Simulates what the core does when a non-ProcedureError is thrown: wraps
|
|
315
|
+
// into ProcedureError with `cause` preserved.
|
|
316
|
+
const original = new UseCaseError('public', 'private')
|
|
317
|
+
const wrapped = new ProcedureError('Test', 'wrapped')
|
|
318
|
+
;(wrapped as any).cause = original
|
|
319
|
+
|
|
320
|
+
const taxonomy = defineErrorTaxonomy({
|
|
321
|
+
UseCaseError: {
|
|
322
|
+
class: UseCaseError,
|
|
323
|
+
statusCode: 422,
|
|
324
|
+
toResponse: (err) => ({ name: 'UseCaseError', message: err.externalMsg }),
|
|
325
|
+
},
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
const resolved = resolveErrorResponse({
|
|
329
|
+
err: wrapped,
|
|
330
|
+
userTaxonomy: taxonomy,
|
|
331
|
+
procedure: fakeProcedure,
|
|
332
|
+
raw: {},
|
|
333
|
+
})
|
|
334
|
+
expect(resolved?.statusCode).toBe(422)
|
|
335
|
+
expect(resolved?.body).toEqual({ name: 'UseCaseError', message: 'public' })
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
test('wrapped ProcedureError falls through default taxonomy to unknownError', () => {
|
|
339
|
+
const original = new TypeError('db-broke')
|
|
340
|
+
const wrapped = new ProcedureError('Test', 'wrapped')
|
|
341
|
+
;(wrapped as any).cause = original
|
|
342
|
+
|
|
343
|
+
const resolved = resolveErrorResponse({
|
|
344
|
+
err: wrapped,
|
|
345
|
+
unknownError: {
|
|
346
|
+
statusCode: 500,
|
|
347
|
+
toResponse: (err) => ({ name: 'InternalServerError', type: (err as Error).constructor.name }),
|
|
348
|
+
},
|
|
349
|
+
procedure: fakeProcedure,
|
|
350
|
+
raw: {},
|
|
351
|
+
})
|
|
352
|
+
expect(resolved?.statusCode).toBe(500)
|
|
353
|
+
expect(resolved?.body).toEqual({ name: 'InternalServerError', type: 'TypeError' })
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
test('direct ProcedureError (no cause) still caught by default entry', () => {
|
|
357
|
+
const direct = new ProcedureError('Test', 'from ctx.error')
|
|
358
|
+
const resolved = resolveErrorResponse({
|
|
359
|
+
err: direct,
|
|
360
|
+
procedure: fakeProcedure,
|
|
361
|
+
raw: {},
|
|
362
|
+
})
|
|
363
|
+
expect(resolved?.statusCode).toBe(500)
|
|
364
|
+
expect((resolved?.body as any).name).toBe('ProcedureError')
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
test('auto-injects name when toResponse omits it', () => {
|
|
368
|
+
const taxonomy = defineErrorTaxonomy({
|
|
369
|
+
UseCaseError: {
|
|
370
|
+
class: UseCaseError,
|
|
371
|
+
statusCode: 422,
|
|
372
|
+
// Returns a body without `name` — resolver should add one.
|
|
373
|
+
toResponse: (err) => ({ message: err.externalMsg, detail: err.internalMsg }),
|
|
374
|
+
},
|
|
375
|
+
})
|
|
376
|
+
const resolved = resolveErrorResponse({
|
|
377
|
+
err: new UseCaseError('ext', 'int'),
|
|
378
|
+
userTaxonomy: taxonomy,
|
|
379
|
+
procedure: fakeProcedure,
|
|
380
|
+
raw: {},
|
|
381
|
+
})
|
|
382
|
+
expect(resolved?.body).toEqual({ name: 'UseCaseError', message: 'ext', detail: 'int' })
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
test('preserves explicit name in toResponse output', () => {
|
|
386
|
+
const taxonomy = defineErrorTaxonomy({
|
|
387
|
+
UseCaseError: {
|
|
388
|
+
class: UseCaseError,
|
|
389
|
+
statusCode: 422,
|
|
390
|
+
toResponse: () => ({ name: 'CustomAlias', reason: 'x' }),
|
|
391
|
+
},
|
|
392
|
+
})
|
|
393
|
+
const resolved = resolveErrorResponse({
|
|
394
|
+
err: new UseCaseError('ext', 'int'),
|
|
395
|
+
userTaxonomy: taxonomy,
|
|
396
|
+
procedure: fakeProcedure,
|
|
397
|
+
raw: {},
|
|
398
|
+
})
|
|
399
|
+
expect((resolved?.body as any).name).toBe('CustomAlias')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('defineErrorTaxonomy topologically sorts class entries (subclass before base)', () => {
|
|
403
|
+
// Declared base-before-subclass on purpose — sort must swap them.
|
|
404
|
+
const taxonomy = defineErrorTaxonomy({
|
|
405
|
+
ProcedureError: {
|
|
406
|
+
class: ProcedureError,
|
|
407
|
+
statusCode: 500,
|
|
408
|
+
toResponse: () => ({ kind: 'base' }),
|
|
409
|
+
},
|
|
410
|
+
ProcedureValidationError: {
|
|
411
|
+
class: ProcedureValidationError,
|
|
412
|
+
statusCode: 400,
|
|
413
|
+
toResponse: () => ({ kind: 'validation' }),
|
|
414
|
+
},
|
|
415
|
+
})
|
|
416
|
+
const keys = Object.keys(taxonomy)
|
|
417
|
+
expect(keys).toEqual(['ProcedureValidationError', 'ProcedureError'])
|
|
418
|
+
|
|
419
|
+
const resolved = resolveErrorResponse({
|
|
420
|
+
err: new ProcedureValidationError('Test', 'bad', []),
|
|
421
|
+
userTaxonomy: taxonomy,
|
|
422
|
+
includeDefaults: false,
|
|
423
|
+
procedure: fakeProcedure,
|
|
424
|
+
raw: {},
|
|
425
|
+
})
|
|
426
|
+
expect(resolved?.statusCode).toBe(400)
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
test('topological sort preserves declared order for unrelated classes', () => {
|
|
430
|
+
class A extends Error {}
|
|
431
|
+
class B extends Error {}
|
|
432
|
+
const taxonomy = defineErrorTaxonomy({
|
|
433
|
+
B: { class: B, statusCode: 400 },
|
|
434
|
+
A: { class: A, statusCode: 400 },
|
|
435
|
+
})
|
|
436
|
+
expect(Object.keys(taxonomy)).toEqual(['B', 'A'])
|
|
437
|
+
})
|
|
438
|
+
})
|