@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibeorm/runtime",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Driver-agnostic query engine and client runtime for VibeORM",
5
5
  "license": "MIT",
6
6
  "keywords": [
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
- if (bigintNames.length === 0) return;
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, modelHasBigInt } from "./coerce.ts";
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 BigInt
681
- // coercion (by looking at the related model's scalar fields). Wide schemas
682
- // with many includes thus pay nothing extra for relations whose child model
683
- // has no BigInt fields.
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 modelHasBigInt(relatedModelMeta) ? relatedModelMeta : null;
688
+ return modelNeedsCoercion(relatedModelMeta) ? relatedModelMeta : null;
689
689
  });
690
690
 
691
691
  const results: Record<string, unknown>[] = [];