@vibeorm/runtime 1.2.0 → 2.0.0-alpha.1
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 +50 -107
- package/dist/adapter.d.ts +124 -0
- package/dist/adapter.d.ts.map +1 -0
- package/dist/client.d.ts +149 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/codecs.d.ts +170 -0
- package/dist/codecs.d.ts.map +1 -0
- package/dist/extensions.d.ts +102 -0
- package/dist/extensions.d.ts.map +1 -0
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6437 -0
- package/dist/index.js.map +20 -0
- package/dist/model-meta.d.ts +144 -0
- package/dist/model-meta.d.ts.map +1 -0
- package/dist/nested-writes.d.ts +100 -0
- package/dist/nested-writes.d.ts.map +1 -0
- package/dist/query-builder.d.ts +242 -0
- package/dist/query-builder.d.ts.map +1 -0
- package/dist/relation-loader.d.ts +75 -0
- package/dist/relation-loader.d.ts.map +1 -0
- package/dist/relation-plan.d.ts +101 -0
- package/dist/relation-plan.d.ts.map +1 -0
- package/dist/render-cache.d.ts +48 -0
- package/dist/render-cache.d.ts.map +1 -0
- package/dist/views.d.ts +97 -0
- package/dist/views.d.ts.map +1 -0
- package/package.json +31 -26
- package/src/adapter.ts +0 -146
- package/src/client.ts +0 -2172
- package/src/coerce.ts +0 -84
- package/src/count-loader.ts +0 -152
- package/src/errors.ts +0 -460
- package/src/id-generators.ts +0 -151
- package/src/index.ts +0 -54
- package/src/lateral-join-builder.ts +0 -1053
- package/src/query-builder.ts +0 -1832
- package/src/relation-loader.ts +0 -534
- package/src/retry.ts +0 -183
- package/src/types.ts +0 -317
- package/src/view.ts +0 -629
- package/src/where-builder.ts +0 -772
package/src/coerce.ts
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Scalar value type coercion.
|
|
3
|
-
*
|
|
4
|
-
* Coerce raw DB driver output to the JS types declared in the Prisma schema.
|
|
5
|
-
* Currently handles `BigInt` (pg / bun:sql return PG `bigint` as a string,
|
|
6
|
-
* but the application expects native `BigInt`).
|
|
7
|
-
*
|
|
8
|
-
* Lives in its own module so all relation loaders (query strategy, lateral
|
|
9
|
-
* join strategy, post-write refresh, raw post-processing) can call it without
|
|
10
|
-
* creating circular imports between `client.ts` and the relation loaders.
|
|
11
|
-
*
|
|
12
|
-
* Mutates the records in place — callers rely on this for performance.
|
|
13
|
-
*/
|
|
14
|
-
|
|
15
|
-
import type { ModelMeta, ScalarFieldMeta } from "./types.ts";
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Per-model cache of names of fields that need BigInt coercion.
|
|
19
|
-
* Empty arrays are cached too, so the `length === 0` fast path costs one
|
|
20
|
-
* `WeakMap.get` after the first call per model.
|
|
21
|
-
*
|
|
22
|
-
* Keyed by the `scalarFields` array reference (the same hot-path cache key
|
|
23
|
-
* used by `getScalarFieldMap`), which is stable for the process lifetime
|
|
24
|
-
* because model metadata is generated once at startup.
|
|
25
|
-
*/
|
|
26
|
-
const _bigintFieldsCache = new WeakMap<
|
|
27
|
-
readonly ScalarFieldMeta[],
|
|
28
|
-
readonly string[]
|
|
29
|
-
>();
|
|
30
|
-
|
|
31
|
-
function getBigIntFieldNames(modelMeta: ModelMeta): readonly string[] {
|
|
32
|
-
const sf = modelMeta.scalarFields;
|
|
33
|
-
let names = _bigintFieldsCache.get(sf);
|
|
34
|
-
if (names) return names;
|
|
35
|
-
const arr: string[] = [];
|
|
36
|
-
for (const f of sf) {
|
|
37
|
-
if ((f as { type?: string }).type === "BigInt") arr.push(f.name);
|
|
38
|
-
}
|
|
39
|
-
names = arr;
|
|
40
|
-
_bigintFieldsCache.set(sf, names);
|
|
41
|
-
return names;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Coerce scalar field values on the given records to their JS-native types.
|
|
46
|
-
*
|
|
47
|
-
* Today only `BigInt` fields are affected. Driver behaviour:
|
|
48
|
-
* - `bun:sql` returns PG `bigint` as a JS `string`.
|
|
49
|
-
* - `node-postgres` returns PG `bigint` as a JS `string` by default too.
|
|
50
|
-
* - In both cases the Prisma type is `BigInt`, so we coerce.
|
|
51
|
-
*
|
|
52
|
-
* No-op when the model has no `BigInt` fields, or when `records` is empty.
|
|
53
|
-
*/
|
|
54
|
-
export function coerceFieldTypes(params: {
|
|
55
|
-
records: Record<string, unknown>[];
|
|
56
|
-
modelMeta: ModelMeta;
|
|
57
|
-
}): void {
|
|
58
|
-
const { records, modelMeta } = params;
|
|
59
|
-
if (records.length === 0) return;
|
|
60
|
-
|
|
61
|
-
const bigintNames = getBigIntFieldNames(modelMeta);
|
|
62
|
-
if (bigintNames.length === 0) return;
|
|
63
|
-
|
|
64
|
-
for (const record of records) {
|
|
65
|
-
for (const name of bigintNames) {
|
|
66
|
-
const val = record[name];
|
|
67
|
-
if (typeof val === "string") {
|
|
68
|
-
record[name] = BigInt(val);
|
|
69
|
-
} else if (typeof val === "number") {
|
|
70
|
-
record[name] = BigInt(val);
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* True iff the model has any `BigInt` fields that require coercion.
|
|
78
|
-
* Cheap O(1) lookup after first call per model. Hot-path callers (lateral-join
|
|
79
|
-
* builder, relation loaders) use this to skip the per-row coercion loop
|
|
80
|
-
* entirely for relations whose related model has no `BigInt` columns.
|
|
81
|
-
*/
|
|
82
|
-
export function modelHasBigInt(modelMeta: ModelMeta): boolean {
|
|
83
|
-
return getBigIntFieldNames(modelMeta).length > 0;
|
|
84
|
-
}
|
package/src/count-loader.ts
DELETED
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared `_count` resolution + loader, used by both the "query" and "join"
|
|
3
|
-
* relation strategies.
|
|
4
|
-
*
|
|
5
|
-
* `resolveCountSpec` looks at `include._count` / `select._count` and returns
|
|
6
|
-
* the list of list-relation names to count (or `["__all__"]` for `_count: true`,
|
|
7
|
-
* or `null` when not requested).
|
|
8
|
-
*
|
|
9
|
-
* `loadRelationCounts` issues a single grouped `COUNT(*) GROUP BY <fk>` per
|
|
10
|
-
* relation (parallelised across relations) and attaches the result as a
|
|
11
|
-
* `_count` object on each parent record.
|
|
12
|
-
*
|
|
13
|
-
* Lives in its own module so the lateral-join path can call it without
|
|
14
|
-
* pulling in `client.ts` (which would create a circular import).
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import type { ModelMeta, ModelMetaMap } from "./types.ts";
|
|
18
|
-
import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
|
|
19
|
-
|
|
20
|
-
type SqlExecutor = (params: {
|
|
21
|
-
text: string;
|
|
22
|
-
values: unknown[];
|
|
23
|
-
}) => Promise<Record<string, unknown>[]>;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Resolve `_count` specification from `include` or `select` args.
|
|
27
|
-
* Returns the list of relation names to count, `["__all__"]` to count every
|
|
28
|
-
* list relation, or `null` if `_count` was not requested.
|
|
29
|
-
*/
|
|
30
|
-
export function resolveCountSpec(params: { args: Record<string, unknown> }): string[] | null {
|
|
31
|
-
const { args } = params;
|
|
32
|
-
const include = args.include as Record<string, unknown> | undefined;
|
|
33
|
-
const select = args.select as Record<string, unknown> | undefined;
|
|
34
|
-
|
|
35
|
-
const countArg = include?._count ?? select?._count;
|
|
36
|
-
if (!countArg) return null;
|
|
37
|
-
|
|
38
|
-
if (countArg === true) {
|
|
39
|
-
// Count all list relations — will be resolved by loadRelationCounts
|
|
40
|
-
return ["__all__"];
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (typeof countArg === "object" && countArg !== null) {
|
|
44
|
-
const countObj = countArg as Record<string, unknown>;
|
|
45
|
-
const selectObj = countObj.select as Record<string, boolean> | undefined;
|
|
46
|
-
if (selectObj) {
|
|
47
|
-
return Object.entries(selectObj)
|
|
48
|
-
.filter(([_, enabled]) => enabled)
|
|
49
|
-
.map(([name]) => name);
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Load relation counts and attach a `_count` object to each parent record.
|
|
58
|
-
* Uses one grouped `COUNT(*)` query per relation, executed in parallel.
|
|
59
|
-
*
|
|
60
|
-
* Mutates the parent records in place.
|
|
61
|
-
*/
|
|
62
|
-
export async function loadRelationCounts(params: {
|
|
63
|
-
records: Record<string, unknown>[];
|
|
64
|
-
modelMeta: ModelMeta;
|
|
65
|
-
allModelsMeta: ModelMetaMap;
|
|
66
|
-
countSpec: string[];
|
|
67
|
-
executor: SqlExecutor;
|
|
68
|
-
}): Promise<void> {
|
|
69
|
-
const { records, modelMeta, allModelsMeta, countSpec, executor } = params;
|
|
70
|
-
const modelMap = getModelByNameMap({ allModelsMeta });
|
|
71
|
-
const parentPk = modelMeta.primaryKey[0];
|
|
72
|
-
if (!parentPk) return;
|
|
73
|
-
|
|
74
|
-
const parentIds = records.map((r) => r[parentPk]).filter((id) => id != null);
|
|
75
|
-
if (parentIds.length === 0) return;
|
|
76
|
-
|
|
77
|
-
// Resolve which relations to count
|
|
78
|
-
const listRelations = modelMeta.relationFields.filter((r) => r.isList);
|
|
79
|
-
const relationsToCount = countSpec.includes("__all__")
|
|
80
|
-
? listRelations
|
|
81
|
-
: listRelations.filter((r) => countSpec.includes(r.name));
|
|
82
|
-
|
|
83
|
-
// Initialize _count on all records
|
|
84
|
-
for (const record of records) {
|
|
85
|
-
const countObj: Record<string, number> = {};
|
|
86
|
-
for (const rel of relationsToCount) {
|
|
87
|
-
countObj[rel.name] = 0;
|
|
88
|
-
}
|
|
89
|
-
record._count = countObj;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
// Run all relation COUNT queries in parallel — each hits a different table
|
|
93
|
-
// so there are no data races on the parent records.
|
|
94
|
-
await Promise.all(
|
|
95
|
-
relationsToCount.map(async (rel) => {
|
|
96
|
-
const relatedModelMeta = modelMap.get(rel.relatedModel);
|
|
97
|
-
if (!relatedModelMeta) return;
|
|
98
|
-
|
|
99
|
-
// M:N relation: count via join table
|
|
100
|
-
if (rel.type === "manyToMany" && (rel as { joinTable?: string }).joinTable) {
|
|
101
|
-
const joinTableName = (rel as { joinTable?: string }).joinTable!;
|
|
102
|
-
const sorted = [modelMeta.name, relatedModelMeta.name].sort();
|
|
103
|
-
const parentIsA = modelMeta.name === sorted[0];
|
|
104
|
-
const parentCol = parentIsA ? "A" : "B";
|
|
105
|
-
|
|
106
|
-
const text = `SELECT "${joinTableName}"."${parentCol}" AS "__fk", COUNT(*) AS "__count" FROM "${joinTableName}" WHERE "${joinTableName}"."${parentCol}" = ANY($1) GROUP BY "${joinTableName}"."${parentCol}"`;
|
|
107
|
-
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
108
|
-
|
|
109
|
-
const countMap = new Map<unknown, number>();
|
|
110
|
-
for (const row of result) {
|
|
111
|
-
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
for (const record of records) {
|
|
115
|
-
const pkValue = record[parentPk];
|
|
116
|
-
const cnt = countMap.get(pkValue) ?? 0;
|
|
117
|
-
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
118
|
-
}
|
|
119
|
-
return;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
// Find the FK column on the related model (with relationName disambiguation)
|
|
123
|
-
const reverseRel = relatedModelMeta.relationFields.find(
|
|
124
|
-
(r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0 &&
|
|
125
|
-
(!rel.relationName || r.relationName === rel.relationName)
|
|
126
|
-
);
|
|
127
|
-
if (!reverseRel) return;
|
|
128
|
-
|
|
129
|
-
const fkField = reverseRel.fields[0]!;
|
|
130
|
-
const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
|
|
131
|
-
const fkScalar = relatedSfMap.get(fkField);
|
|
132
|
-
const fkDbName = fkScalar?.dbName ?? fkField;
|
|
133
|
-
const relatedTable = `"${relatedModelMeta.dbName}"`;
|
|
134
|
-
|
|
135
|
-
// SELECT "fk" AS "__fk", COUNT(*) AS "__count" FROM "related" WHERE "fk" = ANY($1) GROUP BY "fk"
|
|
136
|
-
const text = `SELECT ${relatedTable}."${fkDbName}" AS "__fk", COUNT(*) AS "__count" FROM ${relatedTable} WHERE ${relatedTable}."${fkDbName}" = ANY($1) GROUP BY ${relatedTable}."${fkDbName}"`;
|
|
137
|
-
const result = await executor({ text, values: [new PgArray(parentIds)] });
|
|
138
|
-
|
|
139
|
-
// Map counts back to parent records
|
|
140
|
-
const countMap = new Map<unknown, number>();
|
|
141
|
-
for (const row of result) {
|
|
142
|
-
countMap.set(row.__fk, Number(row.__count ?? 0));
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
for (const record of records) {
|
|
146
|
-
const pkValue = record[parentPk];
|
|
147
|
-
const cnt = countMap.get(pkValue) ?? 0;
|
|
148
|
-
(record._count as Record<string, number>)[rel.name] = cnt;
|
|
149
|
-
}
|
|
150
|
-
})
|
|
151
|
-
);
|
|
152
|
-
}
|
package/src/errors.ts
DELETED
|
@@ -1,460 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* VibeORM Error Hierarchy
|
|
3
|
-
*
|
|
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.
|
|
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) ─────────────────────
|
|
147
|
-
|
|
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 {
|
|
157
|
-
readonly model: string;
|
|
158
|
-
readonly operation: string;
|
|
159
|
-
readonly direction: "input" | "output";
|
|
160
|
-
readonly zodError: unknown;
|
|
161
|
-
|
|
162
|
-
constructor(params: {
|
|
163
|
-
model: string;
|
|
164
|
-
operation: string;
|
|
165
|
-
direction: "input" | "output";
|
|
166
|
-
zodError: unknown;
|
|
167
|
-
}) {
|
|
168
|
-
const { model, operation, direction, zodError } = params;
|
|
169
|
-
const msg = `Validation failed for ${model}.${operation} (${direction}): ${formatZodError({ error: zodError })}`;
|
|
170
|
-
super({
|
|
171
|
-
code: "VALIDATION_ERROR",
|
|
172
|
-
message: msg,
|
|
173
|
-
meta: { model, operation, direction, zodError },
|
|
174
|
-
});
|
|
175
|
-
this.name = "VibeValidationError";
|
|
176
|
-
this.model = model;
|
|
177
|
-
this.operation = operation;
|
|
178
|
-
this.direction = direction;
|
|
179
|
-
this.zodError = zodError;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
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
|
-
|
|
453
|
-
function formatZodError(params: { error: unknown }): string {
|
|
454
|
-
const { error } = params;
|
|
455
|
-
if (error && typeof error === "object" && "issues" in error) {
|
|
456
|
-
const issues = (error as { issues: Array<{ path: (string | number)[]; message: string }> }).issues;
|
|
457
|
-
return issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
458
|
-
}
|
|
459
|
-
return String(error);
|
|
460
|
-
}
|