@vibeorm/runtime 1.0.1 → 1.1.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/src/errors.ts CHANGED
@@ -1,12 +1,159 @@
1
1
  /**
2
- * VibeORM Validation Error
2
+ * VibeORM Error Hierarchy
3
3
  *
4
- * Thrown when Zod validation fails for query inputs or outputs.
5
- * Contains structured information about which model, operation, and
6
- * direction (input/output) the validation failure occurred in.
4
+ * All VibeORM errors extend the abstract VibeError base class, split into
5
+ * two concrete branches:
6
+ *
7
+ * - VibeRequestError — deterministic failures caused by invalid data or
8
+ * violated constraints. Running the same operation again will produce
9
+ * the same error. Includes: unique constraint, FK violation, not-null,
10
+ * check constraint, not-found, and validation errors.
11
+ *
12
+ * - VibeTransientError — transient infrastructure failures where retrying
13
+ * the same operation may succeed. Includes: connection errors, deadlocks,
14
+ * serialization failures, statement timeouts, and pool exhaustion.
15
+ *
16
+ * VibeValidationError (Zod validation) is a subclass of VibeRequestError,
17
+ * so `instanceof VibeRequestError` catches both constraint violations AND
18
+ * validation errors. Use `instanceof VibeValidationError` to narrow.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { VibeRequestError, VibeTransientError, VibeError } from "@vibeorm/runtime";
23
+ *
24
+ * try {
25
+ * await db.user.create({ data: { email: "taken@example.com" } });
26
+ * } catch (error) {
27
+ * if (error instanceof VibeRequestError) {
28
+ * if (error.code === "UNIQUE_CONSTRAINT") {
29
+ * console.log(error.meta.constraint); // "User_email_key"
30
+ * console.log(error.meta.detail); // 'Key (email)=(taken@example.com) already exists.'
31
+ * return { error: "Email already taken" };
32
+ * }
33
+ * }
34
+ * if (error instanceof VibeTransientError) {
35
+ * // error.retryable is always true
36
+ * return retry(() => db.user.create({ ... }));
37
+ * }
38
+ * throw error;
39
+ * }
40
+ * ```
41
+ */
42
+
43
+ // ─── Error Codes ─────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Error codes for deterministic request failures.
47
+ * The operation itself is invalid — changing the data would fix it.
48
+ */
49
+ export type VibeRequestErrorCode =
50
+ | "UNIQUE_CONSTRAINT"
51
+ | "FOREIGN_KEY_VIOLATION"
52
+ | "NOT_NULL_VIOLATION"
53
+ | "CHECK_CONSTRAINT"
54
+ | "NOT_FOUND"
55
+ | "VALIDATION_ERROR"
56
+ | "UNKNOWN_REQUEST_ERROR";
57
+
58
+ /**
59
+ * Error codes for transient infrastructure failures.
60
+ * The operation is valid but the infrastructure failed — retrying may fix it.
61
+ */
62
+ export type VibeTransientErrorCode =
63
+ | "CONNECTION_ERROR"
64
+ | "DEADLOCK"
65
+ | "SERIALIZATION_FAILURE"
66
+ | "STATEMENT_TIMEOUT"
67
+ | "TOO_MANY_CONNECTIONS"
68
+ | "UNKNOWN_TRANSIENT_ERROR";
69
+
70
+ /** Union of all VibeORM error codes. */
71
+ export type VibeErrorCode = VibeRequestErrorCode | VibeTransientErrorCode;
72
+
73
+ // ─── Error Meta ──────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Structured metadata attached to every VibeORM error.
77
+ * Fields are populated when available from the PostgreSQL error protocol
78
+ * or from the application-level context (model name, operation, etc.).
79
+ */
80
+ export type VibeErrorMeta = {
81
+ /** VibeORM model name (e.g. "User", "Post"). Set for app-level errors. */
82
+ model?: string;
83
+ /** The field that caused the error (extracted from PG detail when possible). */
84
+ field?: string;
85
+ /** PostgreSQL constraint name (e.g. "User_email_key"). */
86
+ constraint?: string;
87
+ /** PostgreSQL table name from the error (e.g. "User"). */
88
+ table?: string;
89
+ /** PostgreSQL column name from the error. */
90
+ column?: string;
91
+ /** PostgreSQL schema name from the error (e.g. "public"). */
92
+ schema?: string;
93
+ /** Human-readable detail from PostgreSQL (e.g. 'Key (email)=(x@y.com) already exists.'). */
94
+ detail?: string;
95
+ /** The VibeORM operation that triggered this error (e.g. "create", "update"). */
96
+ operation?: string;
97
+ /** Validation direction — only set on VibeValidationError. */
98
+ direction?: "input" | "output";
99
+ /** Raw Zod error object — only set on VibeValidationError. */
100
+ zodError?: unknown;
101
+ };
102
+
103
+ // ─── Base Class ──────────────────────────────────────────────────
104
+
105
+ /**
106
+ * Abstract base class for all VibeORM errors.
107
+ * Use `instanceof VibeError` to catch any error originating from VibeORM.
108
+ */
109
+ export abstract class VibeError extends Error {
110
+ abstract readonly code: VibeErrorCode;
111
+ readonly meta: VibeErrorMeta;
112
+
113
+ constructor(params: { message: string; meta?: VibeErrorMeta; cause?: Error }) {
114
+ super(params.message, { cause: params.cause });
115
+ this.name = "VibeError";
116
+ this.meta = params.meta ?? {};
117
+ }
118
+ }
119
+
120
+ // ─── Request Error (deterministic) ──────────────────────────────
121
+
122
+ /**
123
+ * Deterministic request error — the operation itself is invalid.
124
+ * Same input will always produce the same failure.
125
+ *
126
+ * Covers: constraint violations, not-found, validation, and
127
+ * unrecognized database errors that aren't transient.
128
+ *
129
+ * Use `error.code` to narrow the specific failure type.
7
130
  */
