@vibeorm/runtime 1.2.0 → 1.3.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/package.json +1 -1
- package/src/coerce.ts +101 -1
- package/src/errors.ts +32 -0
- package/src/index.ts +1 -0
- package/src/lateral-join-builder.ts +6 -6
package/package.json
CHANGED
package/src/coerce.ts
CHANGED
|
@@ -41,6 +41,85 @@ function getBigIntFieldNames(modelMeta: ModelMeta): readonly string[] {
|
|
|
41
41
|
return names;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Per-model cache of enum-array field names. Same WeakMap shape and
|
|
46
|
+
* lifetime guarantees as `_bigintFieldsCache`.
|
|
47
|
+
*
|
|
48
|
+
* Enum-array columns need post-processing because neither `bun:sql` nor
|
|
49
|
+
* `node-postgres` knows the user-defined enum's array type OID, so the
|
|
50
|
+
* driver returns the raw PG array literal as a string (e.g. `"{ADMIN,USER}"`)
|
|
51
|
+
* instead of a JS array. Built-in scalar arrays (`text[]`, `int4[]`, …) ARE
|
|
52
|
+
* parsed by both drivers because their OIDs are well-known.
|
|
53
|
+
*/
|
|
54
|
+
const _enumListFieldsCache = new WeakMap<
|
|
55
|
+
readonly ScalarFieldMeta[],
|
|
56
|
+
readonly string[]
|
|
57
|
+
>();
|
|
58
|
+
|
|
59
|
+
function getEnumListFieldNames(modelMeta: ModelMeta): readonly string[] {
|
|
60
|
+
const sf = modelMeta.scalarFields;
|
|
61
|
+
let names = _enumListFieldsCache.get(sf);
|
|
62
|
+
if (names) return names;
|
|
63
|
+
const arr: string[] = [];
|
|
64
|
+
for (const f of sf) {
|
|
65
|
+
if (f.kind === "enum" && f.isList === true) arr.push(f.name);
|
|
66
|
+
}
|
|
67
|
+
names = arr;
|
|
68
|
+
_enumListFieldsCache.set(sf, names);
|
|
69
|
+
return names;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse a PostgreSQL array literal string into a JS array of strings.
|
|
74
|
+
*
|
|
75
|
+
* Format: `{val1,val2,"quoted,val",NULL,...}` with `""` quoting only when an
|
|
76
|
+
* element contains commas, double-quotes, backslashes, or is the literal
|
|
77
|
+
* `NULL`. PG uses backslash-escaping inside quoted elements.
|
|
78
|
+
*
|
|
79
|
+
* Returns `[]` for `{}`. Returns the input unchanged if it doesn't look like
|
|
80
|
+
* an array literal (defensive — should never happen for an enum-array column).
|
|
81
|
+
*/
|
|
82
|
+
function parsePgArrayLiteral(literal: string): string[] | string {
|
|
83
|
+
if (literal.length < 2 || literal.charCodeAt(0) !== 123 /* { */) return literal;
|
|
84
|
+
if (literal === "{}") return [];
|
|
85
|
+
|
|
86
|
+
const out: string[] = [];
|
|
87
|
+
const inner = literal.slice(1, -1);
|
|
88
|
+
let i = 0;
|
|
89
|
+
const len = inner.length;
|
|
90
|
+
while (i < len) {
|
|
91
|
+
if (inner.charCodeAt(i) === 34 /* " */) {
|
|
92
|
+
// Quoted element — read until matching close-quote, honouring \\ and \"
|
|
93
|
+
let s = "";
|
|
94
|
+
i++;
|
|
95
|
+
while (i < len) {
|
|
96
|
+
const ch = inner.charCodeAt(i);
|
|
97
|
+
if (ch === 92 /* \ */) {
|
|
98
|
+
s += inner[i + 1] ?? "";
|
|
99
|
+
i += 2;
|
|
100
|
+
} else if (ch === 34) {
|
|
101
|
+
i++;
|
|
102
|
+
break;
|
|
103
|
+
} else {
|
|
104
|
+
s += inner[i];
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
out.push(s);
|
|
109
|
+
} else {
|
|
110
|
+
// Unquoted element — read until next comma or end
|
|
111
|
+
let s = "";
|
|
112
|
+
while (i < len && inner.charCodeAt(i) !== 44 /* , */) {
|
|
113
|
+
s += inner[i];
|
|
114
|
+
i++;
|
|
115
|
+
}
|
|
116
|
+
out.push(s);
|
|
117
|
+
}
|
|
118
|
+
if (i < len && inner.charCodeAt(i) === 44 /* , */) i++;
|
|
119
|
+
}
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
44
123
|
/**
|
|
45
124
|
* Coerce scalar field values on the given records to their JS-native types.
|
|
46
125
|
*
|
|
@@ -59,7 +138,8 @@ export function coerceFieldTypes(params: {
|
|
|
59
138
|
if (records.length === 0) return;
|
|
60
139
|
|
|
61
140
|
const bigintNames = getBigIntFieldNames(modelMeta);
|
|
62
|
-
|
|
141
|
+
const enumListNames = getEnumListFieldNames(modelMeta);
|
|
142
|
+
if (bigintNames.length === 0 && enumListNames.length === 0) return;
|
|
63
143
|
|
|
64
144
|
for (const record of records) {
|
|
65
145
|
for (const name of bigintNames) {
|
|
@@ -70,6 +150,16 @@ export function coerceFieldTypes(params: {
|
|
|
70
150
|
record[name] = BigInt(val);
|
|
71
151
|
}
|
|
72
152
|
}
|
|
153
|
+
// Enum-array fields arrive as raw PG array literal strings from both
|
|
154
|
+
// bun:sql and node-postgres (the driver doesn't know the user-defined
|
|
155
|
+
// enum's array OID). Parse them into JS string arrays. Bug 2 + Bug 3.
|
|
156
|
+
for (const name of enumListNames) {
|
|
157
|
+
const val = record[name];
|
|
158
|
+
if (typeof val === "string") {
|
|
159
|
+
const parsed = parsePgArrayLiteral(val);
|
|
160
|
+
if (Array.isArray(parsed)) record[name] = parsed;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
73
163
|
}
|
|
74
164
|
}
|
|
75
165
|
|
|
@@ -82,3 +172,13 @@ export function coerceFieldTypes(params: {
|
|
|
82
172
|
export function modelHasBigInt(modelMeta: ModelMeta): boolean {
|
|
83
173
|
return getBigIntFieldNames(modelMeta).length > 0;
|
|
84
174
|
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* True iff the model has any fields that need post-driver coercion
|
|
178
|
+
* (BigInt OR enum-array). Use this in place of `modelHasBigInt` whenever
|
|
179
|
+
* the fast-path skip would otherwise miss enum-array fields (Bug 2).
|
|
180
|
+
*/
|
|
181
|
+
export function modelNeedsCoercion(modelMeta: ModelMeta): boolean {
|
|
182
|
+
return getBigIntFieldNames(modelMeta).length > 0
|
|
183
|
+
|| getEnumListFieldNames(modelMeta).length > 0;
|
|
184
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -51,6 +51,7 @@ export type VibeRequestErrorCode =
|
|
|
51
51
|
| "FOREIGN_KEY_VIOLATION"
|
|
52
52
|
| "NOT_NULL_VIOLATION"
|
|
53
53
|
| "CHECK_CONSTRAINT"
|
|
54
|
+
| "VALUE_OUT_OF_RANGE"
|
|
54
55
|
| "NOT_FOUND"
|
|
55
56
|
| "VALIDATION_ERROR"
|
|
56
57
|
| "UNKNOWN_REQUEST_ERROR";
|
|
@@ -238,6 +239,16 @@ const CONSTRAINT_CODE_MAP: Record<string, VibeRequestErrorCode> = {
|
|
|
238
239
|
"23514": "CHECK_CONSTRAINT",
|
|
239
240
|
};
|
|
240
241
|
|
|
242
|
+
/**
|
|
243
|
+
* SQLSTATE Class 22 — data exception. We map the specific codes we want to
|
|
244
|
+
* surface as actionable errors. 22003 is the canonical "value out of range
|
|
245
|
+
* for type" code; the most common way users hit it is inserting a `BigInt`
|
|
246
|
+
* value larger than 2^63-1 into a `bigint` column (Bug 9).
|
|
247
|
+
*/
|
|
248
|
+
const DATA_EXCEPTION_CODE_MAP: Record<string, VibeRequestErrorCode> = {
|
|
249
|
+
"22003": "VALUE_OUT_OF_RANGE",
|
|
250
|
+
};
|
|
251
|
+
|
|
241
252
|
/**
|
|
242
253
|
* Extract a field name from a PostgreSQL detail string.
|
|
243
254
|
*
|
|
@@ -410,6 +421,27 @@ export function normalizeError(params: {
|
|
|
410
421
|
});
|
|
411
422
|
}
|
|
412
423
|
|
|
424
|
+
// Data exception (Class 22): e.g. value out of range for the column type.
|
|
425
|
+
// Specifically: 22003 is what users see when a BigInt value overflows the
|
|
426
|
+
// signed-int64 limit on a `bigint` column. We surface a targeted hint for
|
|
427
|
+
// that case because the raw PG message ("value out of range for type
|
|
428
|
+
// bigint") is easy to misread as a driver bug. Bug 9.
|
|
429
|
+
const dataExceptionCode = DATA_EXCEPTION_CODE_MAP[pgCode];
|
|
430
|
+
if (dataExceptionCode) {
|
|
431
|
+
const isBigIntRange =
|
|
432
|
+
dataExceptionCode === "VALUE_OUT_OF_RANGE" &&
|
|
433
|
+
/out of range for type bigint/i.test(pgErr.message);
|
|
434
|
+
const message = isBigIntRange
|
|
435
|
+
? `${pgErr.message} (PostgreSQL bigint is signed int64, max 0x7fffffffffffffff = 2^63-1. For unsigned 64-bit values, store as String or Decimal.)`
|
|
436
|
+
: pgErr.message;
|
|
437
|
+
return new VibeRequestError({
|
|
438
|
+
code: dataExceptionCode,
|
|
439
|
+
message,
|
|
440
|
+
meta,
|
|
441
|
+
cause,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
413
445
|
// Check transient by SQLSTATE class prefix
|
|
414
446
|
if (pgCode.startsWith("08") || pgCode.startsWith("40")) {
|
|
415
447
|
return new VibeTransientError({
|
package/src/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export { withRetry } from "./retry.ts";
|
|
|
24
24
|
export { ViewResult, createView } from "./view.ts";
|
|
25
25
|
export type { ViewDefinition } from "./view.ts";
|
|
26
26
|
export type { RetryOptions } from "./retry.ts";
|
|
27
|
+
export { PgArray } from "./types.ts";
|
|
27
28
|
|
|
28
29
|
export type {
|
|
29
30
|
DatabaseAdapter,
|
|
@@ -49,7 +49,7 @@ import { buildWhereClause } from "./where-builder.ts";
|
|
|
49
49
|
import { sanitizeDirection } from "./query-builder.ts";
|
|
50
50
|
import { loadRelations, resolveRelationsToLoad, hasNestedRelations } from "./relation-loader.ts";
|
|
51
51
|
import type { RelationToLoad } from "./relation-loader.ts";
|
|
52
|
-
import { coerceFieldTypes,
|
|
52
|
+
import { coerceFieldTypes, modelNeedsCoercion } from "./coerce.ts";
|
|
53
53
|
import { resolveCountSpec, loadRelationCounts } from "./count-loader.ts";
|
|
54
54
|
|
|
55
55
|
type SqlExecutor = (params: {
|
|
@@ -677,15 +677,15 @@ export async function executeLateralJoinQuery(params: {
|
|
|
677
677
|
// lateral-join strategy bypasses both, so we do the equivalent work here
|
|
678
678
|
// (Bug #6).
|
|
679
679
|
//
|
|
680
|
-
// Performance: pre-compute which relation aliases actually need
|
|
681
|
-
//
|
|
682
|
-
//
|
|
683
|
-
// has
|
|
680
|
+
// Performance: pre-compute which relation aliases actually need coercion
|
|
681
|
+
// (BigInt or enum-array — see `modelNeedsCoercion`). Wide schemas with
|
|
682
|
+
// many includes thus pay nothing extra for relations whose child model
|
|
683
|
+
// has neither.
|
|
684
684
|
const nestedModelMap = getModelByNameMap({ allModelsMeta });
|
|
685
685
|
const relationCoerceMeta = relationAliases.map(({ relationMeta }) => {
|
|
686
686
|
const relatedModelMeta = nestedModelMap.get(relationMeta.relatedModel);
|
|
687
687
|
if (!relatedModelMeta) return null;
|
|
688
|
-
return
|
|
688
|
+
return modelNeedsCoercion(relatedModelMeta) ? relatedModelMeta : null;
|
|
689
689
|
});
|
|
690
690
|
|
|
691
691
|
const results: Record<string, unknown>[] = [];
|