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
|
@@ -0,0 +1,2293 @@
|
|
|
1
|
+
# Safe Result API & Normalized Client Errors — Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Add a `.safe()` sibling form to every generated RPC/API callable that returns a discriminated `Result<T, E>` — and back it with a classifier that normalizes platform errors (`TypeError`, `DOMException`) into framework error classes. Make the `kind` set extensible via `ClientErrorMap` interface augmentation, mirroring the existing `RequestMeta` pattern. Throwing form remains canonical; `.safe()` is opt-in.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Three failure-source paths in `executeCall` (pre-adapter usage error, adapter throw → classifier, adapter non-2xx → registry-or-fallback) each map to exactly one `Result.kind`. Default classifier is composed with optional adapter-provided `classifyError`. Codegen emits `.safe` as a sibling property on each callable (RPC/API only, streams excluded), branching between `Result<T, RouteErrors>` and `ResultNoTyped<T>` based on whether `route.errors` is declared.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript, Vitest, existing `ts-procedures` codegen pipeline. No new runtime dependencies.
|
|
10
|
+
|
|
11
|
+
**Spec:** [docs/superpowers/specs/2026-04-29-safe-result-api-design.md](../specs/2026-04-29-safe-result-api-design.md)
|
|
12
|
+
|
|
13
|
+
**Branch:** Recommend a fresh worktree off `master` named `safe-result-api`. This is a major version (7.0.0) so changes will not merge back into a minor.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## File map
|
|
18
|
+
|
|
19
|
+
**Create:**
|
|
20
|
+
- `src/client/classify-error.ts` — default classifier + `ErrorClassifier` type
|
|
21
|
+
- `src/client/classify-error.test.ts`
|
|
22
|
+
- `src/client/safe-call.test.ts` — covers `executeSafeCall` and `ClientInstance.safeCall`
|
|
23
|
+
- `src/client/augment-error-map.test-d.ts` — type-level test verifying augmentation widens `Result`
|
|
24
|
+
- `src/codegen/bundle-size.test.ts` — regression guard on per-route byte cost
|
|
25
|
+
- `docs/client-error-handling.md` — consumer guide
|
|
26
|
+
|
|
27
|
+
**Modify:**
|
|
28
|
+
- `src/client/errors.ts` — rename `ClientRequestError` → `ClientHttpError`; add `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError`; add `cause` constructor contract
|
|
29
|
+
- `src/client/types.ts` — add `ClientErrorMap`, `Result`, `ResultNoTyped`, `FrameworkFailure`; extend `ClientAdapter` with optional `classifyError`; extend `ClientInstance` with `safeCall`
|
|
30
|
+
- `src/client/resolve-options.ts` — return signal sources alongside the merged request so classifier can recover provenance
|
|
31
|
+
- `src/client/call.ts` — wrap adapter call in classifier; add `executeSafeCall`
|
|
32
|
+
- `src/client/stream.ts` — wrap pre-stream adapter call in classifier
|
|
33
|
+
- `src/client/fetch-adapter.ts` — accept `classifyError` in config (composes with default)
|
|
34
|
+
- `src/client/index.ts` — export new classes, classifier, `Result`, `ResultNoTyped`, `ClientErrorMap`, `defaultClassifyError`
|
|
35
|
+
- `src/client/call.test.ts`, `src/client/stream.test.ts`, `src/client/fetch-adapter.test.ts`, `src/client/errors.test.ts`, `src/client/index.test.ts` — update for new shapes
|
|
36
|
+
- `src/codegen/emit-scope.ts` — emit `.safe` sibling for RPC/API; branch between `Result` and `ResultNoTyped`
|
|
37
|
+
- `src/codegen/emit-scope.test.ts` — extend coverage
|
|
38
|
+
- `src/codegen/__fixtures__/users-envelope.json` (review for any envelope augmentation needed for the bundle-size test) — likely unchanged
|
|
39
|
+
- `src/codegen/__fixtures__/users-golden.ts` (or per-target equivalent) — refresh with `.safe` siblings
|
|
40
|
+
- `CLAUDE.md` — add normalized error taxonomy + `.safe` form notes
|
|
41
|
+
- `agent_config/claude-code/skills/ts-procedures/patterns.md`, `anti-patterns.md` — error-handling patterns
|
|
42
|
+
- `agent_config/copilot/copilot-instructions.md`, `agent_config/cursor/cursorrules` — condensed equivalents
|
|
43
|
+
- `package.json` — bump to `7.0.0`
|
|
44
|
+
- `CHANGELOG.md` (or release notes file) — major bump entry
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Phase 1 — Error class taxonomy + cause contract
|
|
49
|
+
|
|
50
|
+
### Task 1: Add `cause` constructor contract to existing error classes
|
|
51
|
+
|
|
52
|
+
**Files:**
|
|
53
|
+
- Modify: `src/client/errors.ts`
|
|
54
|
+
- Test: `src/client/errors.test.ts`
|
|
55
|
+
|
|
56
|
+
- [ ] **Step 1: Read the existing test file to understand convention**
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
cat src/client/errors.test.ts
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
- [ ] **Step 2: Write failing test for cause on existing classes**
|
|
63
|
+
|
|
64
|
+
Add to `src/client/errors.test.ts`:
|
|
65
|
+
|
|
66
|
+
```ts
|
|
67
|
+
import { describe, it, expect } from 'vitest'
|
|
68
|
+
import { ClientPathParamError, ClientStreamError } from './errors.js'
|
|
69
|
+
|
|
70
|
+
describe('ClientPathParamError cause', () => {
|
|
71
|
+
it('accepts and stores cause', () => {
|
|
72
|
+
const cause = new TypeError('underlying')
|
|
73
|
+
const err = new ClientPathParamError('id', '/u/:id', 'GetUser', cause)
|
|
74
|
+
expect(err.cause).toBe(cause)
|
|
75
|
+
})
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
describe('ClientStreamError cause', () => {
|
|
79
|
+
it('accepts and stores cause', () => {
|
|
80
|
+
const cause = new Error('underlying')
|
|
81
|
+
const err = new ClientStreamError('boom', 'StreamUsers', 'users', cause)
|
|
82
|
+
expect(err.cause).toBe(cause)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
- [ ] **Step 3: Run tests to verify they fail**
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npx vitest run src/client/errors.test.ts -t 'cause'
|
|
91
|
+
```
|
|
92
|
+
Expected: FAIL — constructor doesn't accept `cause`.
|
|
93
|
+
|
|
94
|
+
- [ ] **Step 4: Update `ClientPathParamError` and `ClientStreamError` constructors**
|
|
95
|
+
|
|
96
|
+
Edit `src/client/errors.ts`:
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
export class ClientPathParamError extends Error {
|
|
100
|
+
readonly name = 'ClientPathParamError'
|
|
101
|
+
|
|
102
|
+
constructor(param: string, path: string, procedureName: string, cause?: unknown) {
|
|
103
|
+
super(`Missing path parameter "${param}" in "${path}" for procedure ${procedureName}`, { cause })
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export class ClientStreamError extends Error {
|
|
108
|
+
readonly name = 'ClientStreamError'
|
|
109
|
+
readonly procedureName: string
|
|
110
|
+
readonly scope: string
|
|
111
|
+
|
|
112
|
+
constructor(message: string, procedureName: string, scope: string, cause?: unknown) {
|
|
113
|
+
super(message, { cause })
|
|
114
|
+
this.procedureName = procedureName
|
|
115
|
+
this.scope = scope
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
- [ ] **Step 5: Run tests to verify pass**
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
npx vitest run src/client/errors.test.ts
|
|
124
|
+
```
|
|
125
|
+
Expected: PASS.
|
|
126
|
+
|
|
127
|
+
- [ ] **Step 6: Commit**
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
git add src/client/errors.ts src/client/errors.test.ts
|
|
131
|
+
git commit -m "feat(client/errors): add cause arg to existing error classes"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Task 2: Rename `ClientRequestError` → `ClientHttpError` with deprecated alias
|
|
135
|
+
|
|
136
|
+
**Files:**
|
|
137
|
+
- Modify: `src/client/errors.ts`
|
|
138
|
+
- Modify: `src/client/index.ts`
|
|
139
|
+
- Modify: `src/client/call.ts`, `src/client/stream.ts`, `src/client/error-dispatch.ts` (any other in-tree references)
|
|
140
|
+
- Test: `src/client/errors.test.ts`
|
|
141
|
+
|
|
142
|
+
- [ ] **Step 1: Find every in-tree reference to `ClientRequestError`**
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
grep -rn 'ClientRequestError' src/ --include='*.ts'
|
|
146
|
+
```
|
|
147
|
+
Note the file:line list — every one needs to point at `ClientHttpError` after the rename.
|
|
148
|
+
|
|
149
|
+
- [ ] **Step 2: Write failing test for the new name + alias**
|
|
150
|
+
|
|
151
|
+
Add to `src/client/errors.test.ts`:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
import { ClientHttpError, ClientRequestError } from './errors.js'
|
|
155
|
+
|
|
156
|
+
describe('ClientHttpError', () => {
|
|
157
|
+
it('exposes status, headers, body, procedureName, scope', () => {
|
|
158
|
+
const err = new ClientHttpError({
|
|
159
|
+
status: 500, headers: { 'x-trace': 'abc' }, body: { error: 'boom' },
|
|
160
|
+
procedureName: 'GetUser', scope: 'users',
|
|
161
|
+
})
|
|
162
|
+
expect(err.status).toBe(500)
|
|
163
|
+
expect(err.headers['x-trace']).toBe('abc')
|
|
164
|
+
expect(err.body).toEqual({ error: 'boom' })
|
|
165
|
+
expect(err.procedureName).toBe('GetUser')
|
|
166
|
+
expect(err.scope).toBe('users')
|
|
167
|
+
expect(err.name).toBe('ClientHttpError')
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('accepts cause', () => {
|
|
171
|
+
const cause = new Error('underlying')
|
|
172
|
+
const err = new ClientHttpError({
|
|
173
|
+
status: 500, headers: {}, body: null,
|
|
174
|
+
procedureName: 'X', scope: 'y', cause,
|
|
175
|
+
})
|
|
176
|
+
expect(err.cause).toBe(cause)
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
describe('ClientRequestError deprecated alias', () => {
|
|
181
|
+
it('is identical to ClientHttpError', () => {
|
|
182
|
+
expect(ClientRequestError).toBe(ClientHttpError)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('instanceof check works against thrown ClientHttpError', () => {
|
|
186
|
+
// Consumers on the previous major used `catch (e) { if (e instanceof
|
|
187
|
+
// ClientRequestError) ... }`. The alias must keep that pattern working
|
|
188
|
+
// until the alias is removed in the next minor cycle's deprecation window.
|
|
189
|
+
const err = new ClientHttpError({
|
|
190
|
+
status: 500, headers: {}, body: null,
|
|
191
|
+
procedureName: 'X', scope: 'y',
|
|
192
|
+
})
|
|
193
|
+
expect(err instanceof ClientRequestError).toBe(true)
|
|
194
|
+
})
|
|
195
|
+
})
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
- [ ] **Step 3: Run tests to verify they fail**
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
npx vitest run src/client/errors.test.ts -t 'ClientHttpError|alias'
|
|
202
|
+
```
|
|
203
|
+
Expected: FAIL — `ClientHttpError` does not exist.
|
|
204
|
+
|
|
205
|
+
- [ ] **Step 4: Rename in `src/client/errors.ts`, add cause + alias**
|
|
206
|
+
|
|
207
|
+
Replace the `ClientRequestError` class definition:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
export class ClientHttpError extends Error {
|
|
211
|
+
readonly name = 'ClientHttpError'
|
|
212
|
+
readonly status: number
|
|
213
|
+
readonly headers: Record<string, string>
|
|
214
|
+
readonly body: unknown
|
|
215
|
+
readonly procedureName: string
|
|
216
|
+
readonly scope: string
|
|
217
|
+
|
|
218
|
+
constructor(opts: {
|
|
219
|
+
status: number
|
|
220
|
+
headers: Record<string, string>
|
|
221
|
+
body: unknown
|
|
222
|
+
procedureName: string
|
|
223
|
+
scope: string
|
|
224
|
+
cause?: unknown
|
|
225
|
+
}) {
|
|
226
|
+
super(
|
|
227
|
+
`${opts.procedureName} (${opts.scope}) failed with status ${opts.status}`,
|
|
228
|
+
{ cause: opts.cause }
|
|
229
|
+
)
|
|
230
|
+
this.status = opts.status
|
|
231
|
+
this.headers = opts.headers
|
|
232
|
+
this.body = opts.body
|
|
233
|
+
this.procedureName = opts.procedureName
|
|
234
|
+
this.scope = opts.scope
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @deprecated Renamed to `ClientHttpError`. The alias will be removed in the next major release. */
|
|
239
|
+
export const ClientRequestError = ClientHttpError
|
|
240
|
+
/** @deprecated Renamed to `ClientHttpError`. */
|
|
241
|
+
export type ClientRequestError = ClientHttpError
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
- [ ] **Step 5: Update every in-tree consumer**
|
|
245
|
+
|
|
246
|
+
For each file from Step 1, change `ClientRequestError` → `ClientHttpError` (use `Edit` tool's `replace_all` per file). Skip the deprecated-alias test itself.
|
|
247
|
+
|
|
248
|
+
- [ ] **Step 6: Update `src/client/index.ts` exports**
|
|
249
|
+
|
|
250
|
+
Ensure both `ClientHttpError` and `ClientRequestError` (alias) are exported.
|
|
251
|
+
|
|
252
|
+
- [ ] **Step 7: Run full test suite to verify no regressions**
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
npm run test
|
|
256
|
+
```
|
|
257
|
+
Expected: PASS for everything except the next phases (which haven't shipped yet — but the rename should not break anything).
|
|
258
|
+
|
|
259
|
+
- [ ] **Step 8: Commit**
|
|
260
|
+
|
|
261
|
+
```bash
|
|
262
|
+
git add src/client/errors.ts src/client/errors.test.ts src/client/index.ts src/client/call.ts src/client/stream.ts src/client/error-dispatch.ts
|
|
263
|
+
git commit -m "feat(client/errors)!: rename ClientRequestError to ClientHttpError
|
|
264
|
+
|
|
265
|
+
Deprecated alias retained for one minor cycle.
|
|
266
|
+
BREAKING CHANGE: ClientRequestError will be removed next major."
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
### Task 3: Add new framework error classes
|
|
270
|
+
|
|
271
|
+
**Files:**
|
|
272
|
+
- Modify: `src/client/errors.ts`
|
|
273
|
+
- Test: `src/client/errors.test.ts`
|
|
274
|
+
- Modify: `src/client/index.ts`
|
|
275
|
+
|
|
276
|
+
- [ ] **Step 1: Write failing tests for new classes**
|
|
277
|
+
|
|
278
|
+
Append to `src/client/errors.test.ts`:
|
|
279
|
+
|
|
280
|
+
```ts
|
|
281
|
+
import {
|
|
282
|
+
ClientNetworkError,
|
|
283
|
+
ClientTimeoutError,
|
|
284
|
+
ClientAbortError,
|
|
285
|
+
ClientParseError,
|
|
286
|
+
} from './errors.js'
|
|
287
|
+
|
|
288
|
+
describe('ClientNetworkError', () => {
|
|
289
|
+
it('carries procedureName, scope, cause', () => {
|
|
290
|
+
const cause = new TypeError('fetch failed')
|
|
291
|
+
const err = new ClientNetworkError({
|
|
292
|
+
procedureName: 'X', scope: 'y', cause,
|
|
293
|
+
})
|
|
294
|
+
expect(err.name).toBe('ClientNetworkError')
|
|
295
|
+
expect(err.procedureName).toBe('X')
|
|
296
|
+
expect(err.scope).toBe('y')
|
|
297
|
+
expect(err.cause).toBe(cause)
|
|
298
|
+
})
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
describe('ClientTimeoutError', () => {
|
|
302
|
+
it('carries timeoutMs', () => {
|
|
303
|
+
const err = new ClientTimeoutError({
|
|
304
|
+
procedureName: 'X', scope: 'y', timeoutMs: 5000,
|
|
305
|
+
})
|
|
306
|
+
expect(err.name).toBe('ClientTimeoutError')
|
|
307
|
+
expect(err.timeoutMs).toBe(5000)
|
|
308
|
+
})
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
describe('ClientAbortError', () => {
|
|
312
|
+
it('carries reason from abort signal', () => {
|
|
313
|
+
const err = new ClientAbortError({
|
|
314
|
+
procedureName: 'X', scope: 'y', reason: 'user-cancelled',
|
|
315
|
+
})
|
|
316
|
+
expect(err.name).toBe('ClientAbortError')
|
|
317
|
+
expect(err.reason).toBe('user-cancelled')
|
|
318
|
+
})
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
describe('ClientParseError', () => {
|
|
322
|
+
it('carries procedureName, scope, cause', () => {
|
|
323
|
+
const cause = new SyntaxError('Unexpected token')
|
|
324
|
+
const err = new ClientParseError({
|
|
325
|
+
procedureName: 'X', scope: 'y', cause,
|
|
326
|
+
})
|
|
327
|
+
expect(err.name).toBe('ClientParseError')
|
|
328
|
+
expect(err.cause).toBe(cause)
|
|
329
|
+
})
|
|
330
|
+
})
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
334
|
+
|
|
335
|
+
```bash
|
|
336
|
+
npx vitest run src/client/errors.test.ts -t 'ClientNetworkError|ClientTimeoutError|ClientAbortError|ClientParseError'
|
|
337
|
+
```
|
|
338
|
+
Expected: FAIL — classes don't exist.
|
|
339
|
+
|
|
340
|
+
- [ ] **Step 3: Add the new classes**
|
|
341
|
+
|
|
342
|
+
Append to `src/client/errors.ts`:
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
export class ClientNetworkError extends Error {
|
|
346
|
+
readonly name = 'ClientNetworkError'
|
|
347
|
+
readonly procedureName: string
|
|
348
|
+
readonly scope: string
|
|
349
|
+
|
|
350
|
+
constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
|
|
351
|
+
super(opts.message ?? `${opts.procedureName} (${opts.scope}) failed: network error`, { cause: opts.cause })
|
|
352
|
+
this.procedureName = opts.procedureName
|
|
353
|
+
this.scope = opts.scope
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export class ClientTimeoutError extends Error {
|
|
358
|
+
readonly name = 'ClientTimeoutError'
|
|
359
|
+
readonly procedureName: string
|
|
360
|
+
readonly scope: string
|
|
361
|
+
readonly timeoutMs: number
|
|
362
|
+
|
|
363
|
+
constructor(opts: { procedureName: string; scope: string; timeoutMs: number; cause?: unknown }) {
|
|
364
|
+
super(`${opts.procedureName} (${opts.scope}) timed out after ${opts.timeoutMs}ms`, { cause: opts.cause })
|
|
365
|
+
this.procedureName = opts.procedureName
|
|
366
|
+
this.scope = opts.scope
|
|
367
|
+
this.timeoutMs = opts.timeoutMs
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export class ClientAbortError extends Error {
|
|
372
|
+
readonly name = 'ClientAbortError'
|
|
373
|
+
readonly procedureName: string
|
|
374
|
+
readonly scope: string
|
|
375
|
+
readonly reason: unknown
|
|
376
|
+
|
|
377
|
+
constructor(opts: { procedureName: string; scope: string; reason?: unknown; cause?: unknown }) {
|
|
378
|
+
super(`${opts.procedureName} (${opts.scope}) aborted`, { cause: opts.cause })
|
|
379
|
+
this.procedureName = opts.procedureName
|
|
380
|
+
this.scope = opts.scope
|
|
381
|
+
this.reason = opts.reason
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export class ClientParseError extends Error {
|
|
386
|
+
readonly name = 'ClientParseError'
|
|
387
|
+
readonly procedureName: string
|
|
388
|
+
readonly scope: string
|
|
389
|
+
|
|
390
|
+
constructor(opts: { procedureName: string; scope: string; cause?: unknown; message?: string }) {
|
|
391
|
+
super(opts.message ?? `${opts.procedureName} (${opts.scope}) response could not be parsed`, { cause: opts.cause })
|
|
392
|
+
this.procedureName = opts.procedureName
|
|
393
|
+
this.scope = opts.scope
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
- [ ] **Step 4: Export from `src/client/index.ts`**
|
|
399
|
+
|
|
400
|
+
Add the four new classes to the existing barrel exports.
|
|
401
|
+
|
|
402
|
+
- [ ] **Step 5: Run tests to verify pass**
|
|
403
|
+
|
|
404
|
+
```bash
|
|
405
|
+
npx vitest run src/client/errors.test.ts
|
|
406
|
+
```
|
|
407
|
+
Expected: PASS.
|
|
408
|
+
|
|
409
|
+
- [ ] **Step 6: Commit**
|
|
410
|
+
|
|
411
|
+
```bash
|
|
412
|
+
git add src/client/errors.ts src/client/errors.test.ts src/client/index.ts
|
|
413
|
+
git commit -m "feat(client/errors): add ClientNetworkError, ClientTimeoutError, ClientAbortError, ClientParseError"
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
---
|
|
417
|
+
|
|
418
|
+
## Phase 2 — Default classifier
|
|
419
|
+
|
|
420
|
+
### Task 4: Create `classify-error.ts` and default classifier
|
|
421
|
+
|
|
422
|
+
**Files:**
|
|
423
|
+
- Create: `src/client/classify-error.ts`
|
|
424
|
+
- Test: `src/client/classify-error.test.ts`
|
|
425
|
+
|
|
426
|
+
- [ ] **Step 1: Write failing tests**
|
|
427
|
+
|
|
428
|
+
Create `src/client/classify-error.test.ts`:
|
|
429
|
+
|
|
430
|
+
```ts
|
|
431
|
+
import { describe, it, expect } from 'vitest'
|
|
432
|
+
import { defaultClassifyError } from './classify-error.js'
|
|
433
|
+
import {
|
|
434
|
+
ClientNetworkError,
|
|
435
|
+
ClientTimeoutError,
|
|
436
|
+
ClientAbortError,
|
|
437
|
+
} from './errors.js'
|
|
438
|
+
|
|
439
|
+
const meta = { procedureName: 'GetUser', scope: 'users' }
|
|
440
|
+
|
|
441
|
+
describe('defaultClassifyError', () => {
|
|
442
|
+
it('classifies fetch TypeError as network', () => {
|
|
443
|
+
const result = defaultClassifyError(new TypeError('Failed to fetch'), { ...meta })
|
|
444
|
+
expect(result?.kind).toBe('network')
|
|
445
|
+
expect(result?.error).toBeInstanceOf(ClientNetworkError)
|
|
446
|
+
expect(result?.error.cause).toBeInstanceOf(TypeError)
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
it('classifies AbortError as timeout when timeout signal fired', () => {
|
|
450
|
+
const timeoutSignal = AbortSignal.timeout(0)
|
|
451
|
+
// wait a tick for the timeout to fire
|
|
452
|
+
return new Promise<void>((resolve) => setTimeout(() => {
|
|
453
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
454
|
+
const result = defaultClassifyError(abortErr, { ...meta, timeoutSignal, timeoutMs: 5000 })
|
|
455
|
+
expect(result?.kind).toBe('timeout')
|
|
456
|
+
expect(result?.error).toBeInstanceOf(ClientTimeoutError)
|
|
457
|
+
expect((result?.error as ClientTimeoutError).timeoutMs).toBe(5000)
|
|
458
|
+
resolve()
|
|
459
|
+
}, 1))
|
|
460
|
+
})
|
|
461
|
+
|
|
462
|
+
it('classifies AbortError as aborted when user signal fired', () => {
|
|
463
|
+
const userController = new AbortController()
|
|
464
|
+
userController.abort('user-cancelled')
|
|
465
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
466
|
+
const result = defaultClassifyError(abortErr, { ...meta, userSignal: userController.signal })
|
|
467
|
+
expect(result?.kind).toBe('aborted')
|
|
468
|
+
expect(result?.error).toBeInstanceOf(ClientAbortError)
|
|
469
|
+
expect((result?.error as ClientAbortError).reason).toBe('user-cancelled')
|
|
470
|
+
})
|
|
471
|
+
|
|
472
|
+
it('classifies AbortError as timeout when both fired (timeout precedence)', () => {
|
|
473
|
+
const timeoutSignal = AbortSignal.timeout(0)
|
|
474
|
+
const userController = new AbortController()
|
|
475
|
+
return new Promise<void>((resolve) => setTimeout(() => {
|
|
476
|
+
userController.abort('user')
|
|
477
|
+
const abortErr = new DOMException('aborted', 'AbortError')
|
|
478
|
+
const result = defaultClassifyError(abortErr, {
|
|
479
|
+
...meta, timeoutSignal, timeoutMs: 1, userSignal: userController.signal,
|
|
480
|
+
})
|
|
481
|
+
expect(result?.kind).toBe('timeout')
|
|
482
|
+
resolve()
|
|
483
|
+
}, 1))
|
|
484
|
+
})
|
|
485
|
+
|
|
486
|
+
it('returns null for unknown errors', () => {
|
|
487
|
+
const result = defaultClassifyError(new Error('weird'), { ...meta })
|
|
488
|
+
expect(result).toBeNull()
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it('returns null for non-Error throws', () => {
|
|
492
|
+
expect(defaultClassifyError('string-thrown', { ...meta })).toBeNull()
|
|
493
|
+
expect(defaultClassifyError(42, { ...meta })).toBeNull()
|
|
494
|
+
})
|
|
495
|
+
})
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
- [ ] **Step 2: Run tests to verify they fail**
|
|
499
|
+
|
|
500
|
+
```bash
|
|
501
|
+
npx vitest run src/client/classify-error.test.ts
|
|
502
|
+
```
|
|
503
|
+
Expected: FAIL — file does not exist.
|
|
504
|
+
|
|
505
|
+
- [ ] **Step 3: Implement `classify-error.ts`**
|
|
506
|
+
|
|
507
|
+
Create `src/client/classify-error.ts`:
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
import {
|
|
511
|
+
ClientNetworkError,
|
|
512
|
+
ClientTimeoutError,
|
|
513
|
+
ClientAbortError,
|
|
514
|
+
} from './errors.js'
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* Classification context — supplies provenance the classifier needs that's
|
|
518
|
+
* not derivable from the raw error itself.
|
|
519
|
+
*
|
|
520
|
+
* `timeoutSignal` and `userSignal` let the classifier distinguish a timeout
|
|
521
|
+
* abort from a user-initiated abort when an `AbortError` lands.
|
|
522
|
+
*/
|
|
523
|
+
export interface ClassifyErrorContext {
|
|
524
|
+
procedureName: string
|
|
525
|
+
scope: string
|
|
526
|
+
timeoutSignal?: AbortSignal
|
|
527
|
+
userSignal?: AbortSignal
|
|
528
|
+
timeoutMs?: number
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The output shape of a successful classification. Contract: `error` is
|
|
533
|
+
* always an `Error` subclass (the framework class). Non-`Error` values fall
|
|
534
|
+
* through to `null` (handled by `executeCall` as `kind: 'unknown'`).
|
|
535
|
+
*/
|
|
536
|
+
export interface ClassifiedError {
|
|
537
|
+
kind: string
|
|
538
|
+
error: Error
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Adapter-provided classifier — runs before `defaultClassifyError`. Return
|
|
543
|
+
* `null` to fall through to the default. Adapter authors should compose with
|
|
544
|
+
* the default explicitly:
|
|
545
|
+
*
|
|
546
|
+
* classifyError: (e, ctx) => myClassify(e, ctx) ?? defaultClassifyError(e, ctx)
|
|
547
|
+
*/
|
|
548
|
+
export type ErrorClassifier = (
|
|
549
|
+
raw: unknown,
|
|
550
|
+
ctx: ClassifyErrorContext,
|
|
551
|
+
) => ClassifiedError | null
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* Default classifier — recognizes:
|
|
555
|
+
* - `TypeError` from fetch → `ClientNetworkError`
|
|
556
|
+
* - `DOMException` with `name: 'AbortError'` + timeout-signal-aborted → `ClientTimeoutError`
|
|
557
|
+
* - `DOMException` with `name: 'AbortError'` + user-signal-aborted → `ClientAbortError`
|
|
558
|
+
*
|
|
559
|
+
* Returns `null` for anything else. Timeout precedence: when both signals
|
|
560
|
+
* fired, classifies as `timeout`.
|
|
561
|
+
*/
|
|
562
|
+
export const defaultClassifyError: ErrorClassifier = (raw, ctx) => {
|
|
563
|
+
const meta = { procedureName: ctx.procedureName, scope: ctx.scope }
|
|
564
|
+
|
|
565
|
+
if (raw instanceof TypeError) {
|
|
566
|
+
return {
|
|
567
|
+
kind: 'network',
|
|
568
|
+
error: new ClientNetworkError({ ...meta, cause: raw, message: raw.message }),
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
if (
|
|
573
|
+
raw instanceof DOMException &&
|
|
574
|
+
raw.name === 'AbortError'
|
|
575
|
+
) {
|
|
576
|
+
if (ctx.timeoutSignal?.aborted) {
|
|
577
|
+
return {
|
|
578
|
+
kind: 'timeout',
|
|
579
|
+
error: new ClientTimeoutError({
|
|
580
|
+
...meta,
|
|
581
|
+
timeoutMs: ctx.timeoutMs ?? 0,
|
|
582
|
+
cause: raw,
|
|
583
|
+
}),
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
if (ctx.userSignal?.aborted) {
|
|
587
|
+
return {
|
|
588
|
+
kind: 'aborted',
|
|
589
|
+
error: new ClientAbortError({
|
|
590
|
+
...meta,
|
|
591
|
+
reason: ctx.userSignal.reason,
|
|
592
|
+
cause: raw,
|
|
593
|
+
}),
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
// AbortError without a tracked source — treat as user abort with no reason.
|
|
597
|
+
return {
|
|
598
|
+
kind: 'aborted',
|
|
599
|
+
error: new ClientAbortError({ ...meta, cause: raw }),
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
return null
|
|
604
|
+
}
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
- [ ] **Step 4: Run tests to verify pass**
|
|
608
|
+
|
|
609
|
+
```bash
|
|
610
|
+
npx vitest run src/client/classify-error.test.ts
|
|
611
|
+
```
|
|
612
|
+
Expected: PASS.
|
|
613
|
+
|
|
614
|
+
- [ ] **Step 5: Export classifier from `src/client/index.ts`**
|
|
615
|
+
|
|
616
|
+
Add: `export { defaultClassifyError, type ErrorClassifier, type ClassifyErrorContext, type ClassifiedError } from './classify-error.js'`
|
|
617
|
+
|
|
618
|
+
- [ ] **Step 6: Commit**
|
|
619
|
+
|
|
620
|
+
```bash
|
|
621
|
+
git add src/client/classify-error.ts src/client/classify-error.test.ts src/client/index.ts
|
|
622
|
+
git commit -m "feat(client): add error classifier with default platform-error coverage"
|
|
623
|
+
```
|
|
624
|
+
|
|
625
|
+
---
|
|
626
|
+
|
|
627
|
+
## Phase 3 — Wire classifier into runtime
|
|
628
|
+
|
|
629
|
+
### Task 5: Refactor `resolveSignal` to expose source signals
|
|
630
|
+
|
|
631
|
+
**Files:**
|
|
632
|
+
- Modify: `src/client/resolve-options.ts`
|
|
633
|
+
- Test: `src/client/resolve-options.test.ts`
|
|
634
|
+
|
|
635
|
+
The classifier needs the original timeout + user signals (not just the combined `AbortSignal.any` result). This task surfaces them.
|
|
636
|
+
|
|
637
|
+
- [ ] **Step 1: Write failing test**
|
|
638
|
+
|
|
639
|
+
Add to `src/client/resolve-options.test.ts`:
|
|
640
|
+
|
|
641
|
+
```ts
|
|
642
|
+
import { resolveSignalSources } from './resolve-options.js'
|
|
643
|
+
|
|
644
|
+
describe('resolveSignalSources', () => {
|
|
645
|
+
it('returns timeout signal alongside combined', () => {
|
|
646
|
+
const result = resolveSignalSources(undefined, { timeout: 100 })
|
|
647
|
+
expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
|
|
648
|
+
expect(result.timeoutMs).toBe(100)
|
|
649
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
650
|
+
expect(result.userSignal).toBeUndefined()
|
|
651
|
+
})
|
|
652
|
+
|
|
653
|
+
it('returns user signal alongside combined', () => {
|
|
654
|
+
const ctrl = new AbortController()
|
|
655
|
+
const result = resolveSignalSources(undefined, { signal: ctrl.signal })
|
|
656
|
+
expect(result.userSignal).toBe(ctrl.signal)
|
|
657
|
+
expect(result.timeoutSignal).toBeUndefined()
|
|
658
|
+
})
|
|
659
|
+
|
|
660
|
+
it('combines both via AbortSignal.any', () => {
|
|
661
|
+
const ctrl = new AbortController()
|
|
662
|
+
const result = resolveSignalSources(undefined, { signal: ctrl.signal, timeout: 100 })
|
|
663
|
+
expect(result.timeoutSignal).toBeInstanceOf(AbortSignal)
|
|
664
|
+
expect(result.userSignal).toBe(ctrl.signal)
|
|
665
|
+
expect(result.combined).toBeInstanceOf(AbortSignal)
|
|
666
|
+
})
|
|
667
|
+
|
|
668
|
+
it('returns undefined combined when no signals', () => {
|
|
669
|
+
const result = resolveSignalSources(undefined, undefined)
|
|
670
|
+
expect(result.combined).toBeUndefined()
|
|
671
|
+
})
|
|
672
|
+
})
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
- [ ] **Step 2: Run test to verify it fails**
|
|
676
|
+
|
|
677
|
+
```bash
|
|
678
|
+
npx vitest run src/client/resolve-options.test.ts -t 'resolveSignalSources'
|
|
679
|
+
```
|
|
680
|
+
Expected: FAIL.
|
|
681
|
+
|
|
682
|
+
- [ ] **Step 3: Implement `resolveSignalSources`**
|
|
683
|
+
|
|
684
|
+
Add to `src/client/resolve-options.ts`:
|
|
685
|
+
|
|
686
|
+
```ts
|
|
687
|
+
export interface SignalSources {
|
|
688
|
+
combined?: AbortSignal
|
|
689
|
+
timeoutSignal?: AbortSignal
|
|
690
|
+
userSignal?: AbortSignal
|
|
691
|
+
timeoutMs?: number
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
export function resolveSignalSources(
|
|
695
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
696
|
+
options: ProcedureCallOptions | undefined,
|
|
697
|
+
): SignalSources {
|
|
698
|
+
const userSignal = options?.signal ?? defaults?.signal
|
|
699
|
+
const timeoutMs = options?.timeout ?? defaults?.timeout
|
|
700
|
+
const timeoutSignal =
|
|
701
|
+
timeoutMs != null && timeoutMs > 0 ? AbortSignal.timeout(timeoutMs) : undefined
|
|
702
|
+
|
|
703
|
+
const signals: AbortSignal[] = []
|
|
704
|
+
if (userSignal) signals.push(userSignal)
|
|
705
|
+
if (timeoutSignal) signals.push(timeoutSignal)
|
|
706
|
+
|
|
707
|
+
let combined: AbortSignal | undefined
|
|
708
|
+
if (signals.length === 1) combined = signals[0]
|
|
709
|
+
else if (signals.length > 1) combined = AbortSignal.any(signals)
|
|
710
|
+
|
|
711
|
+
return { combined, timeoutSignal, userSignal, timeoutMs }
|
|
712
|
+
}
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
- [ ] **Step 4: Refactor `applyRequestOptions` to use `resolveSignalSources` and return both the request and the sources**
|
|
716
|
+
|
|
717
|
+
Replace `applyRequestOptions`:
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
export interface ApplyRequestOptionsResult {
|
|
721
|
+
request: AdapterRequest
|
|
722
|
+
signalSources: SignalSources
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
export function applyRequestOptions(
|
|
726
|
+
request: AdapterRequest,
|
|
727
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
728
|
+
options: ProcedureCallOptions | undefined,
|
|
729
|
+
): ApplyRequestOptionsResult {
|
|
730
|
+
const signalSources = resolveSignalSources(defaults, options)
|
|
731
|
+
const resolvedHeaders = resolveHeaders(defaults, options)
|
|
732
|
+
const meta = resolveMeta(defaults, options)
|
|
733
|
+
|
|
734
|
+
const headers =
|
|
735
|
+
resolvedHeaders || request.headers
|
|
736
|
+
? { ...resolvedHeaders, ...request.headers }
|
|
737
|
+
: undefined
|
|
738
|
+
|
|
739
|
+
return {
|
|
740
|
+
request: { ...request, headers, signal: signalSources.combined, meta },
|
|
741
|
+
signalSources,
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
Keep `resolveSignal` exported as a thin wrapper for backwards-compat with anyone importing it directly:
|
|
747
|
+
|
|
748
|
+
```ts
|
|
749
|
+
export function resolveSignal(
|
|
750
|
+
defaults: ProcedureCallDefaults | undefined,
|
|
751
|
+
options: ProcedureCallOptions | undefined,
|
|
752
|
+
): AbortSignal | undefined {
|
|
753
|
+
return resolveSignalSources(defaults, options).combined
|
|
754
|
+
}
|
|
755
|
+
```
|
|
756
|
+
|
|
757
|
+
- [ ] **Step 5: Update existing `resolve-options.test.ts` tests to handle the new return shape**
|
|
758
|
+
|
|
759
|
+
Find every `applyRequestOptions(...)` call in tests and unwrap `.request`. Easiest: introduce a helper at top of test file:
|
|
760
|
+
```ts
|
|
761
|
+
const apply = (req, d, o) => applyRequestOptions(req, d, o).request
|
|
762
|
+
```
|
|
763
|
+
Then `replace_all` `applyRequestOptions(` with `apply(` *only* in test files (not in src!).
|
|
764
|
+
|
|
765
|
+
- [ ] **Step 6: Update `executeCall` and `executeStream` callers to destructure `request` from the return value**
|
|
766
|
+
|
|
767
|
+
In `src/client/call.ts`, change:
|
|
768
|
+
```ts
|
|
769
|
+
request = applyRequestOptions(request, defaults, options)
|
|
770
|
+
```
|
|
771
|
+
To:
|
|
772
|
+
```ts
|
|
773
|
+
const applied = applyRequestOptions(request, defaults, options)
|
|
774
|
+
request = applied.request
|
|
775
|
+
const signalSources = applied.signalSources // used in Task 6
|
|
776
|
+
```
|
|
777
|
+
|
|
778
|
+
Same change in `src/client/stream.ts`.
|
|
779
|
+
|
|
780
|
+
- [ ] **Step 7: Verify no stale `applyRequestOptions(` callers were missed**
|
|
781
|
+
|
|
782
|
+
```bash
|
|
783
|
+
grep -rn 'applyRequestOptions(' src/ --include='*.ts'
|
|
784
|
+
```
|
|
785
|
+
Expected output: only the implementation in `src/client/resolve-options.ts` and the in-test helper `apply` definition. If any other test file still references `applyRequestOptions(...)` directly without the `.request` unwrap, fix before continuing.
|
|
786
|
+
|
|
787
|
+
- [ ] **Step 8: Run full suite to confirm no regressions**
|
|
788
|
+
|
|
789
|
+
```bash
|
|
790
|
+
npm run test
|
|
791
|
+
```
|
|
792
|
+
Expected: PASS.
|
|
793
|
+
|
|
794
|
+
- [ ] **Step 9: Commit**
|
|
795
|
+
|
|
796
|
+
```bash
|
|
797
|
+
git add src/client/resolve-options.ts src/client/resolve-options.test.ts src/client/call.ts src/client/stream.ts
|
|
798
|
+
git commit -m "refactor(client): expose signal sources from applyRequestOptions
|
|
799
|
+
|
|
800
|
+
Lets the classifier distinguish timeout abort from user abort by
|
|
801
|
+
checking which underlying signal fired first."
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
### Task 6: Wire classifier into `executeCall`
|
|
805
|
+
|
|
806
|
+
**Files:**
|
|
807
|
+
- Modify: `src/client/call.ts`
|
|
808
|
+
- Modify: `src/client/types.ts` (extend `ClientAdapter`)
|
|
809
|
+
- Test: `src/client/call.test.ts`
|
|
810
|
+
|
|
811
|
+
- [ ] **Step 1: Extend `ClientAdapter` interface with optional `classifyError`**
|
|
812
|
+
|
|
813
|
+
In `src/client/types.ts`:
|
|
814
|
+
|
|
815
|
+
```ts
|
|
816
|
+
import type { ErrorClassifier } from './classify-error.js'
|
|
817
|
+
|
|
818
|
+
export interface ClientAdapter {
|
|
819
|
+
request(config: AdapterRequest): Promise<AdapterResponse>
|
|
820
|
+
stream(config: AdapterRequest): Promise<AdapterStreamResponse>
|
|
821
|
+
/** Optional adapter-level error classifier — composes with `defaultClassifyError`. */
|
|
822
|
+
classifyError?: ErrorClassifier
|
|
823
|
+
}
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
- [ ] **Step 2: Write failing test for normalized errors**
|
|
827
|
+
|
|
828
|
+
Add to `src/client/call.test.ts`:
|
|
829
|
+
|
|
830
|
+
```ts
|
|
831
|
+
import {
|
|
832
|
+
ClientNetworkError,
|
|
833
|
+
ClientTimeoutError,
|
|
834
|
+
ClientAbortError,
|
|
835
|
+
ClientHttpError,
|
|
836
|
+
} from './errors.js'
|
|
837
|
+
|
|
838
|
+
describe('executeCall classifier integration', () => {
|
|
839
|
+
it('throws ClientNetworkError when adapter throws TypeError', async () => {
|
|
840
|
+
const adapter: ClientAdapter = {
|
|
841
|
+
request: vi.fn(async () => { throw new TypeError('Failed to fetch') }),
|
|
842
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
843
|
+
}
|
|
844
|
+
await expect(run({ adapter })).rejects.toBeInstanceOf(ClientNetworkError)
|
|
845
|
+
})
|
|
846
|
+
|
|
847
|
+
it('throws ClientTimeoutError when adapter rejects with AbortError on timeout signal', async () => {
|
|
848
|
+
const adapter: ClientAdapter = {
|
|
849
|
+
request: vi.fn(async () => { throw new DOMException('aborted', 'AbortError') }),
|
|
850
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
851
|
+
}
|
|
852
|
+
await expect(
|
|
853
|
+
run({ adapter, options: { timeout: 1 } })
|
|
854
|
+
).rejects.toBeInstanceOf(ClientTimeoutError)
|
|
855
|
+
})
|
|
856
|
+
|
|
857
|
+
it('uses adapter-provided classifier first', async () => {
|
|
858
|
+
class CustomError extends Error { readonly name = 'CustomError' }
|
|
859
|
+
const adapter: ClientAdapter = {
|
|
860
|
+
request: vi.fn(async () => { throw new TypeError('x') }),
|
|
861
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
862
|
+
classifyError: () => ({ kind: 'custom', error: new CustomError('classified') }),
|
|
863
|
+
}
|
|
864
|
+
await expect(run({ adapter })).rejects.toBeInstanceOf(CustomError)
|
|
865
|
+
})
|
|
866
|
+
|
|
867
|
+
it('still throws ClientHttpError for non-2xx responses (registry path unchanged)', async () => {
|
|
868
|
+
const adapter = makeAdapter({ status: 500, body: { message: 'oops' } })
|
|
869
|
+
await expect(run({ adapter })).rejects.toBeInstanceOf(ClientHttpError)
|
|
870
|
+
})
|
|
871
|
+
|
|
872
|
+
it('hooks see the normalized error, not the raw TypeError', async () => {
|
|
873
|
+
const seen: unknown[] = []
|
|
874
|
+
const adapter: ClientAdapter = {
|
|
875
|
+
request: vi.fn(async () => { throw new TypeError('x') }),
|
|
876
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
877
|
+
}
|
|
878
|
+
const hooks = { onError: ({ error }: { error: unknown }) => { seen.push(error) } }
|
|
879
|
+
await expect(run({ adapter, hooks })).rejects.toBeInstanceOf(ClientNetworkError)
|
|
880
|
+
expect(seen[0]).toBeInstanceOf(ClientNetworkError)
|
|
881
|
+
})
|
|
882
|
+
})
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
- [ ] **Step 3: Run tests to verify they fail**
|
|
886
|
+
|
|
887
|
+
```bash
|
|
888
|
+
npx vitest run src/client/call.test.ts -t 'classifier integration'
|
|
889
|
+
```
|
|
890
|
+
Expected: FAIL.
|
|
891
|
+
|
|
892
|
+
- [ ] **Step 4: Update `executeCall` to classify adapter errors**
|
|
893
|
+
|
|
894
|
+
Replace the try/catch around `adapter.request()` in `src/client/call.ts`:
|
|
895
|
+
|
|
896
|
+
```ts
|
|
897
|
+
import { defaultClassifyError } from './classify-error.js'
|
|
898
|
+
// ... existing imports ...
|
|
899
|
+
|
|
900
|
+
// inside executeCall, after `applied = applyRequestOptions(...)`:
|
|
901
|
+
|
|
902
|
+
let response
|
|
903
|
+
try {
|
|
904
|
+
response = await adapter.request(request)
|
|
905
|
+
} catch (rawErr) {
|
|
906
|
+
// Classify: adapter classifier first, default second, fallthrough = re-throw raw
|
|
907
|
+
const classifyCtx = {
|
|
908
|
+
procedureName: descriptor.name,
|
|
909
|
+
scope: descriptor.scope,
|
|
910
|
+
timeoutSignal: signalSources.timeoutSignal,
|
|
911
|
+
userSignal: signalSources.userSignal,
|
|
912
|
+
timeoutMs: signalSources.timeoutMs,
|
|
913
|
+
}
|
|
914
|
+
const classified =
|
|
915
|
+
adapter.classifyError?.(rawErr, classifyCtx) ??
|
|
916
|
+
defaultClassifyError(rawErr, classifyCtx)
|
|
917
|
+
const finalError = classified?.error ?? rawErr
|
|
918
|
+
|
|
919
|
+
// Hooks see the normalized error
|
|
920
|
+
await runOnError(
|
|
921
|
+
{ procedureName: descriptor.name, scope: descriptor.scope, request, error: finalError },
|
|
922
|
+
hooks,
|
|
923
|
+
options,
|
|
924
|
+
)
|
|
925
|
+
throw finalError
|
|
926
|
+
}
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
- [ ] **Step 5: Run tests to verify pass**
|
|
930
|
+
|
|
931
|
+
```bash
|
|
932
|
+
npx vitest run src/client/call.test.ts
|
|
933
|
+
```
|
|
934
|
+
Expected: PASS.
|
|
935
|
+
|
|
936
|
+
- [ ] **Step 6: Commit**
|
|
937
|
+
|
|
938
|
+
```bash
|
|
939
|
+
git add src/client/types.ts src/client/call.ts src/client/call.test.ts
|
|
940
|
+
git commit -m "feat(client/call): classify adapter errors into framework classes
|
|
941
|
+
|
|
942
|
+
executeCall now wraps adapter throws in ClientNetworkError /
|
|
943
|
+
ClientTimeoutError / ClientAbortError via the classifier composition
|
|
944
|
+
(adapter classifier > default > fallthrough). Hooks receive the
|
|
945
|
+
normalized error.
|
|
946
|
+
|
|
947
|
+
BREAKING CHANGE: Raw DOMException / TypeError no longer reach
|
|
948
|
+
consumer catch blocks — the framework throws normalized classes.
|
|
949
|
+
Migration: catch the framework class instead (instanceof
|
|
950
|
+
ClientTimeoutError, etc.)."
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
### Task 7: Wire classifier into `executeStream` pre-stream path
|
|
954
|
+
|
|
955
|
+
**Files:**
|
|
956
|
+
- Modify: `src/client/stream.ts`
|
|
957
|
+
- Test: `src/client/stream.test.ts`
|
|
958
|
+
|
|
959
|
+
- [ ] **Step 1: Write failing test**
|
|
960
|
+
|
|
961
|
+
Add to `src/client/stream.test.ts` (mirror the call.test.ts pattern, scoped to pre-stream errors only):
|
|
962
|
+
|
|
963
|
+
```ts
|
|
964
|
+
import { ClientNetworkError } from './errors.js'
|
|
965
|
+
|
|
966
|
+
describe('executeStream classifier integration', () => {
|
|
967
|
+
it('classifies pre-stream TypeError as ClientNetworkError', async () => {
|
|
968
|
+
const adapter: ClientAdapter = {
|
|
969
|
+
request: vi.fn(async () => { throw new Error('n/a') }),
|
|
970
|
+
stream: vi.fn(async () => { throw new TypeError('Failed to fetch') }),
|
|
971
|
+
}
|
|
972
|
+
await expect(runStream({ adapter })).rejects.toBeInstanceOf(ClientNetworkError)
|
|
973
|
+
})
|
|
974
|
+
})
|
|
975
|
+
```
|
|
976
|
+
|
|
977
|
+
(Use the existing `runStream` helper or add one mirroring `run`.)
|
|
978
|
+
|
|
979
|
+
- [ ] **Step 2: Run to verify fail**
|
|
980
|
+
|
|
981
|
+
```bash
|
|
982
|
+
npx vitest run src/client/stream.test.ts -t 'classifier integration'
|
|
983
|
+
```
|
|
984
|
+
|
|
985
|
+
- [ ] **Step 3: Apply the same classifier composition to the pre-stream try/catch**
|
|
986
|
+
|
|
987
|
+
In `src/client/stream.ts`, update the try/catch around `adapter.stream()` identically to Task 6 Step 4 (using `signalSources` from the applied options).
|
|
988
|
+
|
|
989
|
+
- [ ] **Step 4: Run to verify pass**
|
|
990
|
+
|
|
991
|
+
```bash
|
|
992
|
+
npx vitest run src/client/stream.test.ts
|
|
993
|
+
```
|
|
994
|
+
|
|
995
|
+
- [ ] **Step 5: Commit**
|
|
996
|
+
|
|
997
|
+
```bash
|
|
998
|
+
git add src/client/stream.ts src/client/stream.test.ts
|
|
999
|
+
git commit -m "feat(client/stream): classify pre-stream adapter errors
|
|
1000
|
+
|
|
1001
|
+
Mid-stream errors are unchanged — only the pre-stream connection
|
|
1002
|
+
path uses the classifier."
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
### Task 8: Wire classifier into `createFetchAdapter`
|
|
1006
|
+
|
|
1007
|
+
**Files:**
|
|
1008
|
+
- Modify: `src/client/fetch-adapter.ts`
|
|
1009
|
+
- Test: `src/client/fetch-adapter.test.ts`
|
|
1010
|
+
|
|
1011
|
+
- [ ] **Step 1: Write failing test**
|
|
1012
|
+
|
|
1013
|
+
Add to `src/client/fetch-adapter.test.ts`:
|
|
1014
|
+
|
|
1015
|
+
```ts
|
|
1016
|
+
import { defaultClassifyError } from './classify-error.js'
|
|
1017
|
+
|
|
1018
|
+
describe('createFetchAdapter classifyError', () => {
|
|
1019
|
+
it('passes through to adapter.classifyError', () => {
|
|
1020
|
+
const customClassifier = vi.fn()
|
|
1021
|
+
const adapter = createFetchAdapter({ classifyError: customClassifier })
|
|
1022
|
+
expect(adapter.classifyError).toBe(customClassifier)
|
|
1023
|
+
})
|
|
1024
|
+
|
|
1025
|
+
it('does not set classifyError when not provided', () => {
|
|
1026
|
+
const adapter = createFetchAdapter()
|
|
1027
|
+
expect(adapter.classifyError).toBeUndefined()
|
|
1028
|
+
})
|
|
1029
|
+
})
|
|
1030
|
+
```
|
|
1031
|
+
|
|
1032
|
+
- [ ] **Step 2: Run to verify fail**
|
|
1033
|
+
|
|
1034
|
+
```bash
|
|
1035
|
+
npx vitest run src/client/fetch-adapter.test.ts -t 'classifyError'
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
- [ ] **Step 3: Update `createFetchAdapter`**
|
|
1039
|
+
|
|
1040
|
+
In `src/client/fetch-adapter.ts`:
|
|
1041
|
+
|
|
1042
|
+
```ts
|
|
1043
|
+
export interface FetchAdapterConfig {
|
|
1044
|
+
headers?: Record<string, string>
|
|
1045
|
+
classifyError?: ErrorClassifier
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
|
|
1049
|
+
// ... existing body ...
|
|
1050
|
+
return {
|
|
1051
|
+
async request(req) { /* existing */ },
|
|
1052
|
+
async stream(req) { /* existing */ },
|
|
1053
|
+
classifyError: config?.classifyError,
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
- [ ] **Step 4: Run to verify pass**
|
|
1059
|
+
|
|
1060
|
+
```bash
|
|
1061
|
+
npx vitest run src/client/fetch-adapter.test.ts
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
- [ ] **Step 5: Commit**
|
|
1065
|
+
|
|
1066
|
+
```bash
|
|
1067
|
+
git add src/client/fetch-adapter.ts src/client/fetch-adapter.test.ts
|
|
1068
|
+
git commit -m "feat(client/fetch-adapter): accept optional classifyError config"
|
|
1069
|
+
```
|
|
1070
|
+
|
|
1071
|
+
---
|
|
1072
|
+
|
|
1073
|
+
## Phase 4 — Result type derivation
|
|
1074
|
+
|
|
1075
|
+
### Task 8.5: Enable vitest type-checking for `.test-d.ts` files
|
|
1076
|
+
|
|
1077
|
+
**Files:**
|
|
1078
|
+
- Modify: `vitest.config.ts`
|
|
1079
|
+
|
|
1080
|
+
The `.test-d.ts` files in Tasks 9, 10 use `expectTypeOf` from vitest. These only run type-level assertions when vitest's `typecheck` mode is enabled — without it, the files are parsed but type errors are silently ignored. This task wires it up before the first `.test-d.ts` lands.
|
|
1081
|
+
|
|
1082
|
+
- [ ] **Step 1: Update `vitest.config.ts`**
|
|
1083
|
+
|
|
1084
|
+
```ts
|
|
1085
|
+
import { defineConfig } from 'vitest/config'
|
|
1086
|
+
|
|
1087
|
+
export default defineConfig({
|
|
1088
|
+
test: {
|
|
1089
|
+
globals: true,
|
|
1090
|
+
environment: 'node',
|
|
1091
|
+
exclude: ['**/node_modules/**', '**/build/**', '**/dist/**'],
|
|
1092
|
+
typecheck: {
|
|
1093
|
+
enabled: true,
|
|
1094
|
+
include: ['**/*.test-d.ts'],
|
|
1095
|
+
tsconfig: './tsconfig.json',
|
|
1096
|
+
},
|
|
1097
|
+
},
|
|
1098
|
+
})
|
|
1099
|
+
```
|
|
1100
|
+
|
|
1101
|
+
- [ ] **Step 2: Sanity check — write a deliberately-failing type-test, run it, confirm vitest catches it, then revert**
|
|
1102
|
+
|
|
1103
|
+
```bash
|
|
1104
|
+
cat > /tmp/sanity.test-d.ts <<'EOF'
|
|
1105
|
+
import { expectTypeOf } from 'vitest'
|
|
1106
|
+
expectTypeOf<string>().toEqualTypeOf<number>() // should fail
|
|
1107
|
+
EOF
|
|
1108
|
+
cp /tmp/sanity.test-d.ts src/client/sanity.test-d.ts
|
|
1109
|
+
npx vitest run src/client/sanity.test-d.ts
|
|
1110
|
+
# Expected: FAIL with type-mismatch error
|
|
1111
|
+
rm src/client/sanity.test-d.ts
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
If this passes silently, the typecheck wiring isn't active — debug before proceeding.
|
|
1115
|
+
|
|
1116
|
+
- [ ] **Step 3: Commit**
|
|
1117
|
+
|
|
1118
|
+
```bash
|
|
1119
|
+
git add vitest.config.ts
|
|
1120
|
+
git commit -m "test(vitest): enable typecheck for .test-d.ts files
|
|
1121
|
+
|
|
1122
|
+
Required so the type-level tests added in subsequent tasks
|
|
1123
|
+
(Result, ResultNoTyped, ClientErrorMap augmentation) actually
|
|
1124
|
+
run their assertions."
|
|
1125
|
+
```
|
|
1126
|
+
|
|
1127
|
+
### Task 9: Add `ClientErrorMap`, `Result`, `ResultNoTyped`, `FrameworkFailure`
|
|
1128
|
+
|
|
1129
|
+
**Files:**
|
|
1130
|
+
- Modify: `src/client/types.ts`
|
|
1131
|
+
- Test: `src/client/result-type.test-d.ts` (type-only test)
|
|
1132
|
+
- Modify: `src/client/index.ts`
|
|
1133
|
+
|
|
1134
|
+
- [ ] **Step 1: Add the type definitions to `src/client/types.ts`**
|
|
1135
|
+
|
|
1136
|
+
Append:
|
|
1137
|
+
|
|
1138
|
+
```ts
|
|
1139
|
+
import type {
|
|
1140
|
+
ClientHttpError,
|
|
1141
|
+
ClientNetworkError,
|
|
1142
|
+
ClientTimeoutError,
|
|
1143
|
+
ClientAbortError,
|
|
1144
|
+
ClientParseError,
|
|
1145
|
+
ClientPathParamError,
|
|
1146
|
+
} from './errors.js'
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Augmentable map of `kind` discriminant → error class for the framework's
|
|
1150
|
+
* non-typed failure categories. Mirrors the `RequestMeta` augmentation
|
|
1151
|
+
* pattern: extend via TypeScript declaration merging.
|
|
1152
|
+
*
|
|
1153
|
+
* @example
|
|
1154
|
+
* ```ts
|
|
1155
|
+
* declare module 'ts-procedures/client' {
|
|
1156
|
+
* interface ClientErrorMap {
|
|
1157
|
+
* rateLimited: MyRateLimitError
|
|
1158
|
+
* paymentRequired: MyPaymentError
|
|
1159
|
+
* }
|
|
1160
|
+
* }
|
|
1161
|
+
* ```
|
|
1162
|
+
*
|
|
1163
|
+
* **`'typed'` is reserved** — it's the discriminant used by `Result` for
|
|
1164
|
+
* route-declared errors and is not part of `ClientErrorMap`. Attempting to
|
|
1165
|
+
* register it produces a TS error about overlapping discriminants.
|
|
1166
|
+
*/
|
|
1167
|
+
export interface ClientErrorMap {
|
|
1168
|
+
http: ClientHttpError
|
|
1169
|
+
network: ClientNetworkError
|
|
1170
|
+
timeout: ClientTimeoutError
|
|
1171
|
+
aborted: ClientAbortError
|
|
1172
|
+
parse: ClientParseError
|
|
1173
|
+
usage: ClientPathParamError
|
|
1174
|
+
unknown: unknown
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Distributed union of every framework failure kind. */
|
|
1178
|
+
export type FrameworkFailure = {
|
|
1179
|
+
[K in keyof ClientErrorMap]: { ok: false; kind: K; error: ClientErrorMap[K] }
|
|
1180
|
+
}[keyof ClientErrorMap]
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Discriminated result type for the `.safe()` form. `kind: 'typed'` carries
|
|
1184
|
+
* route-declared errors (registry-dispatched); other kinds carry framework
|
|
1185
|
+
* failures. Use `ResultNoTyped<T>` for routes without declared errors.
|
|
1186
|
+
*/
|
|
1187
|
+
export type Result<T, ETyped> =
|
|
1188
|
+
| { ok: true; value: T }
|
|
1189
|
+
| { ok: false; kind: 'typed'; error: ETyped }
|
|
1190
|
+
| FrameworkFailure
|
|
1191
|
+
|
|
1192
|
+
/**
|
|
1193
|
+
* `Result` for routes that don't declare typed errors. Omits the `'typed'`
|
|
1194
|
+
* arm entirely so IDE hovers stay clean (TS doesn't collapse `never`-payload
|
|
1195
|
+
* arms in tooltip output).
|
|
1196
|
+
*/
|
|
1197
|
+
export type ResultNoTyped<T> =
|
|
1198
|
+
| { ok: true; value: T }
|
|
1199
|
+
| FrameworkFailure
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
- [ ] **Step 2: Create `src/client/result-type.test-d.ts`**
|
|
1203
|
+
|
|
1204
|
+
Type-level test using `expectTypeOf` from vitest:
|
|
1205
|
+
|
|
1206
|
+
```ts
|
|
1207
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
1208
|
+
import type {
|
|
1209
|
+
Result,
|
|
1210
|
+
ResultNoTyped,
|
|
1211
|
+
ClientErrorMap,
|
|
1212
|
+
} from './types.js'
|
|
1213
|
+
import type {
|
|
1214
|
+
ClientHttpError,
|
|
1215
|
+
ClientNetworkError,
|
|
1216
|
+
ClientTimeoutError,
|
|
1217
|
+
ClientAbortError,
|
|
1218
|
+
ClientParseError,
|
|
1219
|
+
ClientPathParamError,
|
|
1220
|
+
} from './errors.js'
|
|
1221
|
+
|
|
1222
|
+
describe('Result type', () => {
|
|
1223
|
+
it('includes ok=true with value', () => {
|
|
1224
|
+
type R = Result<number, Error>
|
|
1225
|
+
type Ok = Extract<R, { ok: true }>
|
|
1226
|
+
expectTypeOf<Ok['value']>().toEqualTypeOf<number>()
|
|
1227
|
+
})
|
|
1228
|
+
|
|
1229
|
+
it('includes typed kind with ETyped error', () => {
|
|
1230
|
+
class MyTyped extends Error {}
|
|
1231
|
+
type R = Result<number, MyTyped>
|
|
1232
|
+
type Typed = Extract<R, { kind: 'typed' }>
|
|
1233
|
+
expectTypeOf<Typed['error']>().toEqualTypeOf<MyTyped>()
|
|
1234
|
+
})
|
|
1235
|
+
|
|
1236
|
+
it('includes every default framework kind', () => {
|
|
1237
|
+
type R = Result<number, Error>
|
|
1238
|
+
expectTypeOf<Extract<R, { kind: 'http' }>['error']>().toEqualTypeOf<ClientHttpError>()
|
|
1239
|
+
expectTypeOf<Extract<R, { kind: 'network' }>['error']>().toEqualTypeOf<ClientNetworkError>()
|
|
1240
|
+
expectTypeOf<Extract<R, { kind: 'timeout' }>['error']>().toEqualTypeOf<ClientTimeoutError>()
|
|
1241
|
+
expectTypeOf<Extract<R, { kind: 'aborted' }>['error']>().toEqualTypeOf<ClientAbortError>()
|
|
1242
|
+
expectTypeOf<Extract<R, { kind: 'parse' }>['error']>().toEqualTypeOf<ClientParseError>()
|
|
1243
|
+
expectTypeOf<Extract<R, { kind: 'usage' }>['error']>().toEqualTypeOf<ClientPathParamError>()
|
|
1244
|
+
expectTypeOf<Extract<R, { kind: 'unknown' }>['error']>().toEqualTypeOf<unknown>()
|
|
1245
|
+
})
|
|
1246
|
+
})
|
|
1247
|
+
|
|
1248
|
+
describe('ResultNoTyped', () => {
|
|
1249
|
+
it('omits the typed arm', () => {
|
|
1250
|
+
type R = ResultNoTyped<number>
|
|
1251
|
+
type Typed = Extract<R, { kind: 'typed' }>
|
|
1252
|
+
expectTypeOf<Typed>().toEqualTypeOf<never>()
|
|
1253
|
+
})
|
|
1254
|
+
})
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
- [ ] **Step 3: Run type-test**
|
|
1258
|
+
|
|
1259
|
+
```bash
|
|
1260
|
+
npx vitest run src/client/result-type.test-d.ts
|
|
1261
|
+
```
|
|
1262
|
+
Expected: PASS (vitest runs `.test-d.ts` files; `expectTypeOf` is type-checked at build time).
|
|
1263
|
+
|
|
1264
|
+
- [ ] **Step 4: Re-export from `src/client/index.ts`**
|
|
1265
|
+
|
|
1266
|
+
Add:
|
|
1267
|
+
```ts
|
|
1268
|
+
export type {
|
|
1269
|
+
ClientErrorMap,
|
|
1270
|
+
FrameworkFailure,
|
|
1271
|
+
Result,
|
|
1272
|
+
ResultNoTyped,
|
|
1273
|
+
} from './types.js'
|
|
1274
|
+
```
|
|
1275
|
+
|
|
1276
|
+
- [ ] **Step 5: Commit**
|
|
1277
|
+
|
|
1278
|
+
```bash
|
|
1279
|
+
git add src/client/types.ts src/client/result-type.test-d.ts src/client/index.ts
|
|
1280
|
+
git commit -m "feat(client/types): add Result, ResultNoTyped, ClientErrorMap"
|
|
1281
|
+
```
|
|
1282
|
+
|
|
1283
|
+
### Task 10: Augmentation type-test
|
|
1284
|
+
|
|
1285
|
+
**Files:**
|
|
1286
|
+
- Create: `src/client/augment-error-map.test-d.ts`
|
|
1287
|
+
|
|
1288
|
+
- [ ] **Step 1: Write the augmentation test**
|
|
1289
|
+
|
|
1290
|
+
```ts
|
|
1291
|
+
import { describe, it, expectTypeOf } from 'vitest'
|
|
1292
|
+
import type { Result } from './types.js'
|
|
1293
|
+
|
|
1294
|
+
class RateLimitError extends Error {
|
|
1295
|
+
constructor(public retryAfter: number) { super('rate limited') }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
declare module './types.js' {
|
|
1299
|
+
interface ClientErrorMap {
|
|
1300
|
+
rateLimited: RateLimitError
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
describe('ClientErrorMap augmentation', () => {
|
|
1305
|
+
it('adds rateLimited kind to Result', () => {
|
|
1306
|
+
type R = Result<number, Error>
|
|
1307
|
+
type RateLimited = Extract<R, { kind: 'rateLimited' }>
|
|
1308
|
+
expectTypeOf<RateLimited['error']>().toEqualTypeOf<RateLimitError>()
|
|
1309
|
+
})
|
|
1310
|
+
})
|
|
1311
|
+
```
|
|
1312
|
+
|
|
1313
|
+
- [ ] **Step 2: Run to verify pass**
|
|
1314
|
+
|
|
1315
|
+
```bash
|
|
1316
|
+
npx vitest run src/client/augment-error-map.test-d.ts
|
|
1317
|
+
```
|
|
1318
|
+
|
|
1319
|
+
- [ ] **Step 3: Commit**
|
|
1320
|
+
|
|
1321
|
+
```bash
|
|
1322
|
+
git add src/client/augment-error-map.test-d.ts
|
|
1323
|
+
git commit -m "test(client): verify ClientErrorMap augmentation widens Result"
|
|
1324
|
+
```
|
|
1325
|
+
|
|
1326
|
+
---
|
|
1327
|
+
|
|
1328
|
+
## Phase 5 — `executeSafeCall` and `ClientInstance.safeCall`
|
|
1329
|
+
|
|
1330
|
+
**Phase dependencies:** Requires Phase 3 (classifier wired into `executeCall` — Tasks 5, 6) and Phase 4 (Result types — Tasks 8.5, 9, 10) complete. Do not start Task 11 until both phases are committed.
|
|
1331
|
+
|
|
1332
|
+
### Task 11: Implement `executeSafeCall`
|
|
1333
|
+
|
|
1334
|
+
**Files:**
|
|
1335
|
+
- Modify: `src/client/call.ts`
|
|
1336
|
+
- Modify: `src/client/types.ts`
|
|
1337
|
+
- Create: `src/client/safe-call.test.ts`
|
|
1338
|
+
- Modify: `src/client/index.ts` (re-import as needed)
|
|
1339
|
+
|
|
1340
|
+
- [ ] **Step 1: Extend `ClientInstance` interface**
|
|
1341
|
+
|
|
1342
|
+
In `src/client/types.ts`:
|
|
1343
|
+
|
|
1344
|
+
```ts
|
|
1345
|
+
export interface ClientInstance {
|
|
1346
|
+
// ... existing ...
|
|
1347
|
+
safeCall<TResponse, ETyped = never>(
|
|
1348
|
+
descriptor: CallDescriptor,
|
|
1349
|
+
options?: ProcedureCallOptions,
|
|
1350
|
+
): Promise<Result<TResponse, ETyped>>
|
|
1351
|
+
}
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
- [ ] **Step 2: Write failing tests**
|
|
1355
|
+
|
|
1356
|
+
Create `src/client/safe-call.test.ts`:
|
|
1357
|
+
|
|
1358
|
+
```ts
|
|
1359
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
1360
|
+
import { executeSafeCall } from './call.js'
|
|
1361
|
+
import {
|
|
1362
|
+
ClientHttpError,
|
|
1363
|
+
ClientNetworkError,
|
|
1364
|
+
ClientTimeoutError,
|
|
1365
|
+
ClientAbortError,
|
|
1366
|
+
ClientPathParamError,
|
|
1367
|
+
} from './errors.js'
|
|
1368
|
+
import type { ClientAdapter, CallDescriptor } from './types.js'
|
|
1369
|
+
|
|
1370
|
+
const baseDescriptor: CallDescriptor = {
|
|
1371
|
+
name: 'GetUser', scope: 'users', path: '/users/:id', method: 'GET',
|
|
1372
|
+
kind: 'rpc', params: { id: '42' },
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
const ok = (body: unknown): ClientAdapter => ({
|
|
1376
|
+
request: vi.fn(async () => ({ status: 200, headers: {}, body })),
|
|
1377
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
1378
|
+
})
|
|
1379
|
+
|
|
1380
|
+
const failWith = (err: unknown): ClientAdapter => ({
|
|
1381
|
+
request: vi.fn(async () => { throw err }),
|
|
1382
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
1383
|
+
})
|
|
1384
|
+
|
|
1385
|
+
describe('executeSafeCall', () => {
|
|
1386
|
+
it('returns ok=true with value on success', async () => {
|
|
1387
|
+
const r = await executeSafeCall({
|
|
1388
|
+
descriptor: baseDescriptor,
|
|
1389
|
+
basePath: 'https://api.x',
|
|
1390
|
+
adapter: ok({ id: '42' }),
|
|
1391
|
+
hooks: {},
|
|
1392
|
+
})
|
|
1393
|
+
expect(r.ok).toBe(true)
|
|
1394
|
+
if (r.ok) expect(r.value).toEqual({ id: '42' })
|
|
1395
|
+
})
|
|
1396
|
+
|
|
1397
|
+
it("returns kind='http' on non-2xx without registry match", async () => {
|
|
1398
|
+
const r = await executeSafeCall({
|
|
1399
|
+
descriptor: baseDescriptor,
|
|
1400
|
+
basePath: 'https://api.x',
|
|
1401
|
+
adapter: {
|
|
1402
|
+
request: vi.fn(async () => ({ status: 500, headers: {}, body: { msg: 'oops' } })),
|
|
1403
|
+
stream: vi.fn(async () => { throw new Error('n/a') }),
|
|
1404
|
+
},
|
|
1405
|
+
hooks: {},
|
|
1406
|
+
})
|
|
1407
|
+
expect(r.ok).toBe(false)
|
|
1408
|
+
if (!r.ok) {
|
|
1409
|
+
expect(r.kind).toBe('http')
|
|
1410
|
+
expect(r.error).toBeInstanceOf(ClientHttpError)
|
|
1411
|
+
}
|
|
1412
|
+
})
|
|
1413
|
+
|
|
1414
|
+
it("returns kind='network' on TypeError", async () => {
|
|
1415
|
+
const r = await executeSafeCall({
|
|
1416
|
+
descriptor: baseDescriptor,
|
|
1417
|
+
basePath: 'https://api.x',
|
|
1418
|
+
adapter: failWith(new TypeError('Failed to fetch')),
|
|
1419
|
+
hooks: {},
|
|
1420
|
+
})
|
|
1421
|
+
expect(r.ok).toBe(false)
|
|
1422
|
+
if (!r.ok) {
|
|
1423
|
+
expect(r.kind).toBe('network')
|
|
1424
|
+
expect(r.error).toBeInstanceOf(ClientNetworkError)
|
|
1425
|
+
}
|
|
1426
|
+
})
|
|
1427
|
+
|
|
1428
|
+
it("returns kind='timeout' when timeout fires", async () => {
|
|
1429
|
+
const r = await executeSafeCall({
|
|
1430
|
+
descriptor: baseDescriptor,
|
|
1431
|
+
basePath: 'https://api.x',
|
|
1432
|
+
adapter: failWith(new DOMException('aborted', 'AbortError')),
|
|
1433
|
+
hooks: {},
|
|
1434
|
+
options: { timeout: 1 },
|
|
1435
|
+
})
|
|
1436
|
+
if (!r.ok) {
|
|
1437
|
+
expect(r.kind).toBe('timeout')
|
|
1438
|
+
expect(r.error).toBeInstanceOf(ClientTimeoutError)
|
|
1439
|
+
}
|
|
1440
|
+
})
|
|
1441
|
+
|
|
1442
|
+
it("returns kind='aborted' when user signal fires", async () => {
|
|
1443
|
+
const ctrl = new AbortController()
|
|
1444
|
+
ctrl.abort('user')
|
|
1445
|
+
const r = await executeSafeCall({
|
|
1446
|
+
descriptor: baseDescriptor,
|
|
1447
|
+
basePath: 'https://api.x',
|
|
1448
|
+
adapter: failWith(new DOMException('aborted', 'AbortError')),
|
|
1449
|
+
hooks: {},
|
|
1450
|
+
options: { signal: ctrl.signal },
|
|
1451
|
+
})
|
|
1452
|
+
if (!r.ok) {
|
|
1453
|
+
expect(r.kind).toBe('aborted')
|
|
1454
|
+
expect(r.error).toBeInstanceOf(ClientAbortError)
|
|
1455
|
+
}
|
|
1456
|
+
})
|
|
1457
|
+
|
|
1458
|
+
it("returns kind='usage' on pre-adapter ClientPathParamError, does NOT invoke onError", async () => {
|
|
1459
|
+
// Spec contract: pre-adapter usage errors bypass the classifier AND the
|
|
1460
|
+
// onError hook entirely. Path 1 of the three failure-source paths.
|
|
1461
|
+
const seen: unknown[] = []
|
|
1462
|
+
const r = await executeSafeCall({
|
|
1463
|
+
// path requires :id but params don't supply it
|
|
1464
|
+
descriptor: { ...baseDescriptor, params: {} },
|
|
1465
|
+
basePath: 'https://api.x',
|
|
1466
|
+
adapter: ok({}),
|
|
1467
|
+
hooks: { onError: ({ error }) => { seen.push(error) } },
|
|
1468
|
+
})
|
|
1469
|
+
if (!r.ok) {
|
|
1470
|
+
expect(r.kind).toBe('usage')
|
|
1471
|
+
expect(r.error).toBeInstanceOf(ClientPathParamError)
|
|
1472
|
+
}
|
|
1473
|
+
// onError must NOT fire — this is by design (per spec architectural decision #1).
|
|
1474
|
+
expect(seen).toEqual([])
|
|
1475
|
+
})
|
|
1476
|
+
|
|
1477
|
+
it("returns kind='unknown' for unrecognized throws", async () => {
|
|
1478
|
+
const r = await executeSafeCall({
|
|
1479
|
+
descriptor: baseDescriptor,
|
|
1480
|
+
basePath: 'https://api.x',
|
|
1481
|
+
adapter: failWith('weird-string-throw'),
|
|
1482
|
+
hooks: {},
|
|
1483
|
+
})
|
|
1484
|
+
if (!r.ok) {
|
|
1485
|
+
expect(r.kind).toBe('unknown')
|
|
1486
|
+
expect(r.error).toBe('weird-string-throw')
|
|
1487
|
+
}
|
|
1488
|
+
})
|
|
1489
|
+
|
|
1490
|
+
it('invokes onError hook on failure (cross-cutting telemetry)', async () => {
|
|
1491
|
+
const seen: unknown[] = []
|
|
1492
|
+
const r = await executeSafeCall({
|
|
1493
|
+
descriptor: baseDescriptor,
|
|
1494
|
+
basePath: 'https://api.x',
|
|
1495
|
+
adapter: failWith(new TypeError('x')),
|
|
1496
|
+
hooks: { onError: ({ error }) => { seen.push(error) } },
|
|
1497
|
+
})
|
|
1498
|
+
expect(r.ok).toBe(false)
|
|
1499
|
+
expect(seen[0]).toBeInstanceOf(ClientNetworkError)
|
|
1500
|
+
})
|
|
1501
|
+
})
|
|
1502
|
+
```
|
|
1503
|
+
|
|
1504
|
+
- [ ] **Step 3: Run to verify fail**
|
|
1505
|
+
|
|
1506
|
+
```bash
|
|
1507
|
+
npx vitest run src/client/safe-call.test.ts
|
|
1508
|
+
```
|
|
1509
|
+
Expected: FAIL — `executeSafeCall` does not exist.
|
|
1510
|
+
|
|
1511
|
+
- [ ] **Step 4: Implement `executeSafeCall`**
|
|
1512
|
+
|
|
1513
|
+
Add to `src/client/call.ts`:
|
|
1514
|
+
|
|
1515
|
+
```ts
|
|
1516
|
+
import type { Result, ClientErrorMap, FrameworkFailure } from './types.js'
|
|
1517
|
+
import {
|
|
1518
|
+
ClientHttpError,
|
|
1519
|
+
ClientNetworkError,
|
|
1520
|
+
ClientTimeoutError,
|
|
1521
|
+
ClientAbortError,
|
|
1522
|
+
ClientParseError,
|
|
1523
|
+
ClientPathParamError,
|
|
1524
|
+
} from './errors.js'
|
|
1525
|
+
|
|
1526
|
+
/**
|
|
1527
|
+
* Wraps `executeCall` and returns a discriminated `Result` instead of throwing.
|
|
1528
|
+
*
|
|
1529
|
+
* Three failure-source paths map to distinct kinds:
|
|
1530
|
+
* 1. Pre-adapter throw (e.g. `ClientPathParamError`) → `kind: 'usage'`
|
|
1531
|
+
* 2. Adapter throw, classified → `kind: 'network' | 'timeout' | 'aborted' | <custom> | 'unknown'`
|
|
1532
|
+
* 3. Adapter returns non-2xx → `kind: 'typed'` (registry match) or `kind: 'http'`
|
|
1533
|
+
*
|
|
1534
|
+
* `onError` hook fires on every failure path (cross-cutting telemetry).
|
|
1535
|
+
*/
|
|
1536
|
+
export async function executeSafeCall<TResponse, ETyped = never>(
|
|
1537
|
+
config: ExecuteCallConfig,
|
|
1538
|
+
): Promise<Result<TResponse, ETyped>> {
|
|
1539
|
+
try {
|
|
1540
|
+
const value = await executeCall<TResponse>(config)
|
|
1541
|
+
return { ok: true, value }
|
|
1542
|
+
} catch (err) {
|
|
1543
|
+
return classifyThrownError<ETyped>(err)
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
function classifyThrownError<ETyped>(err: unknown): Result<never, ETyped> {
|
|
1548
|
+
// Path 1: pre-adapter usage
|
|
1549
|
+
if (err instanceof ClientPathParamError) {
|
|
1550
|
+
return { ok: false, kind: 'usage', error: err }
|
|
1551
|
+
}
|
|
1552
|
+
// Path 3: post-status-check (registry or fallback)
|
|
1553
|
+
if (err instanceof ClientHttpError) {
|
|
1554
|
+
return { ok: false, kind: 'http', error: err }
|
|
1555
|
+
}
|
|
1556
|
+
// Path 2: classifier output (already normalized by executeCall)
|
|
1557
|
+
if (err instanceof ClientNetworkError) {
|
|
1558
|
+
return { ok: false, kind: 'network', error: err }
|
|
1559
|
+
}
|
|
1560
|
+
if (err instanceof ClientTimeoutError) {
|
|
1561
|
+
return { ok: false, kind: 'timeout', error: err }
|
|
1562
|
+
}
|
|
1563
|
+
if (err instanceof ClientAbortError) {
|
|
1564
|
+
return { ok: false, kind: 'aborted', error: err }
|
|
1565
|
+
}
|
|
1566
|
+
if (err instanceof ClientParseError) {
|
|
1567
|
+
return { ok: false, kind: 'parse', error: err }
|
|
1568
|
+
}
|
|
1569
|
+
// Registry-dispatched typed error (any other Error subclass thrown by the registry)
|
|
1570
|
+
if (err instanceof Error && (err as { __tsProceduresTyped?: boolean }).__tsProceduresTyped) {
|
|
1571
|
+
return { ok: false, kind: 'typed', error: err as ETyped }
|
|
1572
|
+
}
|
|
1573
|
+
// Custom adapter-classified error — has a known kind tag.
|
|
1574
|
+
// The cast is intentional: the framework knows only the default ClientErrorMap
|
|
1575
|
+
// keys, but consumer-augmented kinds are valid at the consumer's site (where
|
|
1576
|
+
// the augmented map is in scope). The runtime kind string is whatever the
|
|
1577
|
+
// adapter classifier returned; we trust it to match a registered entry.
|
|
1578
|
+
if (err instanceof Error && typeof (err as { __tsProceduresKind?: string }).__tsProceduresKind === 'string') {
|
|
1579
|
+
const kind = (err as { __tsProceduresKind: string }).__tsProceduresKind
|
|
1580
|
+
return { ok: false, kind: kind as keyof ClientErrorMap, error: err } as FrameworkFailure
|
|
1581
|
+
}
|
|
1582
|
+
// Fallthrough
|
|
1583
|
+
return { ok: false, kind: 'unknown', error: err }
|
|
1584
|
+
}
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
**Note on the `__tsProceduresTyped` / `__tsProceduresKind` tagging:** the simplest way for `executeSafeCall` to distinguish "a generated typed error class" from "a custom-classified error" from "a fallthrough" is for the call-site that *threw* to mark the error before re-throwing. The tags are deliberate internal implementation details — add a brief comment on each tag site explaining that they exist solely to bridge information from the throw-site to `classifyThrownError` without re-doing classification work. Do NOT add tagging to `executeStream` (Task 7) "for symmetry" — streams have no `.safe` form, so `classifyThrownError` is never called on stream-thrown errors and the tags would be inert overhead. Two changes follow:
|
|
1588
|
+
|
|
1589
|
+
(a) In `executeCall`, when the registry dispatches a typed error, tag it:
|
|
1590
|
+
```ts
|
|
1591
|
+
if (typed) {
|
|
1592
|
+
;(typed as { __tsProceduresTyped?: boolean }).__tsProceduresTyped = true
|
|
1593
|
+
throw typed
|
|
1594
|
+
}
|
|
1595
|
+
```
|
|
1596
|
+
|
|
1597
|
+
(b) In `executeCall`, when the classifier returns a custom kind (not one of the default kinds), tag with the kind:
|
|
1598
|
+
```ts
|
|
1599
|
+
if (classified) {
|
|
1600
|
+
const defaultKinds = new Set(['network', 'timeout', 'aborted'])
|
|
1601
|
+
if (!defaultKinds.has(classified.kind)) {
|
|
1602
|
+
;(classified.error as { __tsProceduresKind?: string }).__tsProceduresKind = classified.kind
|
|
1603
|
+
}
|
|
1604
|
+
// Hooks see normalized error, then throw
|
|
1605
|
+
await runOnError(...)
|
|
1606
|
+
throw classified.error
|
|
1607
|
+
}
|
|
1608
|
+
```
|
|
1609
|
+
|
|
1610
|
+
Update `executeCall` accordingly. (This is a behavior detail not present in the spec — we picked the simplest reliable mechanism. Document the field as internal in the source.)
|
|
1611
|
+
|
|
1612
|
+
- [ ] **Step 5: Run tests to verify pass**
|
|
1613
|
+
|
|
1614
|
+
```bash
|
|
1615
|
+
npx vitest run src/client/safe-call.test.ts
|
|
1616
|
+
```
|
|
1617
|
+
Expected: PASS.
|
|
1618
|
+
|
|
1619
|
+
- [ ] **Step 6: Wire `executeSafeCall` into `ClientInstance` via `createClient`**
|
|
1620
|
+
|
|
1621
|
+
**Read `src/client/index.ts` first** — the `createClient` factory composes its `call` closure from several local bindings (`basePath`, `adapter`, `hooks`, `globalDefaults`, optional `errorRegistry`). The `safeCall` wiring must reuse exactly the same bindings so behavior is identical except for the throw-vs-return difference.
|
|
1622
|
+
|
|
1623
|
+
```bash
|
|
1624
|
+
sed -n '1,90p' src/client/index.ts
|
|
1625
|
+
```
|
|
1626
|
+
|
|
1627
|
+
Add `safeCall` on the returned `ClientInstance` mirroring the existing `call` wiring (use the same field names that `createClient` actually uses — the snippet below uses generic placeholders; substitute them):
|
|
1628
|
+
|
|
1629
|
+
```ts
|
|
1630
|
+
return {
|
|
1631
|
+
// ... existing fields and call(...) ...
|
|
1632
|
+
safeCall: <TResponse, ETyped = never>(descriptor, options) =>
|
|
1633
|
+
executeSafeCall<TResponse, ETyped>({
|
|
1634
|
+
descriptor,
|
|
1635
|
+
basePath, // ← whatever local binding holds the resolved basePath
|
|
1636
|
+
adapter,
|
|
1637
|
+
hooks,
|
|
1638
|
+
defaults: globalDefaults, // ← whatever local binding holds the defaults
|
|
1639
|
+
options,
|
|
1640
|
+
errorRegistry,
|
|
1641
|
+
}),
|
|
1642
|
+
}
|
|
1643
|
+
```
|
|
1644
|
+
|
|
1645
|
+
The TypeScript type on `ClientInstance.safeCall` (added in Step 1) requires `safeCall<TResponse, ETyped = never>(...)`. Match the existing `call` signature shape exactly so generated callables can call either uniformly.
|
|
1646
|
+
|
|
1647
|
+
- [ ] **Step 7: Run full client suite**
|
|
1648
|
+
|
|
1649
|
+
```bash
|
|
1650
|
+
npx vitest run src/client/
|
|
1651
|
+
```
|
|
1652
|
+
Expected: PASS.
|
|
1653
|
+
|
|
1654
|
+
- [ ] **Step 8: Commit**
|
|
1655
|
+
|
|
1656
|
+
```bash
|
|
1657
|
+
git add src/client/call.ts src/client/types.ts src/client/index.ts src/client/safe-call.test.ts
|
|
1658
|
+
git commit -m "feat(client): add executeSafeCall and ClientInstance.safeCall
|
|
1659
|
+
|
|
1660
|
+
Returns a discriminated Result<T, E> instead of throwing. Three
|
|
1661
|
+
failure-source paths map to distinct kinds (usage / classifier /
|
|
1662
|
+
http-or-typed). onError fires on every failure (cross-cutting
|
|
1663
|
+
telemetry; consumers suppress per-call for retry loops)."
|
|
1664
|
+
```
|
|
1665
|
+
|
|
1666
|
+
---
|
|
1667
|
+
|
|
1668
|
+
## Phase 6 — Codegen: emit `.safe` sibling
|
|
1669
|
+
|
|
1670
|
+
### Task 12: Update `emit-scope.ts` for RPC routes
|
|
1671
|
+
|
|
1672
|
+
**Files:**
|
|
1673
|
+
- Modify: `src/codegen/emit-scope.ts`
|
|
1674
|
+
- Test: `src/codegen/emit-scope.test.ts`
|
|
1675
|
+
|
|
1676
|
+
- [ ] **Step 1: Read `emit-scope.test.ts` to understand existing test fixtures**
|
|
1677
|
+
|
|
1678
|
+
```bash
|
|
1679
|
+
cat src/codegen/emit-scope.test.ts | head -80
|
|
1680
|
+
```
|
|
1681
|
+
|
|
1682
|
+
- [ ] **Step 2: Write failing test**
|
|
1683
|
+
|
|
1684
|
+
Add to `src/codegen/emit-scope.test.ts`:
|
|
1685
|
+
|
|
1686
|
+
```ts
|
|
1687
|
+
describe('emitScopeFile .safe sibling', () => {
|
|
1688
|
+
it('emits .safe property on RPC callable when route has errors', async () => {
|
|
1689
|
+
const group = makeGroupWithRpcRoute({ name: 'GetUser', errors: ['NotFound'] })
|
|
1690
|
+
const out = await emitScopeFile(group, { errorKeys: new Set(['NotFound']) })
|
|
1691
|
+
expect(out).toContain('GetUser.safe = ')
|
|
1692
|
+
expect(out).toMatch(/Promise<Result<.*GetUser\.Response, .*GetUser\.Errors>>/)
|
|
1693
|
+
})
|
|
1694
|
+
|
|
1695
|
+
it('emits .safe with ResultNoTyped when route has no errors', async () => {
|
|
1696
|
+
const group = makeGroupWithRpcRoute({ name: 'GetUser', errors: [] })
|
|
1697
|
+
const out = await emitScopeFile(group, {})
|
|
1698
|
+
expect(out).toContain('GetUser.safe = ')
|
|
1699
|
+
expect(out).toMatch(/Promise<ResultNoTyped<.*GetUser\.Response>>/)
|
|
1700
|
+
expect(out).not.toContain('Errors')
|
|
1701
|
+
})
|
|
1702
|
+
})
|
|
1703
|
+
```
|
|
1704
|
+
|
|
1705
|
+
(The `makeGroupWithRpcRoute` helper may need to be added; check existing patterns and add minimally.)
|
|
1706
|
+
|
|
1707
|
+
- [ ] **Step 3: Run to verify fail**
|
|
1708
|
+
|
|
1709
|
+
```bash
|
|
1710
|
+
npx vitest run src/codegen/emit-scope.test.ts -t '.safe sibling'
|
|
1711
|
+
```
|
|
1712
|
+
|
|
1713
|
+
- [ ] **Step 4: Update `emitRpcRoute` in `src/codegen/emit-scope.ts`**
|
|
1714
|
+
|
|
1715
|
+
Locate the existing callable emission for RPC (around `src/codegen/emit-scope.ts:249-261`). Replace with:
|
|
1716
|
+
|
|
1717
|
+
```ts
|
|
1718
|
+
// IMPORTANT: hasErrors must reflect the *filtered* error union (post-errorKeys
|
|
1719
|
+
// filter), not raw route.errors.length. Otherwise we may emit
|
|
1720
|
+
// Result<Response, Foo.Errors> when no Foo.Errors namespace member was
|
|
1721
|
+
// emitted (because all keys lacked schemas) — producing a compile error in
|
|
1722
|
+
// the generated client. Use the same buildErrorUnion result that
|
|
1723
|
+
// injectRouteErrors uses below.
|
|
1724
|
+
const errorUnion = buildErrorUnion(route.errors, ctx)
|
|
1725
|
+
const hasErrors = errorUnion !== null
|
|
1726
|
+
const errorsRef = ctx.namespaceTypes
|
|
1727
|
+
? `${ctx.scopePascal}.${pascal}.Errors`
|
|
1728
|
+
: `${pascal}Errors`
|
|
1729
|
+
const resultType = hasErrors
|
|
1730
|
+
? `Result<${responseTypeName}, ${errorsRef}>`
|
|
1731
|
+
: `ResultNoTyped<${responseTypeName}>`
|
|
1732
|
+
|
|
1733
|
+
const callable = [
|
|
1734
|
+
` /** ${route.method.toUpperCase()} ${route.path} */`,
|
|
1735
|
+
` ${pascal}: Object.assign(`,
|
|
1736
|
+
` function ${pascal}(params: ${paramsTypeName}, options?: ProcedureCallOptions): Promise<${responseTypeName}> {`,
|
|
1737
|
+
` return client.call<${responseTypeName}>({`,
|
|
1738
|
+
` name: '${pascal}',`,
|
|
1739
|
+
` scope: '${scopeStr}',`,
|
|
1740
|
+
` path: '${route.path}',`,
|
|
1741
|
+
` method: '${route.method}',`,
|
|
1742
|
+
` kind: 'rpc',`,
|
|
1743
|
+
` params,`,
|
|
1744
|
+
` }, options)`,
|
|
1745
|
+
` },`,
|
|
1746
|
+
` {`,
|
|
1747
|
+
` safe(params: ${paramsTypeName}, options?: ProcedureCallOptions): Promise<${resultType}> {`,
|
|
1748
|
+
` return client.safeCall<${responseTypeName}${hasErrors ? `, ${errorsRef}` : ''}>({`,
|
|
1749
|
+
` name: '${pascal}',`,
|
|
1750
|
+
` scope: '${scopeStr}',`,
|
|
1751
|
+
` path: '${route.path}',`,
|
|
1752
|
+
` method: '${route.method}',`,
|
|
1753
|
+
` kind: 'rpc',`,
|
|
1754
|
+
` params,`,
|
|
1755
|
+
` }, options)`,
|
|
1756
|
+
` },`,
|
|
1757
|
+
` },`,
|
|
1758
|
+
` ),`,
|
|
1759
|
+
].join('\n')
|
|
1760
|
+
```
|
|
1761
|
+
|
|
1762
|
+
Pass the precomputed `errorUnion` into `injectRouteErrors` (instead of recomputing) so we don't call `buildErrorUnion` twice for the same route:
|
|
1763
|
+
|
|
1764
|
+
```ts
|
|
1765
|
+
// Replace the existing call:
|
|
1766
|
+
// const hasErrors = injectRouteErrors(declarations, pascal, buildErrorUnion(route.errors, ctx), ctx.namespaceTypes)
|
|
1767
|
+
// With:
|
|
1768
|
+
const hasErrorsInjected = injectRouteErrors(declarations, pascal, errorUnion, ctx.namespaceTypes)
|
|
1769
|
+
return { typeDeclarations: declarations, callable, hasStream: false, hasErrors: hasErrorsInjected }
|
|
1770
|
+
```
|
|
1771
|
+
|
|
1772
|
+
(`hasErrors` and `hasErrorsInjected` are equivalent here — `injectRouteErrors` returns `false` precisely when `errorUnion` is null. Use whichever name reads cleanest in the final code.)
|
|
1773
|
+
|
|
1774
|
+
Update the imports block at the top of the generated scope file to include `Result` / `ResultNoTyped`:
|
|
1775
|
+
|
|
1776
|
+
```ts
|
|
1777
|
+
const clientImports = hasStream
|
|
1778
|
+
? `import type { ClientInstance, ProcedureCallOptions, TypedStream, Result, ResultNoTyped } from '${clientImportPath}'`
|
|
1779
|
+
: `import type { ClientInstance, ProcedureCallOptions, Result, ResultNoTyped } from '${clientImportPath}'`
|
|
1780
|
+
```
|
|
1781
|
+
|
|
1782
|
+
(Drop `Result`/`ResultNoTyped` from the import set if no callable in the scope used them — left as an optimization for the writing-plans review pass; safe to always include.)
|
|
1783
|
+
|
|
1784
|
+
- [ ] **Step 5: Run tests to verify pass**
|
|
1785
|
+
|
|
1786
|
+
```bash
|
|
1787
|
+
npx vitest run src/codegen/emit-scope.test.ts
|
|
1788
|
+
```
|
|
1789
|
+
|
|
1790
|
+
- [ ] **Step 6: Commit**
|
|
1791
|
+
|
|
1792
|
+
```bash
|
|
1793
|
+
git add src/codegen/emit-scope.ts src/codegen/emit-scope.test.ts
|
|
1794
|
+
git commit -m "feat(codegen): emit .safe sibling on RPC callables"
|
|
1795
|
+
```
|
|
1796
|
+
|
|
1797
|
+
### Task 13: Update `emit-scope.ts` for API routes
|
|
1798
|
+
|
|
1799
|
+
**Files:** same as Task 12.
|
|
1800
|
+
|
|
1801
|
+
- [ ] **Step 1: Write failing test for API route .safe emission**
|
|
1802
|
+
|
|
1803
|
+
Mirror the Task 12 test for an API route (use `makeGroupWithApiRoute` or analogous helper).
|
|
1804
|
+
|
|
1805
|
+
- [ ] **Step 2: Update `emitApiRoute` with the same `Object.assign(fn, { safe })` pattern**
|
|
1806
|
+
|
|
1807
|
+
Apply the same changes from Task 12 Step 4 inside `emitApiRoute` (around `src/codegen/emit-scope.ts:326-338`).
|
|
1808
|
+
|
|
1809
|
+
- [ ] **Step 3: Run tests to verify pass**
|
|
1810
|
+
|
|
1811
|
+
```bash
|
|
1812
|
+
npx vitest run src/codegen/emit-scope.test.ts
|
|
1813
|
+
```
|
|
1814
|
+
|
|
1815
|
+
- [ ] **Step 4: Commit**
|
|
1816
|
+
|
|
1817
|
+
```bash
|
|
1818
|
+
git add src/codegen/emit-scope.ts src/codegen/emit-scope.test.ts
|
|
1819
|
+
git commit -m "feat(codegen): emit .safe sibling on API callables"
|
|
1820
|
+
```
|
|
1821
|
+
|
|
1822
|
+
### Task 14: Confirm streams remain unchanged (no `.safe` emission)
|
|
1823
|
+
|
|
1824
|
+
**Files:**
|
|
1825
|
+
- Test: `src/codegen/emit-scope.test.ts`
|
|
1826
|
+
|
|
1827
|
+
- [ ] **Step 1: Add a test asserting stream callables do NOT have a `.safe` sibling**
|
|
1828
|
+
|
|
1829
|
+
```ts
|
|
1830
|
+
it('does not emit .safe on stream callables', async () => {
|
|
1831
|
+
const group = makeGroupWithStreamRoute({ name: 'StreamUsers' })
|
|
1832
|
+
const out = await emitScopeFile(group, {})
|
|
1833
|
+
expect(out).toContain('StreamUsers(')
|
|
1834
|
+
expect(out).not.toContain('StreamUsers.safe')
|
|
1835
|
+
// Stream callables stay as plain functions — no Object.assign wrapper.
|
|
1836
|
+
expect(out).not.toMatch(/StreamUsers:\s*Object\.assign/)
|
|
1837
|
+
})
|
|
1838
|
+
```
|
|
1839
|
+
|
|
1840
|
+
- [ ] **Step 2: Run to verify pass (no implementation change required if Tasks 12-13 only touched RPC/API)**
|
|
1841
|
+
|
|
1842
|
+
```bash
|
|
1843
|
+
npx vitest run src/codegen/emit-scope.test.ts -t 'stream'
|
|
1844
|
+
```
|
|
1845
|
+
|
|
1846
|
+
- [ ] **Step 3: Commit**
|
|
1847
|
+
|
|
1848
|
+
```bash
|
|
1849
|
+
git add src/codegen/emit-scope.test.ts
|
|
1850
|
+
git commit -m "test(codegen): confirm stream callables omit .safe sibling"
|
|
1851
|
+
```
|
|
1852
|
+
|
|
1853
|
+
---
|
|
1854
|
+
|
|
1855
|
+
## Phase 7 — Self-contained bundling
|
|
1856
|
+
|
|
1857
|
+
### Task 15: Update self-contained bundler with new types and runtime file
|
|
1858
|
+
|
|
1859
|
+
**Files:**
|
|
1860
|
+
- Modify: `src/codegen/emit-client-runtime.ts`
|
|
1861
|
+
- Test: `src/codegen/emit-client-runtime.test.ts` (extend, or add if absent)
|
|
1862
|
+
|
|
1863
|
+
The self-contained bundler in `emit-client-runtime.ts` has two **hard-coded** lists that must be extended for the new types and runtime to flow through to consumers' bundled `_types.ts` / `_client.ts`:
|
|
1864
|
+
|
|
1865
|
+
1. `TYPES_IMPORT` (around lines 6–25) — the inline import list at the top of the bundled `_client.ts`. Must include every new type from `src/client/types.ts` that the runtime references.
|
|
1866
|
+
2. `SOURCE_FILES` (around lines 32–42) — the ordered list of `src/client/*.ts` files concatenated into the bundle. Must include `classify-error.ts`.
|
|
1867
|
+
|
|
1868
|
+
- [ ] **Step 1: Read `emit-client-runtime.ts` and confirm the hard-coded lists**
|
|
1869
|
+
|
|
1870
|
+
```bash
|
|
1871
|
+
sed -n '1,50p' src/codegen/emit-client-runtime.ts
|
|
1872
|
+
```
|
|
1873
|
+
|
|
1874
|
+
- [ ] **Step 2: Write a failing integration test**
|
|
1875
|
+
|
|
1876
|
+
Create or extend `src/codegen/emit-client-runtime.test.ts`:
|
|
1877
|
+
|
|
1878
|
+
```ts
|
|
1879
|
+
import { describe, it, expect } from 'vitest'
|
|
1880
|
+
import { generateClient } from './index.js'
|
|
1881
|
+
import * as path from 'node:path'
|
|
1882
|
+
import * as os from 'node:os'
|
|
1883
|
+
import * as fs from 'node:fs/promises'
|
|
1884
|
+
|
|
1885
|
+
describe('self-contained bundling — safe-result symbols', () => {
|
|
1886
|
+
it('includes Result, ResultNoTyped, ClientErrorMap, FrameworkFailure in _client.ts', async () => {
|
|
1887
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-self-'))
|
|
1888
|
+
await generateClient({
|
|
1889
|
+
envelope: { service: 'Api', routes: [], errors: [] },
|
|
1890
|
+
outDir: tmp,
|
|
1891
|
+
selfContained: true,
|
|
1892
|
+
})
|
|
1893
|
+
const client = await fs.readFile(path.join(tmp, '_client.ts'), 'utf-8')
|
|
1894
|
+
expect(client).toMatch(/Result\b/)
|
|
1895
|
+
expect(client).toMatch(/ResultNoTyped\b/)
|
|
1896
|
+
expect(client).toMatch(/ClientErrorMap\b/)
|
|
1897
|
+
expect(client).toMatch(/FrameworkFailure\b/)
|
|
1898
|
+
expect(client).toMatch(/defaultClassifyError\b/)
|
|
1899
|
+
expect(client).toMatch(/ClientNetworkError\b/)
|
|
1900
|
+
expect(client).toMatch(/ClientTimeoutError\b/)
|
|
1901
|
+
expect(client).toMatch(/ClientAbortError\b/)
|
|
1902
|
+
expect(client).toMatch(/ClientParseError\b/)
|
|
1903
|
+
expect(client).toMatch(/ClientHttpError\b/)
|
|
1904
|
+
expect(client).toMatch(/executeSafeCall\b/)
|
|
1905
|
+
})
|
|
1906
|
+
|
|
1907
|
+
it('bundled _client.ts type-checks under tsc --noEmit', async () => {
|
|
1908
|
+
// Confirms the bundled file is importable and self-consistent — would fail
|
|
1909
|
+
// if TYPES_IMPORT is missing a name the runtime references.
|
|
1910
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-tsc-'))
|
|
1911
|
+
await generateClient({
|
|
1912
|
+
envelope: { service: 'Api', routes: [], errors: [] },
|
|
1913
|
+
outDir: tmp,
|
|
1914
|
+
selfContained: true,
|
|
1915
|
+
})
|
|
1916
|
+
// Write a minimal tsconfig + invoke tsc; or shell out to tsc with --noEmit
|
|
1917
|
+
// pointed at the temp dir. (Shape this against existing project conventions
|
|
1918
|
+
// for child-process tsc invocations, if any.)
|
|
1919
|
+
const { execSync } = await import('node:child_process')
|
|
1920
|
+
expect(() =>
|
|
1921
|
+
execSync(`npx tsc --noEmit --target es2022 --module nodenext --moduleResolution nodenext ${path.join(tmp, '_client.ts')} ${path.join(tmp, '_types.ts')}`, { stdio: 'pipe' })
|
|
1922
|
+
).not.toThrow()
|
|
1923
|
+
})
|
|
1924
|
+
})
|
|
1925
|
+
```
|
|
1926
|
+
|
|
1927
|
+
- [ ] **Step 3: Run to verify fail**
|
|
1928
|
+
|
|
1929
|
+
```bash
|
|
1930
|
+
npx vitest run src/codegen/emit-client-runtime.test.ts
|
|
1931
|
+
```
|
|
1932
|
+
|
|
1933
|
+
- [ ] **Step 4: Update `TYPES_IMPORT` in `src/codegen/emit-client-runtime.ts`**
|
|
1934
|
+
|
|
1935
|
+
Add the new symbols:
|
|
1936
|
+
|
|
1937
|
+
```ts
|
|
1938
|
+
const TYPES_IMPORT = `import type {
|
|
1939
|
+
ClientAdapter,
|
|
1940
|
+
AdapterRequest,
|
|
1941
|
+
AdapterResponse,
|
|
1942
|
+
AdapterStreamResponse,
|
|
1943
|
+
ClientHooks,
|
|
1944
|
+
BeforeRequestContext,
|
|
1945
|
+
AfterResponseContext,
|
|
1946
|
+
ErrorContext,
|
|
1947
|
+
CallDescriptor,
|
|
1948
|
+
StreamDescriptor,
|
|
1949
|
+
TypedStream,
|
|
1950
|
+
ClientInstance,
|
|
1951
|
+
ProcedureCallDefaults,
|
|
1952
|
+
ProcedureCallOptions,
|
|
1953
|
+
CreateClientConfig,
|
|
1954
|
+
RequestMeta,
|
|
1955
|
+
ErrorRegistry,
|
|
1956
|
+
ErrorFactory,
|
|
1957
|
+
ErrorResponseMeta,
|
|
1958
|
+
ClientErrorMap,
|
|
1959
|
+
FrameworkFailure,
|
|
1960
|
+
Result,
|
|
1961
|
+
ResultNoTyped,
|
|
1962
|
+
ErrorClassifier,
|
|
1963
|
+
ClassifyErrorContext,
|
|
1964
|
+
ClassifiedError,
|
|
1965
|
+
} from './_types'`
|
|
1966
|
+
```
|
|
1967
|
+
|
|
1968
|
+
- [ ] **Step 5: Update `SOURCE_FILES` to include `classify-error.ts`**
|
|
1969
|
+
|
|
1970
|
+
```ts
|
|
1971
|
+
const SOURCE_FILES = [
|
|
1972
|
+
'errors.ts',
|
|
1973
|
+
'classify-error.ts', // ← new — must come before call.ts which imports it
|
|
1974
|
+
'error-dispatch.ts',
|
|
1975
|
+
'request-builder.ts',
|
|
1976
|
+
'resolve-options.ts',
|
|
1977
|
+
'hooks.ts',
|
|
1978
|
+
'call.ts',
|
|
1979
|
+
'stream.ts',
|
|
1980
|
+
'fetch-adapter.ts',
|
|
1981
|
+
'index.ts',
|
|
1982
|
+
] as const
|
|
1983
|
+
```
|
|
1984
|
+
|
|
1985
|
+
- [ ] **Step 6: Confirm the `_types.ts` bundling pipeline emits the new types**
|
|
1986
|
+
|
|
1987
|
+
`_types.ts` is generated by re-exporting from `src/client/types.ts`. Since Task 9 already added the new types to `types.ts`, they should flow through automatically. Verify by reading the relevant emitter (likely `emit-types-bundle.ts` or similar — find via `grep -n '_types.ts' src/codegen/ -r`). If the bundler uses an explicit re-export list, extend it the same way.
|
|
1988
|
+
|
|
1989
|
+
- [ ] **Step 7: Run tests to verify pass**
|
|
1990
|
+
|
|
1991
|
+
```bash
|
|
1992
|
+
npx vitest run src/codegen/emit-client-runtime.test.ts
|
|
1993
|
+
```
|
|
1994
|
+
|
|
1995
|
+
- [ ] **Step 8: Commit**
|
|
1996
|
+
|
|
1997
|
+
```bash
|
|
1998
|
+
git add src/codegen/emit-client-runtime.ts src/codegen/emit-client-runtime.test.ts
|
|
1999
|
+
git commit -m "feat(codegen/self-contained): bundle Result types and classifier runtime
|
|
2000
|
+
|
|
2001
|
+
Extends TYPES_IMPORT with Result, ResultNoTyped, ClientErrorMap,
|
|
2002
|
+
FrameworkFailure, ErrorClassifier; adds classify-error.ts to
|
|
2003
|
+
SOURCE_FILES."
|
|
2004
|
+
```
|
|
2005
|
+
|
|
2006
|
+
### Task 16: Refresh golden fixture for the TS target
|
|
2007
|
+
|
|
2008
|
+
**Files:**
|
|
2009
|
+
- Modify: the golden output file used by the TS-target codegen integration test
|
|
2010
|
+
|
|
2011
|
+
- [ ] **Step 1: Identify the golden fixture file**
|
|
2012
|
+
|
|
2013
|
+
```bash
|
|
2014
|
+
find src/codegen -name '*golden*' -o -name '*.golden.*' | head
|
|
2015
|
+
```
|
|
2016
|
+
|
|
2017
|
+
- [ ] **Step 2: Run the integration test in update mode (or hand-edit the expected output)**
|
|
2018
|
+
|
|
2019
|
+
If a snapshot-update mode exists:
|
|
2020
|
+
```bash
|
|
2021
|
+
npx vitest run src/codegen/ -u
|
|
2022
|
+
```
|
|
2023
|
+
Otherwise hand-edit to include the new `.safe` siblings + `Result`/`ResultNoTyped` imports.
|
|
2024
|
+
|
|
2025
|
+
- [ ] **Step 3: Inspect the diff to confirm changes match expected emission**
|
|
2026
|
+
|
|
2027
|
+
```bash
|
|
2028
|
+
git diff -- src/codegen/__fixtures__/
|
|
2029
|
+
```
|
|
2030
|
+
|
|
2031
|
+
- [ ] **Step 4: Run integration test**
|
|
2032
|
+
|
|
2033
|
+
```bash
|
|
2034
|
+
npx vitest run src/codegen/
|
|
2035
|
+
```
|
|
2036
|
+
|
|
2037
|
+
- [ ] **Step 5: Commit**
|
|
2038
|
+
|
|
2039
|
+
```bash
|
|
2040
|
+
git add src/codegen/__fixtures__/<files>
|
|
2041
|
+
git commit -m "test(codegen): refresh golden fixture with .safe siblings"
|
|
2042
|
+
```
|
|
2043
|
+
|
|
2044
|
+
---
|
|
2045
|
+
|
|
2046
|
+
## Phase 8 — Bundle-size budget regression test
|
|
2047
|
+
|
|
2048
|
+
### Task 17: Add a 100-route fixture and per-route byte-cost assertion
|
|
2049
|
+
|
|
2050
|
+
**Files:**
|
|
2051
|
+
- Create: `src/codegen/__fixtures__/100-routes-envelope.json` (generated programmatically — see step 1)
|
|
2052
|
+
- Create: `src/codegen/bundle-size.test.ts`
|
|
2053
|
+
|
|
2054
|
+
- [ ] **Step 1: Generate the 100-route envelope fixture**
|
|
2055
|
+
|
|
2056
|
+
Inline the generation in the test setup (no separate fixture file) — easier to maintain:
|
|
2057
|
+
|
|
2058
|
+
```ts
|
|
2059
|
+
import { describe, it, expect, beforeAll } from 'vitest'
|
|
2060
|
+
import { generateClient } from './index.js'
|
|
2061
|
+
import * as path from 'node:path'
|
|
2062
|
+
import * as os from 'node:os'
|
|
2063
|
+
import * as fs from 'node:fs/promises'
|
|
2064
|
+
|
|
2065
|
+
function makeEnvelope(routeCount: number) {
|
|
2066
|
+
const routes = Array.from({ length: routeCount }, (_, i) => ({
|
|
2067
|
+
kind: 'rpc',
|
|
2068
|
+
name: `Op${i}`,
|
|
2069
|
+
scope: 'ops',
|
|
2070
|
+
method: 'POST',
|
|
2071
|
+
path: `/ops/op${i}`,
|
|
2072
|
+
jsonSchema: {
|
|
2073
|
+
body: { type: 'object', properties: { x: { type: 'string' } } },
|
|
2074
|
+
response: { type: 'object', properties: { y: { type: 'string' } } },
|
|
2075
|
+
},
|
|
2076
|
+
}))
|
|
2077
|
+
return { service: 'BenchApi', routes, errors: [] }
|
|
2078
|
+
}
|
|
2079
|
+
```
|
|
2080
|
+
|
|
2081
|
+
- [ ] **Step 2: Generate, minify, measure**
|
|
2082
|
+
|
|
2083
|
+
```ts
|
|
2084
|
+
describe('bundle size budget', () => {
|
|
2085
|
+
let perRouteDelta: number
|
|
2086
|
+
|
|
2087
|
+
beforeAll(async () => {
|
|
2088
|
+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-bundle-'))
|
|
2089
|
+
await generateClient({ envelope: makeEnvelope(100), outDir: tmp, selfContained: false })
|
|
2090
|
+
const scopeFile = await fs.readFile(path.join(tmp, 'ops.ts'), 'utf-8')
|
|
2091
|
+
|
|
2092
|
+
// Strip whitespace/comments as a stand-in for minification (esbuild would
|
|
2093
|
+
// be more accurate but adds a dep; this is a stable lower-bound proxy).
|
|
2094
|
+
const stripped = scopeFile.replace(/\s+/g, ' ').replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, '')
|
|
2095
|
+
perRouteDelta = stripped.length / 100
|
|
2096
|
+
})
|
|
2097
|
+
|
|
2098
|
+
it('stays within 1500 chars per route post-strip', () => {
|
|
2099
|
+
// Initial budget — refine after first measurement. The .safe sibling adds
|
|
2100
|
+
// roughly 400 chars of unminified emission per route (Object.assign wrapper +
|
|
2101
|
+
// duplicated descriptor literal). Post-strip should land well under 1500.
|
|
2102
|
+
//
|
|
2103
|
+
// RELATIONSHIP TO SPEC TARGET: The spec sets a "200 bytes per route
|
|
2104
|
+
// post-minify" goal. This whitespace-strip proxy is a coarse stand-in
|
|
2105
|
+
// chosen to avoid an esbuild dep — actual minified bytes will be lower
|
|
2106
|
+
// than `perRouteDelta` here. If a future task swaps in real esbuild
|
|
2107
|
+
// minification, tighten the budget to ~250 bytes (200 + 25% headroom)
|
|
2108
|
+
// and link back to spec section "Tests / item 10". For now this guard
|
|
2109
|
+
// catches catastrophic regressions only, not subtle bloat.
|
|
2110
|
+
//
|
|
2111
|
+
// Record the actual measurement when the test first passes:
|
|
2112
|
+
// PERROUTEDELTA_BASELINE: <fill in after first run>
|
|
2113
|
+
expect(perRouteDelta).toBeLessThan(1500)
|
|
2114
|
+
})
|
|
2115
|
+
})
|
|
2116
|
+
```
|
|
2117
|
+
|
|
2118
|
+
**Note:** The whitespace-strip proxy is a coarse but stable measure. If the team wants a real minified-bytes budget later, swap in `esbuild` (already a transitive dep via vitest) — out of scope for this task. The point of the test is regression detection, not absolute accuracy.
|
|
2119
|
+
|
|
2120
|
+
- [ ] **Step 3: Run to establish baseline**
|
|
2121
|
+
|
|
2122
|
+
```bash
|
|
2123
|
+
npx vitest run src/codegen/bundle-size.test.ts
|
|
2124
|
+
```
|
|
2125
|
+
If it passes — good baseline. If it fails, log the actual delta and adjust the budget upward to current + 20% headroom, with a comment noting the baseline.
|
|
2126
|
+
|
|
2127
|
+
- [ ] **Step 4: Commit**
|
|
2128
|
+
|
|
2129
|
+
```bash
|
|
2130
|
+
git add src/codegen/bundle-size.test.ts
|
|
2131
|
+
git commit -m "test(codegen): add per-route bundle-size budget regression guard"
|
|
2132
|
+
```
|
|
2133
|
+
|
|
2134
|
+
---
|
|
2135
|
+
|
|
2136
|
+
## Phase 9 — Documentation
|
|
2137
|
+
|
|
2138
|
+
### Task 18: Write `docs/client-error-handling.md`
|
|
2139
|
+
|
|
2140
|
+
**Files:**
|
|
2141
|
+
- Create: `docs/client-error-handling.md`
|
|
2142
|
+
|
|
2143
|
+
- [ ] **Step 1: Write the doc**
|
|
2144
|
+
|
|
2145
|
+
Sections required:
|
|
2146
|
+
|
|
2147
|
+
1. **The throwing form** — framework error class taxonomy table; narrowing pattern with `instanceof`; example showing `try/catch` with branches for `ApiErrors.X`, `ClientHttpError`, `ClientTimeoutError`, `ClientNetworkError`, `ClientAbortError`.
|
|
2148
|
+
2. **The `.safe` form** — when to use, full example with exhaustive switch on `kind`, narrowing inside each branch.
|
|
2149
|
+
3. **Custom error categories** — `ClientErrorMap` augmentation example (both direct and self-contained import targets); `classifyError` adapter config example showing composition with `defaultClassifyError`.
|
|
2150
|
+
4. **Migration** — `ClientRequestError` → `ClientHttpError`; raw `DOMException` / `TypeError` → framework classes; `onError` payload shape change. Concrete before/after for each.
|
|
2151
|
+
5. **FAQ — `.safe` + retries** — explain why `onError` fires on every `.safe` failure, show the per-call no-op or `meta.isRetry` suppression patterns. Note the deliberate choice to not add a config knob in v1.
|
|
2152
|
+
|
|
2153
|
+
(Use the spec's "Where each `kind` originates" table verbatim — it's the canonical reference for failure-source paths.)
|
|
2154
|
+
|
|
2155
|
+
- [ ] **Step 2: Commit**
|
|
2156
|
+
|
|
2157
|
+
```bash
|
|
2158
|
+
git add docs/client-error-handling.md
|
|
2159
|
+
git commit -m "docs: add client error-handling guide"
|
|
2160
|
+
```
|
|
2161
|
+
|
|
2162
|
+
### Task 19: Update `CLAUDE.md`
|
|
2163
|
+
|
|
2164
|
+
**Files:**
|
|
2165
|
+
- Modify: `CLAUDE.md`
|
|
2166
|
+
|
|
2167
|
+
- [ ] **Step 1: In the Client section, add a paragraph pointing at the new guide and the `.safe` form**
|
|
2168
|
+
|
|
2169
|
+
Append a bullet under "Important Patterns" referencing `docs/client-error-handling.md` and noting that every RPC/API generated callable has a `.safe` sibling returning `Result<T, E>`.
|
|
2170
|
+
|
|
2171
|
+
- [ ] **Step 2: Add a paragraph on the framework error class taxonomy**
|
|
2172
|
+
|
|
2173
|
+
Mention the six classes and that platform errors (`TypeError`, `DOMException`) are normalized at the `executeCall` boundary.
|
|
2174
|
+
|
|
2175
|
+
- [ ] **Step 3: Commit**
|
|
2176
|
+
|
|
2177
|
+
```bash
|
|
2178
|
+
git add CLAUDE.md
|
|
2179
|
+
git commit -m "docs(CLAUDE.md): note safe form and normalized error taxonomy"
|
|
2180
|
+
```
|
|
2181
|
+
|
|
2182
|
+
### Task 20: Update `agent_config/` patterns and anti-patterns
|
|
2183
|
+
|
|
2184
|
+
**Files:**
|
|
2185
|
+
- Modify: `agent_config/claude-code/skills/ts-procedures/patterns.md`
|
|
2186
|
+
- Modify: `agent_config/claude-code/skills/ts-procedures/anti-patterns.md`
|
|
2187
|
+
- Modify: `agent_config/copilot/copilot-instructions.md`
|
|
2188
|
+
- Modify: `agent_config/cursor/cursorrules`
|
|
2189
|
+
|
|
2190
|
+
- [ ] **Step 1: Add an "Error handling in client code" section to `patterns.md`**
|
|
2191
|
+
|
|
2192
|
+
Show both the throwing form and the `.safe` form, with the framework class narrowing pattern. Mention `ClientErrorMap` augmentation briefly with a link to `docs/client-error-handling.md`.
|
|
2193
|
+
|
|
2194
|
+
- [ ] **Step 2: Add a "Don't catch raw `DOMException`/`TypeError`" entry to `anti-patterns.md`**
|
|
2195
|
+
|
|
2196
|
+
Show the wrong pattern (catching `DOMException` to detect timeout) and the right pattern (`instanceof ClientTimeoutError`).
|
|
2197
|
+
|
|
2198
|
+
- [ ] **Step 3: Mirror the additions in `copilot-instructions.md` and `cursorrules`**
|
|
2199
|
+
|
|
2200
|
+
These files are condensed equivalents — keep them tight (~10-15 lines per addition).
|
|
2201
|
+
|
|
2202
|
+
- [ ] **Step 4: Confirm no installer/postinstall changes are needed**
|
|
2203
|
+
|
|
2204
|
+
The `agent_config/` files are bundled as static documentation; the installer (`agent_config/bin/setup.mjs`) and postinstall (`agent_config/bin/postinstall.mjs`) do recursive file copies — they pick up new content automatically. No installer change required for this task; confirm with:
|
|
2205
|
+
|
|
2206
|
+
```bash
|
|
2207
|
+
grep -n 'patterns.md\|anti-patterns.md' agent_config/bin/*.mjs
|
|
2208
|
+
```
|
|
2209
|
+
Expected: no hard-coded references (or only references that already cover the modified files via globs).
|
|
2210
|
+
|
|
2211
|
+
- [ ] **Step 5: Commit**
|
|
2212
|
+
|
|
2213
|
+
```bash
|
|
2214
|
+
git add agent_config/
|
|
2215
|
+
git commit -m "docs(agent_config): add safe-form patterns and DOMException anti-pattern"
|
|
2216
|
+
```
|
|
2217
|
+
|
|
2218
|
+
---
|
|
2219
|
+
|
|
2220
|
+
## Phase 10 — Migration + version bump
|
|
2221
|
+
|
|
2222
|
+
### Task 21: Update `CHANGELOG.md` (or release notes file)
|
|
2223
|
+
|
|
2224
|
+
**Files:**
|
|
2225
|
+
- Modify: `CHANGELOG.md` (if it exists) or create one
|
|
2226
|
+
|
|
2227
|
+
- [ ] **Step 1: Check if CHANGELOG exists**
|
|
2228
|
+
|
|
2229
|
+
```bash
|
|
2230
|
+
ls CHANGELOG.md
|
|
2231
|
+
```
|
|
2232
|
+
|
|
2233
|
+
- [ ] **Step 2: Add a `7.0.0` entry**
|
|
2234
|
+
|
|
2235
|
+
Required content:
|
|
2236
|
+
- **Breaking changes:**
|
|
2237
|
+
- `ClientRequestError` renamed to `ClientHttpError` (deprecated alias retained for one minor cycle)
|
|
2238
|
+
- Raw `DOMException` / `TypeError` from the adapter no longer reach consumer catch blocks — replaced with `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`
|
|
2239
|
+
- `onError` hook receives the normalized framework error in `ctx.error` (not the raw platform error; original is on `error.cause`)
|
|
2240
|
+
- **New features:**
|
|
2241
|
+
- `.safe()` sibling form on every generated RPC/API callable, returning `Result<T, E>`
|
|
2242
|
+
- `ClientErrorMap` interface for declaration-merging custom error kinds
|
|
2243
|
+
- `defaultClassifyError` exported for adapter-author composition
|
|
2244
|
+
- `ClientAdapter.classifyError` optional adapter-level classifier
|
|
2245
|
+
- `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError` framework classes (all carry `cause`)
|
|
2246
|
+
- **Migration:** see `docs/client-error-handling.md`
|
|
2247
|
+
|
|
2248
|
+
- [ ] **Step 3: Commit**
|
|
2249
|
+
|
|
2250
|
+
```bash
|
|
2251
|
+
git add CHANGELOG.md
|
|
2252
|
+
git commit -m "docs(changelog): add 7.0.0 entry"
|
|
2253
|
+
```
|
|
2254
|
+
|
|
2255
|
+
### Task 22: Bump `package.json` to `7.0.0`
|
|
2256
|
+
|
|
2257
|
+
**Files:**
|
|
2258
|
+
- Modify: `package.json`
|
|
2259
|
+
|
|
2260
|
+
- [ ] **Step 1: Update version field**
|
|
2261
|
+
|
|
2262
|
+
Change `"version": "6.2.0"` to `"version": "7.0.0"`.
|
|
2263
|
+
|
|
2264
|
+
- [ ] **Step 2: Run lint + build + test**
|
|
2265
|
+
|
|
2266
|
+
```bash
|
|
2267
|
+
npm run lint && npm run build && npm run test
|
|
2268
|
+
```
|
|
2269
|
+
Expected: PASS.
|
|
2270
|
+
|
|
2271
|
+
- [ ] **Step 3: Commit**
|
|
2272
|
+
|
|
2273
|
+
Match the project's prior version-bump commit style (recent commits use the bare version: `6.2.0`, `6.1.0`):
|
|
2274
|
+
|
|
2275
|
+
```bash
|
|
2276
|
+
git add package.json
|
|
2277
|
+
git commit -m "7.0.0"
|
|
2278
|
+
```
|
|
2279
|
+
|
|
2280
|
+
---
|
|
2281
|
+
|
|
2282
|
+
## Final verification checklist
|
|
2283
|
+
|
|
2284
|
+
Before opening the PR / merging:
|
|
2285
|
+
|
|
2286
|
+
- [ ] `npm run test` passes (full suite)
|
|
2287
|
+
- [ ] `npm run lint` passes
|
|
2288
|
+
- [ ] `npm run build` succeeds without TS errors
|
|
2289
|
+
- [ ] `npm run check-docs` passes (docs consistency check)
|
|
2290
|
+
- [ ] Manually inspect a generated client against a small fixture envelope to confirm `.safe` callables hover correctly in an editor (`tsc --noEmit` in the test fixture's directory)
|
|
2291
|
+
- [ ] Bundle-size budget test (Task 17) is green and within reasonable range of expected
|
|
2292
|
+
- [ ] Migration doc walks through all three breaking changes with concrete before/after
|
|
2293
|
+
- [ ] Spec decision-log items all reflected in code
|