@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.
@@ -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.