@spfn/core 0.3.0-beta.5 → 0.3.0-beta.6
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 +132 -4
- package/dist/db/index.d.ts +173 -27
- package/dist/db/index.js +192 -57
- package/dist/db/index.js.map +1 -1
- package/dist/nextjs/index.d.ts +18 -1
- package/dist/nextjs/index.js +40 -1
- package/dist/nextjs/index.js.map +1 -1
- package/dist/nextjs/server.d.ts +34 -1
- package/dist/nextjs/server.js +14 -0
- package/dist/nextjs/server.js.map +1 -1
- package/docs/file-upload.md +195 -333
- package/package.json +6 -5
- package/src/cache/README.md +330 -0
- package/src/codegen/README.md +516 -0
- package/src/config/README.md +326 -0
- package/src/contract/README.md +326 -0
- package/src/db/README.md +589 -0
- package/src/db/manager/README.md +500 -0
- package/src/db/schema/README.md +344 -0
- package/src/db/transaction/README.md +822 -0
- package/src/env/README.md +651 -0
- package/src/errors/README.md +429 -0
- package/src/event/README.md +736 -0
- package/src/job/README.md +514 -0
- package/src/logger/README.md +321 -0
- package/src/middleware/README.md +634 -0
- package/src/nextjs/README.md +608 -0
- package/src/route/README.md +738 -0
- package/src/security/README.md +100 -0
- package/src/server/README.md +704 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
# @spfn/core/errors — Serializable HTTP/DB error classes with cross-boundary deserialization
|
|
2
|
+
|
|
3
|
+
Type-safe error classes that carry an HTTP status code, auto-serialize to JSON via
|
|
4
|
+
`toJSON()`, and can be reconstructed as real error instances on the client through an
|
|
5
|
+
`ErrorRegistry`.
|
|
6
|
+
|
|
7
|
+
## Import paths
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
// Error classes, base class, registry, type guards
|
|
11
|
+
import {
|
|
12
|
+
NotFoundError,
|
|
13
|
+
ValidationError,
|
|
14
|
+
EntityNotFoundError,
|
|
15
|
+
SerializableError,
|
|
16
|
+
ErrorRegistry,
|
|
17
|
+
errorRegistry,
|
|
18
|
+
isHttpError,
|
|
19
|
+
} from '@spfn/core/errors';
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
There is **no** root `.` export for `@spfn/core`, so `import { NotFoundError } from
|
|
23
|
+
'@spfn/core'` does not resolve. Import from the `@spfn/core/errors` subpath. (Older
|
|
24
|
+
snippets showing the root form are stale.)
|
|
25
|
+
|
|
26
|
+
Related symbols that live in **other** subpaths (a common mistake):
|
|
27
|
+
|
|
28
|
+
- `ErrorHandler` (the Hono `onError` middleware) → `@spfn/core/middleware`.
|
|
29
|
+
- `fromPostgresError` (Postgres error-code → DB error mapping) → `@spfn/core/db`,
|
|
30
|
+
**not** `@spfn/core/errors`.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## Public API (complete)
|
|
35
|
+
|
|
36
|
+
From `@spfn/core/errors`:
|
|
37
|
+
|
|
38
|
+
- Base: `SerializableError` (abstract class), `ErrorRegistry` (class),
|
|
39
|
+
`errorRegistry` (a pre-populated `ErrorRegistry` instance)
|
|
40
|
+
- HTTP errors: `HttpError`, `BadRequestError`, `ValidationError`, `UnauthorizedError`,
|
|
41
|
+
`ForbiddenError`, `NotFoundError`, `ConflictError`, `GoneError`,
|
|
42
|
+
`TooManyRequestsError`, `UnsupportedMediaTypeError`, `UnprocessableEntityError`,
|
|
43
|
+
`InternalServerError`, `ServiceUnavailableError`
|
|
44
|
+
- Database errors: `DatabaseError`, `ConnectionError`, `QueryError`,
|
|
45
|
+
`EntityNotFoundError`, `ConstraintViolationError`, `TransactionError`,
|
|
46
|
+
`DeadlockError`, `DuplicateEntryError`
|
|
47
|
+
- Namespaced re-exports: `HttpErrors` (`HttpErrors.NotFoundError`, …),
|
|
48
|
+
`DatabaseErrors` (`DatabaseErrors.QueryError`, …)
|
|
49
|
+
- Type guards: `isHttpError`, `isDatabaseError`, `hasStatusCode`
|
|
50
|
+
- Types: `SerializedError`, `SerializableErrorConstructor`, `ErrorRegistryInput`
|
|
51
|
+
|
|
52
|
+
> **Every constructor takes a single object argument.** There is **no** positional form.
|
|
53
|
+
> `new NotFoundError('User', 123)`, `new EntityNotFoundError('User', 123)`,
|
|
54
|
+
> `new DatabaseError('msg', 500, {...})`, `new HttpError(404, 'msg')` do **not** exist —
|
|
55
|
+
> these were old signatures and will not compile. Use the object forms below.
|
|
56
|
+
>
|
|
57
|
+
> There is **no** `name` or `timestamp` field on the serialized output. Serialized JSON is
|
|
58
|
+
> `{ __type, message, ...publicFields }` — `statusCode` is intentionally excluded (it is
|
|
59
|
+
> inferred from the type on deserialize). Old docs showing `{ "name": ..., "timestamp": ... }`
|
|
60
|
+
> responses are wrong. The `code` a response carries lives inside the `error` envelope the
|
|
61
|
+
> handler adds (below), never as a top-level field.
|
|
62
|
+
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
## Quick Start
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
import { NotFoundError, ValidationError } from '@spfn/core/errors';
|
|
69
|
+
|
|
70
|
+
// Throw anywhere in a route/service. The ErrorHandler middleware serializes it.
|
|
71
|
+
throw new NotFoundError({ message: 'User not found', resource: 'User' });
|
|
72
|
+
|
|
73
|
+
throw new ValidationError({
|
|
74
|
+
message: 'Invalid input',
|
|
75
|
+
fields: [{ path: '/email', message: 'Email is required' }],
|
|
76
|
+
});
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Wire up the handler once (it is **not** automatic — see Pitfalls):
|
|
80
|
+
|
|
81
|
+
```typescript
|
|
82
|
+
import { Hono } from 'hono';
|
|
83
|
+
import { ErrorHandler } from '@spfn/core/middleware';
|
|
84
|
+
|
|
85
|
+
const app = new Hono();
|
|
86
|
+
app.onError(ErrorHandler());
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Response for the `NotFoundError` above (HTTP 404):
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"__type": "NotFoundError",
|
|
94
|
+
"message": "User not found",
|
|
95
|
+
"resource": "User",
|
|
96
|
+
"error": {
|
|
97
|
+
"code": "NotFoundError",
|
|
98
|
+
"message": "User not found",
|
|
99
|
+
"requestId": "9f2c8b1e4d6a70f3c5b2e8a1d4f70c93"
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## HTTP error classes
|
|
107
|
+
|
|
108
|
+
All extend `HttpError → SerializableError`. Each constructor takes an object. Classes
|
|
109
|
+
with no required fields accept zero args and fall back to a default message.
|
|
110
|
+
|
|
111
|
+
| Class | Status | Constructor arg | Extra public fields |
|
|
112
|
+
|---|---|---|---|
|
|
113
|
+
| `HttpError` | (required) | `{ message, statusCode, details? }` | `details?` |
|
|
114
|
+
| `BadRequestError` | 400 | `{ message?, details? }` | — |
|
|
115
|
+
| `ValidationError` | 400 | `{ message, fields?, details? }` | `fields?` |
|
|
116
|
+
| `UnauthorizedError` | 401 | `{ message?, details? }` | — |
|
|
117
|
+
| `ForbiddenError` | 403 | `{ message?, details? }` | — |
|
|
118
|
+
| `NotFoundError` | 404 | `{ message?, resource?, details? }` | `resource?` |
|
|
119
|
+
| `ConflictError` | 409 | `{ message?, details? }` | — |
|
|
120
|
+
| `GoneError` | 410 | `{ message?, resource?, details? }` | `resource?` |
|
|
121
|
+
| `UnsupportedMediaTypeError` | 415 | `{ message?, mediaType?, supportedTypes?, details? }` | `mediaType?`, `supportedTypes?` |
|
|
122
|
+
| `UnprocessableEntityError` | 422 | `{ message?, details? }` | — |
|
|
123
|
+
| `TooManyRequestsError` | 429 | `{ message?, retryAfter?, details? }` | `retryAfter?` |
|
|
124
|
+
| `InternalServerError` | 500 | `{ message?, details? }` | — |
|
|
125
|
+
| `ServiceUnavailableError` | 503 | `{ message?, retryAfter?, details? }` | `retryAfter?` |
|
|
126
|
+
|
|
127
|
+
```typescript
|
|
128
|
+
import {
|
|
129
|
+
BadRequestError,
|
|
130
|
+
UnauthorizedError,
|
|
131
|
+
ForbiddenError,
|
|
132
|
+
NotFoundError,
|
|
133
|
+
ConflictError,
|
|
134
|
+
ValidationError,
|
|
135
|
+
TooManyRequestsError,
|
|
136
|
+
} from '@spfn/core/errors';
|
|
137
|
+
|
|
138
|
+
throw new BadRequestError(); // "Bad request"
|
|
139
|
+
throw new UnauthorizedError({ message: 'Invalid token' }); // 401
|
|
140
|
+
throw new ForbiddenError({ message: 'Insufficient permissions' });
|
|
141
|
+
throw new NotFoundError({ resource: 'User' }); // "Resource not found", resource: 'User'
|
|
142
|
+
throw new ConflictError({ message: 'Email already in use' });
|
|
143
|
+
|
|
144
|
+
throw new ValidationError({
|
|
145
|
+
message: 'Validation failed',
|
|
146
|
+
fields: [
|
|
147
|
+
{ path: '/email', message: 'Invalid format', value: 'nope' },
|
|
148
|
+
{ path: '/age', message: 'Must be >= 18', value: 15 },
|
|
149
|
+
],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
throw new TooManyRequestsError({ message: 'Rate limit exceeded', retryAfter: 60 });
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The optional `details` field accepts any `Record<string, unknown>` and is serialized as-is.
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## Database error classes
|
|
160
|
+
|
|
161
|
+
All extend `SerializableError`. `EntityNotFoundError`, `ConstraintViolationError`, and
|
|
162
|
+
`DuplicateEntryError` extend `QueryError`; `DeadlockError` extends `TransactionError`.
|
|
163
|
+
`EntityNotFoundError` and `DuplicateEntryError` **build their own message** from the
|
|
164
|
+
provided fields.
|
|
165
|
+
|
|
166
|
+
| Class | Status | Constructor arg | Notes |
|
|
167
|
+
|---|---|---|---|
|
|
168
|
+
| `DatabaseError` | 500 (default) | `{ message, statusCode?, details? }` | base class |
|
|
169
|
+
| `ConnectionError` | 503 | `{ message, details? }` | |
|
|
170
|
+
| `QueryError` | 500 (default) | `{ message, statusCode?, details? }` | |
|
|
171
|
+
| `EntityNotFoundError` | 404 | `{ resource, id }` | message auto-built; `resource`, `id` public; `details = { resource, id }` |
|
|
172
|
+
| `ConstraintViolationError` | 400 | `{ message, details? }` | |
|
|
173
|
+
| `TransactionError` | 500 (default) | `{ message, statusCode?, details? }` | |
|
|
174
|
+
| `DeadlockError` | 409 | `{ message, details? }` | |
|
|
175
|
+
| `DuplicateEntryError` | 409 | `{ field, value }` | message auto-built; `field`, `value` public; `details = { field, value }` |
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
import {
|
|
179
|
+
EntityNotFoundError,
|
|
180
|
+
DuplicateEntryError,
|
|
181
|
+
ConnectionError,
|
|
182
|
+
QueryError,
|
|
183
|
+
} from '@spfn/core/errors';
|
|
184
|
+
|
|
185
|
+
throw new EntityNotFoundError({ resource: 'User', id: 123 });
|
|
186
|
+
// message: "User with id 123 not found", statusCode 404
|
|
187
|
+
|
|
188
|
+
throw new DuplicateEntryError({ field: 'email', value: 'john@example.com' });
|
|
189
|
+
// message: "email 'john@example.com' already exists", statusCode 409
|
|
190
|
+
|
|
191
|
+
throw new ConnectionError({ message: 'Failed to connect to database' });
|
|
192
|
+
throw new QueryError({ message: 'Syntax error in SQL query' });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`EntityNotFoundError` is for missing **database** rows. For the HTTP layer (a missing
|
|
196
|
+
route/resource) use `NotFoundError`.
|
|
197
|
+
|
|
198
|
+
### Mapping raw Postgres errors
|
|
199
|
+
|
|
200
|
+
`fromPostgresError(error)` (from `@spfn/core/db`) maps a `pg` error code to one of the DB
|
|
201
|
+
classes above. SPFN's DB helpers and the `Transactional()` middleware already call it, so
|
|
202
|
+
you rarely need it directly.
|
|
203
|
+
|
|
204
|
+
```typescript
|
|
205
|
+
import { fromPostgresError } from '@spfn/core/db';
|
|
206
|
+
|
|
207
|
+
try { await db.insert(users).values(data); }
|
|
208
|
+
catch (error) { throw fromPostgresError(error); }
|
|
209
|
+
// 23505 → DuplicateEntryError, 23503 → ConstraintViolationError,
|
|
210
|
+
// 40P01 → DeadlockError, 08xxx → ConnectionError, else → QueryError
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Serialization & the ErrorRegistry
|
|
216
|
+
|
|
217
|
+
`SerializableError.toJSON()` emits `{ __type: this.constructor.name, message, ...publicFields }`,
|
|
218
|
+
skipping `name`, `message`, `stack`, and `statusCode`. The `ErrorHandler` middleware calls
|
|
219
|
+
`toJSON()`, adds the `error` envelope, and responds with the matching status code (plus
|
|
220
|
+
`stack` when `includeStack` is on).
|
|
221
|
+
|
|
222
|
+
### Reserved field names
|
|
223
|
+
|
|
224
|
+
`__type`, `message` and `error` are reserved. They are the slots the response shape itself
|
|
225
|
+
occupies: `__type` is the discriminator the client registry looks up, `message` is the text,
|
|
226
|
+
and `error` is the `{ code, message, requestId }` envelope a client generated for another
|
|
227
|
+
language reads. A public field with one of those names would land in the same slot, and
|
|
228
|
+
either outcome is a loss — overwrite the envelope and the generated client cannot classify
|
|
229
|
+
the failure, drop the field and the app silently loses data it meant to send.
|
|
230
|
+
|
|
231
|
+
So the collision is refused rather than arbitrated. An error class declaring one of the three
|
|
232
|
+
throws on serialization outside production, which surfaces the first time a test serializes
|
|
233
|
+
it. In production the field is dropped and the class name is logged: throwing there would
|
|
234
|
+
replace the real failure with a failure about serializing it, and the original would never
|
|
235
|
+
reach the log or the client.
|
|
236
|
+
|
|
237
|
+
```typescript
|
|
238
|
+
class OrderFailedError extends SerializableError
|
|
239
|
+
{
|
|
240
|
+
readonly statusCode = 400;
|
|
241
|
+
error!: { vendorCode: string }; // ✗ reserved — rename to vendor, detail, …
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
Going the other way, `ErrorRegistry.deserialize()` drops `__type` and `error` before calling the
|
|
246
|
+
constructor: `__type` routed the lookup and `error` describes the response, so neither is a field
|
|
247
|
+
of the error. Without that, the documented `Object.assign(this, data)` constructor would copy both
|
|
248
|
+
onto the instance and the error would refuse to serialize the moment a server re-threw it.
|
|
249
|
+
|
|
250
|
+
On the client, the SPFN API client deserializes a response body that has a `__type` field
|
|
251
|
+
back into a real error instance using an `ErrorRegistry`. Built-in HTTP and DB errors are
|
|
252
|
+
already in the exported `errorRegistry` and are always merged into the client's registry,
|
|
253
|
+
so `error instanceof NotFoundError` works on the client out of the box. **Custom error
|
|
254
|
+
classes only deserialize if you register them.**
|
|
255
|
+
|
|
256
|
+
```typescript
|
|
257
|
+
import { ErrorRegistry, errorRegistry, ValidationError } from '@spfn/core/errors';
|
|
258
|
+
|
|
259
|
+
// Pre-populated registry holds all built-in HTTP + DB errors
|
|
260
|
+
errorRegistry.has('ValidationError'); // true
|
|
261
|
+
errorRegistry.getRegisteredTypes(); // string[]
|
|
262
|
+
|
|
263
|
+
// Build a custom registry (merge in the built-ins)
|
|
264
|
+
const registry = new ErrorRegistry([errorRegistry, PaymentFailedError]);
|
|
265
|
+
registry.append(ValidationError); // chainable
|
|
266
|
+
registry.concat(errorRegistry); // merge another registry
|
|
267
|
+
|
|
268
|
+
const err = registry.deserialize({ __type: 'NotFoundError', message: 'x', resource: 'User' });
|
|
269
|
+
// err instanceof NotFoundError === true
|
|
270
|
+
|
|
271
|
+
const maybe = registry.tryDeserialize(body); // null if __type missing/unknown (never throws)
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`ErrorRegistry` methods: `append(ClassOrArray)`, `concat(registry)`, `has(name)`,
|
|
275
|
+
`deserialize(data)` (throws on unknown `__type`), `tryDeserialize(data)` (returns `null`),
|
|
276
|
+
`getRegisteredTypes()`. Constructor accepts an array mixing single classes, arrays of
|
|
277
|
+
classes, and other `ErrorRegistry` instances (`ErrorRegistryInput[]`).
|
|
278
|
+
|
|
279
|
+
---
|
|
280
|
+
|
|
281
|
+
## Custom errors
|
|
282
|
+
|
|
283
|
+
Extend `SerializableError` directly (define `statusCode`, set `name`, assign fields), or
|
|
284
|
+
extend one of the concrete classes.
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
import { SerializableError } from '@spfn/core/errors';
|
|
288
|
+
|
|
289
|
+
export class PaymentFailedError extends SerializableError
|
|
290
|
+
{
|
|
291
|
+
readonly statusCode = 402;
|
|
292
|
+
transactionId!: string;
|
|
293
|
+
reason!: 'insufficient_funds' | 'card_declined';
|
|
294
|
+
|
|
295
|
+
constructor(data: {
|
|
296
|
+
message: string;
|
|
297
|
+
transactionId: string;
|
|
298
|
+
reason: 'insufficient_funds' | 'card_declined';
|
|
299
|
+
})
|
|
300
|
+
{
|
|
301
|
+
super(data.message);
|
|
302
|
+
this.name = 'PaymentFailedError';
|
|
303
|
+
Object.assign(this, data);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
To make the client receive `instanceof PaymentFailedError`, register the class in the
|
|
309
|
+
`ErrorRegistry` passed to the API client (otherwise the client gets a plain `Error`).
|
|
310
|
+
For a custom subclass to round-trip, **its constructor must accept the serialized object**
|
|
311
|
+
(`deserialize` calls `new ErrorClass(data)` with the whole JSON body).
|
|
312
|
+
|
|
313
|
+
---
|
|
314
|
+
|
|
315
|
+
## Type guards
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
import { isHttpError, isDatabaseError, hasStatusCode } from '@spfn/core/errors';
|
|
319
|
+
|
|
320
|
+
if (isDatabaseError(error)) { /* error: DatabaseError — has .statusCode, .details */ }
|
|
321
|
+
if (isHttpError(error)) { /* error: HttpError */ }
|
|
322
|
+
if (hasStatusCode(error)) { /* error: { statusCode: number } */ }
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## Pitfalls & anti-patterns
|
|
328
|
+
|
|
329
|
+
- **The `ErrorHandler` middleware is not automatic.** You must register it
|
|
330
|
+
(`app.onError(ErrorHandler())` from `@spfn/core/middleware`). Without it, thrown
|
|
331
|
+
`SerializableError`s are not serialized and you get Hono's default 500.
|
|
332
|
+
- **`ErrorHandler` and `fromPostgresError` are not in `@spfn/core/errors`.** They live in
|
|
333
|
+
`@spfn/core/middleware` and `@spfn/core/db` respectively. Importing them from
|
|
334
|
+
`@spfn/core/errors` fails.
|
|
335
|
+
- **Use the object constructor form.** `new NotFoundError('User')`,
|
|
336
|
+
`new EntityNotFoundError('User', 123)`, `new HttpError(404, 'x')`,
|
|
337
|
+
`new DatabaseError('x', 500, {...})` are all old/removed signatures and won't compile.
|
|
338
|
+
- **A generic `throw new Error('...')` is not type-safe.** `ErrorHandler` returns it as
|
|
339
|
+
`{ __type: 'Error', message, cause?, stack? }` with status **500** (or `error.statusCode`
|
|
340
|
+
if present) — no field-level data, no 4xx mapping. Throw a specific class instead.
|
|
341
|
+
- **Custom errors don't deserialize on the client unless registered.** Built-ins are in the
|
|
342
|
+
exported `errorRegistry` and always merged in, but your own classes must be added to the
|
|
343
|
+
registry you pass to the API client, or the client receives a plain `Error`.
|
|
344
|
+
- **`statusCode` is not in the serialized JSON.** It's excluded by `toJSON()` and re-derived
|
|
345
|
+
from the class on deserialize. Don't expect it in the response body.
|
|
346
|
+
- **No `name` / `timestamp` / `code` fields** in the response — only `__type`, `message`,
|
|
347
|
+
and the error's own public fields (plus `stack` in dev). Don't parse for those.
|
|
348
|
+
- **In a transactional route, re-throw caught errors.** Errors propagate to trigger
|
|
349
|
+
rollback; swallowing one commits the transaction. Let errors bubble to the middleware
|
|
350
|
+
rather than returning `c.json({ error }, code)` yourself.
|
|
351
|
+
- **Never put secrets in `message` or `details`.** They are serialized verbatim and shipped
|
|
352
|
+
to the client (and logged). Don't include passwords, tokens, raw SQL with credentials, etc.
|
|
353
|
+
|
|
354
|
+
---
|
|
355
|
+
|
|
356
|
+
## Complete example
|
|
357
|
+
|
|
358
|
+
```typescript
|
|
359
|
+
// server.ts — register the handler once
|
|
360
|
+
import { Hono } from 'hono';
|
|
361
|
+
import { ErrorHandler } from '@spfn/core/middleware';
|
|
362
|
+
|
|
363
|
+
export const app = new Hono();
|
|
364
|
+
app.onError(ErrorHandler({ includeStack: process.env.NODE_ENV !== 'production' }));
|
|
365
|
+
|
|
366
|
+
// routes/users.ts — throw specific errors; the handler serializes them
|
|
367
|
+
import { route } from '@spfn/core/route';
|
|
368
|
+
import { Type } from '@sinclair/typebox';
|
|
369
|
+
import { NotFoundError, DuplicateEntryError } from '@spfn/core/errors';
|
|
370
|
+
import { findOne, create } from '@spfn/core/db';
|
|
371
|
+
import { users } from '@/server/entities/users';
|
|
372
|
+
|
|
373
|
+
export const getUser = route.get('/users/:id')
|
|
374
|
+
.input({ params: Type.Object({ id: Type.String() }) })
|
|
375
|
+
.handler(async (c) =>
|
|
376
|
+
{
|
|
377
|
+
const { params } = await c.data();
|
|
378
|
+
const user = await findOne(users, { id: params.id });
|
|
379
|
+
|
|
380
|
+
if (!user)
|
|
381
|
+
{
|
|
382
|
+
throw new NotFoundError({ message: 'User not found', resource: 'User' });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
return user;
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
export const createUser = route.post('/users')
|
|
389
|
+
.input({ body: Type.Object({ email: Type.String(), name: Type.String() }) })
|
|
390
|
+
.handler(async (c) =>
|
|
391
|
+
{
|
|
392
|
+
const { body } = await c.data();
|
|
393
|
+
|
|
394
|
+
if (await findOne(users, { email: body.email }))
|
|
395
|
+
{
|
|
396
|
+
throw new DuplicateEntryError({ field: 'email', value: body.email });
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
return c.created(await create(users, body));
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
// client side — instanceof works because NotFoundError is a built-in
|
|
403
|
+
try { await api.users.get({ id: '999' }); }
|
|
404
|
+
catch (err)
|
|
405
|
+
{
|
|
406
|
+
if (err instanceof NotFoundError) { /* err.resource === 'User' */ }
|
|
407
|
+
}
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Types reference
|
|
413
|
+
|
|
414
|
+
- `SerializedError` — `{ __type: string; message: string; [key: string]: unknown }`
|
|
415
|
+
- `SerializableErrorConstructor` — `new (data: any) => SerializableError`
|
|
416
|
+
- `ErrorRegistryInput` — `SerializableErrorConstructor | SerializableErrorConstructor[] | ErrorRegistry`
|
|
417
|
+
- `SerializableError` (abstract) — `Error` subclass with abstract `readonly statusCode: number`
|
|
418
|
+
and `toJSON(): SerializedError`
|
|
419
|
+
|
|
420
|
+
---
|
|
421
|
+
|
|
422
|
+
## Related
|
|
423
|
+
|
|
424
|
+
- `@spfn/core/middleware` — `ErrorHandler` (the `onError` middleware that serializes these
|
|
425
|
+
errors), `ErrorHandlerOptions` (`includeStack`, `enableLogging`, `onError`).
|
|
426
|
+
- `@spfn/core/db` — `fromPostgresError` and the `Transactional()` middleware that converts
|
|
427
|
+
and rolls back on DB errors.
|
|
428
|
+
- `@spfn/notification/server` — `createErrorSlackNotifier` (an `onError` callback for Slack
|
|
429
|
+
alerts). `@spfn/monitor/server` — `createMonitorErrorHandler` for DB-backed error tracking.
|