131
+ export class VibeRequestError extends VibeError {
132
+ readonly code: VibeRequestErrorCode;
133
+
134
+ constructor(params: {
135
+ code: VibeRequestErrorCode;
136
+ message: string;
137
+ meta?: VibeErrorMeta;
138
+ cause?: Error;
139
+ }) {
140
+ super({ message: params.message, meta: params.meta, cause: params.cause });
141
+ this.name = "VibeRequestError";
142
+ this.code = params.code;
143
+ }
144
+ }
145
+
146
+ // ─── Validation Error (subclass of Request) ─────────────────────
8
147
 
9
- export class VibeValidationError extends Error {
148
+ /**
149
+ * Zod validation error — thrown when input or output data fails schema validation.
150
+ *
151
+ * Subclass of VibeRequestError, so `instanceof VibeRequestError` catches it.
152
+ * Use `instanceof VibeValidationError` to narrow specifically to validation failures.
153
+ *
154
+ * Preserves backward-compatible fields: model, operation, direction, zodError.
155
+ */
156
+ export class VibeValidationError extends VibeRequestError {
10
157
  readonly model: string;
11
158
  readonly operation: string;
12
159
  readonly direction: "input" | "output";
@@ -20,7 +167,11 @@ export class VibeValidationError extends Error {
20
167
  }) {
21
168
  const { model, operation, direction, zodError } = params;
22
169
  const msg = `Validation failed for ${model}.${operation} (${direction}): ${formatZodError({ error: zodError })}`;
23
- super(msg);
170
+ super({
171
+ code: "VALIDATION_ERROR",
172
+ message: msg,
173
+ meta: { model, operation, direction, zodError },
174
+ });
24
175
  this.name = "VibeValidationError";
25
176
  this.model = model;
26
177
  this.operation = operation;
@@ -29,6 +180,276 @@ export class VibeValidationError extends Error {
29
180
  }
30
181
  }
31
182
 
183
+ // ─── Transient Error (retryable) ────────────────────────────────
184
+
185
+ /**
186
+ * Transient infrastructure error — the operation is valid but the
187
+ * infrastructure failed. Retrying the same operation may succeed.
188
+ *
189
+ * Covers: connection errors, deadlocks, serialization failures,
190
+ * statement timeouts, and pool exhaustion.
191
+ *
192
+ * `retryable` is always `true` on this class.
193
+ */
194
+ export class VibeTransientError extends VibeError {
195
+ readonly code: VibeTransientErrorCode;
196
+ readonly retryable = true as const;
197
+
198
+ constructor(params: {
199
+ code: VibeTransientErrorCode;
200
+ message: string;
201
+ meta?: VibeErrorMeta;
202
+ cause?: Error;
203
+ }) {
204
+ super({ message: params.message, meta: params.meta, cause: params.cause });
205
+ this.name = "VibeTransientError";
206
+ this.code = params.code;
207
+ }
208
+ }
209
+
210
+ // ─── SQLSTATE → VibeError Mapping ───────────────────────────────
211
+
212
+ /**
213
+ * SQLSTATE code ranges for transient (retryable) errors.
214
+ * Class 08 = connection, Class 40 = transaction rollback,
215
+ * 57014 = query_canceled (statement_timeout), 53300 = too_many_connections.
216
+ */
217
+ const TRANSIENT_CODE_MAP: Record<string, VibeTransientErrorCode> = {
218
+ "08000": "CONNECTION_ERROR",
219
+ "08001": "CONNECTION_ERROR",
220
+ "08003": "CONNECTION_ERROR",
221
+ "08004": "CONNECTION_ERROR",
222
+ "08006": "CONNECTION_ERROR",
223
+ "08007": "CONNECTION_ERROR",
224
+ "08P01": "CONNECTION_ERROR",
225
+ "40P01": "DEADLOCK",
226
+ "40001": "SERIALIZATION_FAILURE",
227
+ "57014": "STATEMENT_TIMEOUT",
228
+ "53300": "TOO_MANY_CONNECTIONS",
229
+ };
230
+
231
+ /**
232
+ * SQLSTATE codes for constraint violation errors (Class 23).
233
+ */
234
+ const CONSTRAINT_CODE_MAP: Record<string, VibeRequestErrorCode> = {
235
+ "23505": "UNIQUE_CONSTRAINT",
236
+ "23503": "FOREIGN_KEY_VIOLATION",
237
+ "23502": "NOT_NULL_VIOLATION",
238
+ "23514": "CHECK_CONSTRAINT",
239
+ };
240
+
241
+ /**
242
+ * Extract a field name from a PostgreSQL detail string.
243
+ *
244
+ * Examples:
245
+ * - 'Key (email)=(x@y.com) already exists.' → "email"
246
+ * - 'Failing row contains (1, null, ...).' → undefined
247
+ * - 'Key (author_id)=(999) is not present in table "User".' → "author_id"
248
+ */
249
+ function extractFieldFromDetail(params: { detail: string }): string | undefined {
250
+ const match = params.detail.match(/Key \(([^)]+)\)/);
251
+ return match ? match[1] : undefined;
252
+ }
253
+
254
+ /**
255
+ * Shape of a PostgreSQL protocol error as exposed by both bun:sql and node-postgres.
256
+ *
257
+ * Note: bun:sql puts the SQLSTATE code in `errno` (e.g. "23505") while `code`
258
+ * contains a Node.js-style string (e.g. "ERR_POSTGRES_SERVER_ERROR").
259
+ * node-postgres puts the SQLSTATE code in `code` directly.
260
+ */
261
+ type PgProtocolError = {
262
+ code: string;
263
+ errno?: string;
264
+ message: string;
265
+ detail?: string;
266
+ hint?: string;
267
+ constraint?: string;
268
+ table?: string;
269
+ column?: string | number;
270
+ schema?: string;
271
+ severity?: string;
272
+ };
273
+
274
+ /**
275
+ * Check whether a raw error object looks like a PostgreSQL protocol error.
276
+ * Both bun:sql (PostgresError) and node-postgres (DatabaseError) expose
277
+ * error fields from the PostgreSQL wire protocol. The SQLSTATE code is in
278
+ * `errno` (bun:sql) or `code` (node-postgres).
279
+ */
280
+ function isPgError(error: unknown): error is PgProtocolError {
281
+ if (error === null || typeof error !== "object" || !("message" in error)) return false;
282
+ const e = error as Record<string, unknown>;
283
+ // node-postgres: has `code` as a 5-char SQLSTATE string
284
+ // bun:sql: has `errno` as a SQLSTATE string + `severity`
285
+ return (
286
+ (typeof e.code === "string" && /^[0-9A-Z]{5}$/.test(e.code)) ||
287
+ (typeof e.errno === "string" && typeof e.severity === "string")
288
+ );
289
+ }
290
+
291
+ /**
292
+ * Extract the SQLSTATE code from a PgProtocolError.
293
+ * bun:sql stores it in `errno`, node-postgres stores it in `code`.
294
+ */
295
+ function getSqlStateCode(error: PgProtocolError): string {
296
+ // bun:sql: errno contains the actual SQLSTATE code (e.g. "23505")
297
+ if (error.errno && /^[0-9A-Z]{5}$/.test(error.errno)) {
298
+ return error.errno;
299
+ }
300
+ // node-postgres: code contains the SQLSTATE code
301
+ if (/^[0-9A-Z]{5}$/.test(error.code)) {
302
+ return error.code;
303
+ }
304
+ return error.code;
305
+ }
306
+
307
+ /**
308
+ * Check whether a raw error looks like a client-side connection error
309
+ * (e.g. ECONNREFUSED, ENOTFOUND, ETIMEDOUT) that doesn't have a SQLSTATE code.
310
+ */
311
+ function isConnectionError(err: unknown): boolean {
312
+ if (err === null || typeof err !== "object") return false;
313
+ const anyErr = err as Record<string, unknown>;
314
+
315
+ // Node.js system errors from net/dns
316
+ if (typeof anyErr.code === "string") {
317
+ const code = anyErr.code;
318
+ if (
319
+ code === "ECONNREFUSED" ||
320
+ code === "ECONNRESET" ||
321
+ code === "ENOTFOUND" ||
322
+ code === "ETIMEDOUT" ||
323
+ code === "EPIPE"
324
+ ) {
325
+ return true;
326
+ }
327
+ }
328
+
329
+ // bun:sql connection-level errors often have specific message patterns
330
+ const msg = typeof anyErr.message === "string" ? anyErr.message : "";
331
+ if (
332
+ msg.includes("connection refused") ||
333
+ msg.includes("Connection terminated") ||
334
+ msg.includes("Connection lost") ||
335
+ msg.includes("connect ECONNREFUSED") ||
336
+ msg.includes("the database system is starting up")
337
+ ) {
338
+ return true;
339
+ }
340
+
341
+ return false;
342
+ }
343
+
344
+ /**
345
+ * Normalize a raw database error into a structured VibeORM error.
346
+ *
347
+ * Both bun:sql and node-postgres expose the PostgreSQL ErrorResponse fields
348
+ * (code, detail, constraint, table, column, schema, severity), so this
349
+ * function is adapter-agnostic.
350
+ *
351
+ * If the error is already a VibeError, it is returned as-is.
352
+ *
353
+ * @param error - The raw error from the database driver.
354
+ * @param model - Optional VibeORM model name for context.
355
+ * @param operation - Optional operation name for context.
356
+ */
357
+ export function normalizeError(params: {
358
+ error: unknown;
359
+ model?: string;
360
+ operation?: string;
361
+ }): VibeRequestError | VibeTransientError {
362
+ const { error, model, operation } = params;
363
+
364
+ // Already a VibeError — return as-is (don't double-wrap)
365
+ if (error instanceof VibeError) {
366
+ return error as VibeRequestError | VibeTransientError;
367
+ }
368
+
369
+ const cause = error instanceof Error ? error : new Error(String(error));
370
+
371
+ // ─── PostgreSQL protocol error (has SQLSTATE code) ───────────
372
+ if (isPgError(error)) {
373
+ const pgErr = error;
374
+ const pgCode = getSqlStateCode(pgErr);
375
+ const meta: VibeErrorMeta = {
376
+ model,
377
+ operation,
378
+ constraint: pgErr.constraint,
379
+ table: pgErr.table,
380
+ column: typeof pgErr.column === "string" ? pgErr.column : undefined,
381
+ schema: pgErr.schema,
382
+ detail: pgErr.detail,
383
+ };
384
+
385
+ // Extract field name from detail when available
386
+ if (pgErr.detail) {
387
+ const field = extractFieldFromDetail({ detail: pgErr.detail });
388
+ if (field) meta.field = field;
389
+ }
390
+
391
+ // Check transient errors first (Class 08, 40, 57014, 53300)
392
+ const transientCode = TRANSIENT_CODE_MAP[pgCode];
393
+ if (transientCode) {
394
+ return new VibeTransientError({
395
+ code: transientCode,
396
+ message: pgErr.message,
397
+ meta,
398
+ cause,
399
+ });
400
+ }
401
+
402
+ // Check constraint violations (Class 23)
403
+ const constraintCode = CONSTRAINT_CODE_MAP[pgCode];
404
+ if (constraintCode) {
405
+ return new VibeRequestError({
406
+ code: constraintCode,
407
+ message: pgErr.message,
408
+ meta,
409
+ cause,
410
+ });
411
+ }
412
+
413
+ // Check transient by SQLSTATE class prefix
414
+ if (pgCode.startsWith("08") || pgCode.startsWith("40")) {
415
+ return new VibeTransientError({
416
+ code: pgCode.startsWith("08") ? "CONNECTION_ERROR" : "UNKNOWN_TRANSIENT_ERROR",
417
+ message: pgErr.message,
418
+ meta,
419
+ cause,
420
+ });
421
+ }
422
+
423
+ // Unrecognized SQLSTATE — treat as request error
424
+ return new VibeRequestError({
425
+ code: "UNKNOWN_REQUEST_ERROR",
426
+ message: pgErr.message,
427
+ meta,
428
+ cause,
429
+ });
430
+ }
431
+
432
+ // ─── Client-side connection error (no SQLSTATE) ──────────────
433
+ if (isConnectionError(error)) {
434
+ return new VibeTransientError({
435
+ code: "CONNECTION_ERROR",
436
+ message: cause.message,
437
+ meta: { model, operation },
438
+ cause,
439
+ });
440
+ }
441
+
442
+ // ─── Unknown error — treat as request error ──────────────────
443
+ return new VibeRequestError({
444
+ code: "UNKNOWN_REQUEST_ERROR",
445
+ message: cause.message,
446
+ meta: { model, operation },
447
+ cause,
448
+ });
449
+ }
450
+
451
+ // ─── Helpers ─────────────────────────────────────────────────────
452
+
32
453
  function formatZodError(params: { error: unknown }): string {
33
454
  const { error } = params;
34
455
  if (error && typeof error === "object" && "issues" in error) {
package/src/index.ts CHANGED
@@ -8,7 +8,13 @@
8
8
  */
9
9
 
10
10
  export { createClient } from "./client.ts";
11
- export { VibeValidationError } from "./errors.ts";
11
+ export {
12
+ VibeError,
13
+ VibeRequestError,
14
+ VibeTransientError,
15
+ VibeValidationError,
16
+ normalizeError,
17
+ } from "./errors.ts";
12
18
  export {
13
19
  loadRelationsWithLateralJoin,
14
20
  executeLateralJoinQuery,
@@ -19,8 +25,16 @@ export type {
19
25
  DatabaseAdapter,
20
26
  QueryResult,
21
27
  SqlExecutor,
28
+ TransactionOptions,
22
29
  } from "./adapter.ts";
23
30
 
31
+ export type {
32
+ VibeErrorCode,
33
+ VibeRequestErrorCode,
34
+ VibeTransientErrorCode,
35
+ VibeErrorMeta,
36
+ } from "./errors.ts";
37
+
24
38
  export type {
25
39
  VibeClientOptions,
26
40
  ModelMeta,