@vibeorm/runtime 1.1.8 → 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.1.8",
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/client.ts CHANGED
@@ -38,6 +38,8 @@ import {
38
38
  import { generateDefault } from "./id-generators.ts";
39
39
  import { createView } from "./view.ts";
40
40
  import type { ViewDefinition } from "./view.ts";
41
+ import { coerceFieldTypes } from "./coerce.ts";
42
+ import { resolveCountSpec, loadRelationCounts } from "./count-loader.ts";
41
43
 
42
44
  /**
43
45
  * Creates a VibeORM client instance.
@@ -181,14 +183,54 @@ export function createClient(params: {
181
183
  }
182
184
  }
183
185
 
184
- // Create delegate for a model
186
+ // Build a one-off implicit transaction for a single write operation that
187
+ // has nested relation ops (Bug #8). Mirrors the executor-wrapping pattern
188
+ // used in `client.$transaction`'s callback path — same error normalisation,
189
+ // same PgArray formatting, same logging.
190
+ //
191
+ // The caller passes a `runFn` that takes the transactional executor and
192
+ // performs all writes; we then commit and return the result.
193
+ async function runInImplicitTx<T>(implicitParams: {
194
+ runFn: (txExec: typeof executeSql) => Promise<T>;
195
+ }): Promise<T> {
196
+ const { runFn } = implicitParams;
197
+ try {
198
+ return await adapter.transaction(async (txAdapter) => {
199
+ async function txExecutor(execParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
200
+ const values = execParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
201
+ if (shouldLog) {
202
+ console.log(`[vibeorm:tx-implicit] ${execParams.text}`);
203
+ if (values.length > 0) console.log(`[vibeorm:tx-implicit] params:`, values);
204
+ }
205
+ try {
206
+ return await txAdapter.execute({ text: execParams.text, values });
207
+ } catch (err) {
208
+ throw normalizeError({ error: err });
209
+ }
210
+ }
211
+ return runFn(txExecutor);
212
+ });
213
+ } catch (err) {
214
+ throw normalizeError({ error: err });
215
+ }
216
+ }
217
+
218
+ // Create delegate for a model.
219
+ //
220
+ // `inTransaction` (default false) tells the delegate it's running inside
221
+ // an outer `$transaction(...)` callback. When true, write operations that
222
+ // contain nested relation ops skip their implicit-transaction wrap (Bug #8 —
223
+ // mirror Prisma behaviour) because the caller's `tx` already provides
224
+ // atomicity. Without this flag, we'd open a SAVEPOINT on the in-tx
225
+ // connection for every nested-write, which is correct but wasteful.
185
226
  function createDelegate(params: {
186
227
  modelKey: string;
187
228
  modelMeta: ModelMeta;
188
229
  executor: typeof executeSql;
189
230
  schemas?: ModelSchemas;
231
+ inTransaction?: boolean;
190
232
  }): Record<string, Function> {
191
- const { modelKey, modelMeta, executor, schemas } = params;
233
+ const { modelKey, modelMeta, executor, schemas, inTransaction = false } = params;
192
234
 
193
235
  // Resolve validation mode from options
194
236
  const validateOpt = options?.validate;
@@ -517,43 +559,62 @@ export function createClient(params: {
517
559
  injectUpdatedAt({ data, modelMeta });
518
560
  injectAutoDefaults({ data, modelMeta, idGenerator: options?.idGenerator });
519
561
 
520
- const { processedData, deferredCreates } = await processNestedCreates({
521
- data,
522
- modelMeta,
523
- allModelsMeta,
524
- executor,
525
- });
562
+ // Bug #8: when the write has any nested relation ops, do the whole
563
+ // INSERT + nested-children sequence inside a single transaction so
564
+ // partial failures roll back atomically (Prisma parity). Skip the
565
+ // wrap when we're already inside a caller-supplied transaction.
566
+ const hasAnyNestedRelation = hasNestedRelationOps({ data, modelMeta });
567
+ const needsTxWrap = hasAnyNestedRelation && !inTransaction;
526
568
 
527
- const query = buildInsertQuery({
528
- modelMeta,
529
- data: processedData,
530
- args: deferredCreates.length === 0 ? args : undefined,
531
- });
532
- let records = await executor(query);
569
+ const runCreate = async (txExec: typeof executor): Promise<Record<string, unknown>[]> => {
570
+ const { processedData, deferredCreates } = await processNestedCreates({
571
+ data,
572
+ modelMeta,
573
+ allModelsMeta,
574
+ executor: txExec,
575
+ });
533
576
 
534
- // Execute deferred creates (related records that hold the FK to this parent)
535
- // Group by related model to batch multiple creates into single INSERT statements.
536
- const parentRecord = records[0];
537
- if (parentRecord && deferredCreates.length > 0) {
538
- for (const deferred of deferredCreates) {
539
- const parentRefValue = parentRecord[deferred.parentRefField];
540
- if (parentRefValue != null) {
541
- const createItems = Array.isArray(deferred.createData) ? deferred.createData : [deferred.createData];
542
- const dataArray = createItems.map((createData) => {
543
- (createData as Record<string, unknown>)[deferred.fkField] = parentRefValue;
544
- return createData as Record<string, unknown>;
545
- });
546
- if (dataArray.length === 1) {
547
- const insertQuery = buildInsertQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray[0]! });
548
- await executor(insertQuery);
549
- } else if (dataArray.length > 1) {
550
- const insertQuery = buildInsertManyQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray, returning: false });
551
- await executor(insertQuery);
577
+ const query = buildInsertQuery({
578
+ modelMeta,
579
+ data: processedData,
580
+ args: deferredCreates.length === 0 ? args : undefined,
581
+ });
582
+ const parentRows = await txExec(query);
583
+
584
+ const parentRecord = parentRows[0];
585
+ if (parentRecord && deferredCreates.length > 0) {
586
+ for (const deferred of deferredCreates) {
587
+ const parentRefValue = parentRecord[deferred.parentRefField];
588
+ if (parentRefValue != null) {
589
+ const createItems = Array.isArray(deferred.createData) ? deferred.createData : [deferred.createData];
590
+ const dataArray = createItems.map((createData) => {
591
+ (createData as Record<string, unknown>)[deferred.fkField] = parentRefValue;
592
+ return createData as Record<string, unknown>;
593
+ });
594
+ if (dataArray.length === 1) {
595
+ const insertQuery = buildInsertQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray[0]! });
596
+ await txExec(insertQuery);
597
+ } else if (dataArray.length > 1) {
598
+ const insertQuery = buildInsertManyQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray, returning: false });
599
+ await txExec(insertQuery);
600
+ }
552
601
  }
553
602
  }
554
603
  }
604
+
605
+ return parentRows;
606
+ };
607
+
608
+ let records: Record<string, unknown>[];
609
+ if (needsTxWrap) {
610
+ records = await runInImplicitTx({ runFn: runCreate });
611
+ } else {
612
+ records = await runCreate(executor);
555
613
  }
556
614
 
615
+ // Keep relation-loading + select-filtering + output validation OUTSIDE
616
+ // the transaction so we don't hold a connection longer than the
617
+ // actual write needs it.
557
618
  records = await loadRelationsForStrategy({
558
619
  records,
559
620
  modelMeta,
@@ -589,46 +650,57 @@ export function createClient(params: {
589
650
  modelMeta,
590
651
  });
591
652
 
592
- const query = buildUpdateQuery({
593
- modelMeta,
594
- allModelsMeta,
595
- where: whereInput,
596
- data: scalarData,
597
- args: nestedOps.length === 0 ? args : undefined,
598
- });
599
- let records = await executor(query);
600
-
601
- if (records.length === 0) {
602
- throw new VibeRequestError({
603
- code: "NOT_FOUND",
604
- message: `An operation failed because it depends on one or more records that were required but not found. No ${modelMeta.name} found for the given where clause.`,
605
- meta: { model: modelMeta.name, operation: "update" },
606
- });
607
- }
653
+ // Bug #8: wrap UPDATE + nested ops in a single transaction so partial
654
+ // failures roll back atomically. Skip when already inside a caller tx.
655
+ const needsTxWrap = nestedOps.length > 0 && !inTransaction;
608
656
 
609
- const updatedRecord = records[0]!;
610
-
611
- // Process nested relation operations
612
- if (nestedOps.length > 0) {
613
- await processNestedUpdateOps({
614
- parentRecord: updatedRecord,
615
- nestedOps,
657
+ const runUpdate = async (txExec: typeof executor): Promise<Record<string, unknown>[]> => {
658
+ const query = buildUpdateQuery({
616
659
  modelMeta,
617
660
  allModelsMeta,
618
- executor,
661
+ where: whereInput,
662
+ data: scalarData,
663
+ args: nestedOps.length === 0 ? args : undefined,
619
664
  });
665
+ let updateRows = await txExec(query);
620
666
 
621
- // Re-read the parent record after nested ops to reflect FK changes
622
- // (e.g., connect/disconnect may have updated FK columns)
623
- const refreshQuery = buildSelectQuery({
624
- modelMeta,
625
- allModelsMeta,
626
- args: { where: whereInput, take: 1 },
627
- });
628
- const refreshed = await executor(refreshQuery);
629
- if (refreshed.length > 0) {
630
- records = refreshed;
667
+ if (updateRows.length === 0) {
668
+ throw new VibeRequestError({
669
+ code: "NOT_FOUND",
670
+ message: `An operation failed because it depends on one or more records that were required but not found. No ${modelMeta.name} found for the given where clause.`,
671
+ meta: { model: modelMeta.name, operation: "update" },
672
+ });
673
+ }
674
+
675
+ if (nestedOps.length > 0) {
676
+ await processNestedUpdateOps({
677
+ parentRecord: updateRows[0]!,
678
+ nestedOps,
679
+ modelMeta,
680
+ allModelsMeta,
681
+ executor: txExec,
682
+ });
683
+
684
+ // Re-read the parent record after nested ops to reflect FK changes
685
+ // (e.g., connect/disconnect may have updated FK columns).
686
+ const refreshQuery = buildSelectQuery({
687
+ modelMeta,
688
+ allModelsMeta,
689
+ args: { where: whereInput, take: 1 },
690
+ });
691
+ const refreshed = await txExec(refreshQuery);
692
+ if (refreshed.length > 0) {
693
+ updateRows = refreshed;
694
+ }
631
695
  }
696
+ return updateRows;
697
+ };
698
+
699
+ let records: Record<string, unknown>[];
700
+ if (needsTxWrap) {
701
+ records = await runInImplicitTx({ runFn: runUpdate });
702
+ } else {
703
+ records = await runUpdate(executor);
632
704
  }
633
705
 
634
706
  records = await loadRelationsForStrategy({
@@ -1059,75 +1131,107 @@ export function createClient(params: {
1059
1131
  const fn = fnOrPromises as (tx: Record<string, unknown>) => Promise<T>;
1060
1132
  try {
1061
1133
  return await adapter.transaction(async (txAdapter) => {
1062
- // Create a transactional executor — normalizes DB errors
1063
- async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
1064
- const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
1065
- if (shouldLog) {
1066
- console.log(`[vibeorm:tx] ${txParams.text}`);
1067
- if (values.length > 0) {
1068
- console.log(`[vibeorm:tx] params:`, values);
1069
- }
1070
- }
1071
- try {
1072
- return await txAdapter.execute({ text: txParams.text, values });
1073
- } catch (err) {
1074
- throw normalizeError({ error: err });
1075
- }
1076
- }
1077
-
1078
- // Build transactional delegates
1079
- const txClient: Record<string, unknown> = {};
1080
- for (const [key, meta] of Object.entries(allModelsMeta)) {
1081
- txClient[key] = createDelegate({
1082
- modelKey: key,
1083
- modelMeta: meta,
1084
- executor: txExecutor,
1085
- schemas: params.schemas?.[key],
1086
- });
1087
- }
1088
-
1089
- // Add $queryRaw and $executeRaw to transactional client
1090
- txClient.$queryRaw = async function <T = unknown>(
1091
- strings: TemplateStringsArray,
1092
- ...values: unknown[]
1093
- ): Promise<T[]> {
1094
- const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1095
- if (shouldLog) {
1096
- console.log(`[vibeorm:tx] ${text}`);
1097
- if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1098
- }
1099
- try {
1100
- const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1101
- return result.rows as T[];
1102
- } catch (err) {
1103
- throw normalizeError({ error: err });
1104
- }
1105
- };
1106
-
1107
- txClient.$executeRaw = async function (
1108
- strings: TemplateStringsArray,
1109
- ...values: unknown[]
1110
- ): Promise<number> {
1111
- const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1112
- if (shouldLog) {
1113
- console.log(`[vibeorm:tx] ${text}`);
1114
- if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1115
- }
1116
- try {
1117
- const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1118
- return result.affectedRows;
1119
- } catch (err) {
1120
- throw normalizeError({ error: err });
1121
- }
1122
- };
1123
-
1124
- return fn(txClient);
1134
+ return fn(buildTxClient({ txAdapter }));
1125
1135
  }, txOptions);
1126
1136
  } catch (err) {
1127
1137
  throw normalizeError({ error: err });
1128
1138
  }
1129
1139
  };
1130
1140
 
1141
+ /**
1142
+ * Build a transactional client backed by the given txAdapter. Recursive:
1143
+ * the returned `$transaction` method opens a nested transaction (which
1144
+ * the adapters realise as a SAVEPOINT) and rebuilds the same shape over
1145
+ * the resulting innerTxAdapter, so arbitrarily-deep nesting works.
1146
+ *
1147
+ * Used by both the top-level `db.$transaction` callback path and by the
1148
+ * `$transaction` exposed on tx clients (Bug #3 — nested transactions on
1149
+ * the bun adapter previously crashed because they tried to call
1150
+ * `sql.begin()` on a reserved connection).
1151
+ */
1152
+ function buildTxClient(builderParams: { txAdapter: DatabaseAdapter }): Record<string, unknown> {
1153
+ const { txAdapter } = builderParams;
1154
+
1155
+ async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
1156
+ const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
1157
+ if (shouldLog) {
1158
+ console.log(`[vibeorm:tx] ${txParams.text}`);
1159
+ if (values.length > 0) {
1160
+ console.log(`[vibeorm:tx] params:`, values);
1161
+ }
1162
+ }
1163
+ try {
1164
+ return await txAdapter.execute({ text: txParams.text, values });
1165
+ } catch (err) {
1166
+ throw normalizeError({ error: err });
1167
+ }
1168
+ }
1169
+
1170
+ const txClient: Record<string, unknown> = {};
1171
+ for (const [key, meta] of Object.entries(allModelsMeta)) {
1172
+ txClient[key] = createDelegate({
1173
+ modelKey: key,
1174
+ modelMeta: meta,
1175
+ executor: txExecutor,
1176
+ schemas: params.schemas?.[key],
1177
+ inTransaction: true,
1178
+ });
1179
+ }
1180
+
1181
+ txClient.$queryRaw = async function <T = unknown>(
1182
+ strings: TemplateStringsArray,
1183
+ ...values: unknown[]
1184
+ ): Promise<T[]> {
1185
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1186
+ if (shouldLog) {
1187
+ console.log(`[vibeorm:tx] ${text}`);
1188
+ if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1189
+ }
1190
+ try {
1191
+ const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1192
+ return result.rows as T[];
1193
+ } catch (err) {
1194
+ throw normalizeError({ error: err });
1195
+ }
1196
+ };
1197
+
1198
+ txClient.$executeRaw = async function (
1199
+ strings: TemplateStringsArray,
1200
+ ...values: unknown[]
1201
+ ): Promise<number> {
1202
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1203
+ if (shouldLog) {
1204
+ console.log(`[vibeorm:tx] ${text}`);
1205
+ if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1206
+ }
1207
+ try {
1208
+ const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1209
+ return result.affectedRows;
1210
+ } catch (err) {
1211
+ throw normalizeError({ error: err });
1212
+ }
1213
+ };
1214
+
1215
+ // Nested $transaction → SAVEPOINT through the adapter contract.
1216
+ // The adapter implementation (bun, pg) knows whether the current source
1217
+ // is a pool, a reserved connection, or an in-tx handle, and picks
1218
+ // BEGIN/COMMIT vs SAVEPOINT/RELEASE accordingly.
1219
+ txClient.$transaction = async function <U>(
1220
+ nestedFn: (tx: Record<string, unknown>) => Promise<U>,
1221
+ nestedOptions?: TransactionOptions
1222
+ ): Promise<U> {
1223
+ try {
1224
+ return await txAdapter.transaction(async (innerTxAdapter) => {
1225
+ return nestedFn(buildTxClient({ txAdapter: innerTxAdapter }));
1226
+ }, nestedOptions);
1227
+ } catch (err) {
1228
+ throw normalizeError({ error: err });
1229
+ }
1230
+ };
1231
+
1232
+ return txClient;
1233
+ }
1234
+
1131
1235
  // $queryRaw — tagged template literal for safe parameterized queries
1132
1236
  client.$queryRaw = async function <T = unknown>(
1133
1237
  strings: TemplateStringsArray,
@@ -1168,7 +1272,12 @@ export function createClient(params: {
1168
1272
  }
1169
1273
  };
1170
1274
 
1171
- // $queryRawUnsafe — accepts a plain SQL string + params array
1275
+ // $queryRawUnsafe — accepts a plain SQL string + params array.
1276
+ // Routed through `executeUnsafe` (NOT `execute`) so that adapters know this
1277
+ // is dynamic raw SQL and can skip prepared-statement caching. This also lets
1278
+ // adapters apply raw-query-only param normalisation (e.g. bun:sql converting
1279
+ // plain JS array params to PG array literals for `= ANY($N)`) without
1280
+ // affecting the ORM hot path.
1172
1281
  client.$queryRawUnsafe = async function <T = unknown>(
1173
1282
  query: string,
1174
1283
  ...values: unknown[]
@@ -1180,12 +1289,11 @@ export function createClient(params: {
1180
1289
  }
1181
1290
  }
1182
1291
  try {
1183
- if (values.length === 0) {
1184
- const result = await adapter.executeUnsafe({ text: query });
1185
- return result.rows as T[];
1186
- }
1187
- const rows = await adapter.execute({ text: query, values });
1188
- return rows as T[];
1292
+ const result = await adapter.executeUnsafe({
1293
+ text: query,
1294
+ values: values.length > 0 ? values : undefined,
1295
+ });
1296
+ return result.rows as T[];
1189
1297
  } catch (err) {
1190
1298
  throw normalizeError({ error: err });
1191
1299
  }
@@ -1247,39 +1355,6 @@ export function createClient(params: {
1247
1355
  return client;
1248
1356
  }
1249
1357
 
1250
- /**
1251
- * Coerce scalar field values to their correct JS types.
1252
- * Currently handles BigInt fields: bun:sql returns PostgreSQL bigint as string,
1253
- * but the application expects native BigInt values.
1254
- */
1255
- function coerceFieldTypes(params: {
1256
- records: Record<string, unknown>[];
1257
- modelMeta: ModelMeta;
1258
- }): Record<string, unknown>[] {
1259
- const { records, modelMeta } = params;
1260
- if (records.length === 0) return records;
1261
-
1262
- // Find fields that need coercion
1263
- const bigintFields = modelMeta.scalarFields.filter(
1264
- (f) => (f as { type?: string }).type === "BigInt"
1265
- );
1266
-
1267
- if (bigintFields.length === 0) return records;
1268
-
1269
- for (const record of records) {
1270
- for (const field of bigintFields) {
1271
- const val = record[field.name];
1272
- if (typeof val === "string") {
1273
- record[field.name] = BigInt(val);
1274
- } else if (typeof val === "number") {
1275
- record[field.name] = BigInt(val);
1276
- }
1277
- }
1278
- }
1279
-
1280
- return records;
1281
- }
1282
-
1283
1358
  /**
1284
1359
  * Apply select filtering to returned records.
1285
1360
  * When args.select is specified, strips fields not in the select object
@@ -1499,137 +1574,35 @@ function parseAggregateResult(params: {
1499
1574
  return result;
1500
1575
  }
1501
1576
 
1502
- /**
1503
- * Resolve _count specification from include or select args.
1504
- * Returns the list of relation names to count, or null if _count not requested.
1505
- */
1506
- function resolveCountSpec(params: { args: Record<string, unknown> }): string[] | null {
1507
- const { args } = params;
1508
- const include = args.include as Record<string, unknown> | undefined;
1509
- const select = args.select as Record<string, unknown> | undefined;
1510
-
1511
- const countArg = include?._count ?? select?._count;
1512
- if (!countArg) return null;
1513
-
1514
- if (countArg === true) {
1515
- // Count all list relations — will be resolved by loadRelationCounts
1516
- return ["__all__"];
1517
- }
1518
-
1519
- if (typeof countArg === "object" && countArg !== null) {
1520
- const countObj = countArg as Record<string, unknown>;
1521
- const selectObj = countObj.select as Record<string, boolean> | undefined;
1522
- if (selectObj) {
1523
- return Object.entries(selectObj)
1524
- .filter(([_, enabled]) => enabled)
1525
- .map(([name]) => name);
1526
- }
1527
- }
1528
-
1529
- return null;
1530
- }
1577
+ type NestedOp = {
1578
+ relationField: ModelMeta["relationFields"][number];
1579
+ ops: Record<string, unknown>;
1580
+ };
1531
1581
 
1532
1582
  /**
1533
- * Load relation counts and attach _count object to each record.
1534
- * Uses COUNT subqueries grouped by parent FK.
1583
+ * Cheap upfront detection: does this write `data` object contain any keys
1584
+ * that correspond to a relation field on the model? Used by `create` (and
1585
+ * by analogy, fits any nested-write op detection) to decide whether to wrap
1586
+ * the operation in an implicit transaction (Bug #8).
1587
+ *
1588
+ * Doesn't try to verify the nested value is a well-formed `{ create | connect
1589
+ * | … }` object — `processNestedCreates` does that validation while running.
1590
+ * The conservative over-wrap (e.g. user passes `{ relationName: undefined }`)
1591
+ * is cheap and still correct.
1535
1592
  */
1536
- async function loadRelationCounts(params: {
1537
- records: Record<string, unknown>[];
1593
+ function hasNestedRelationOps(params: {
1594
+ data: Record<string, unknown>;
1538
1595
  modelMeta: ModelMeta;
1539
- allModelsMeta: ModelMetaMap;
1540
- countSpec: string[];
1541
- executor: (params: { text: string; values: unknown[] }) => Promise<Record<string, unknown>[]>;
1542
- }): Promise<void> {
1543
- const { records, modelMeta, allModelsMeta, countSpec, executor } = params;
1544
- const modelMap = getModelByNameMap({ allModelsMeta });
1545
- const parentPk = modelMeta.primaryKey[0];
1546
- if (!parentPk) return;
1547
-
1548
- const parentIds = records.map((r) => r[parentPk]).filter((id) => id != null);
1549
- if (parentIds.length === 0) return;
1550
-
1551
- // Resolve which relations to count
1552
- const listRelations = modelMeta.relationFields.filter((r) => r.isList);
1553
- const relationsToCount = countSpec.includes("__all__")
1554
- ? listRelations
1555
- : listRelations.filter((r) => countSpec.includes(r.name));
1556
-
1557
- // Initialize _count on all records
1558
- for (const record of records) {
1559
- const countObj: Record<string, number> = {};
1560
- for (const rel of relationsToCount) {
1561
- countObj[rel.name] = 0;
1562
- }
1563
- record._count = countObj;
1596
+ }): boolean {
1597
+ const { data, modelMeta } = params;
1598
+ for (const rf of modelMeta.relationFields) {
1599
+ const v = data[rf.name];
1600
+ if (v === undefined) continue;
1601
+ if (v !== null && typeof v === "object") return true;
1564
1602
  }
1565
-
1566
- // Run all relation COUNT queries in parallel — each hits a different table
1567
- // so there are no data races on the parent records.
1568
- await Promise.all(
1569
- relationsToCount.map(async (rel) => {
1570
- const relatedModelMeta = modelMap.get(rel.relatedModel);
1571
- if (!relatedModelMeta) return;
1572
-
1573
- // M:N relation: count via join table
1574
- if (rel.type === "manyToMany" && (rel as { joinTable?: string }).joinTable) {
1575
- const joinTableName = (rel as { joinTable?: string }).joinTable!;
1576
- const sorted = [modelMeta.name, relatedModelMeta.name].sort();
1577
- const parentIsA = modelMeta.name === sorted[0];
1578
- const parentCol = parentIsA ? "A" : "B";
1579
-
1580
- const text = `SELECT "${joinTableName}"."${parentCol}" AS "__fk", COUNT(*) AS "__count" FROM "${joinTableName}" WHERE "${joinTableName}"."${parentCol}" = ANY($1) GROUP BY "${joinTableName}"."${parentCol}"`;
1581
- const result = await executor({ text, values: [new PgArray(parentIds)] });
1582
-
1583
- const countMap = new Map<unknown, number>();
1584
- for (const row of result) {
1585
- countMap.set(row.__fk, Number(row.__count ?? 0));
1586
- }
1587
-
1588
- for (const record of records) {
1589
- const pkValue = record[parentPk];
1590
- const cnt = countMap.get(pkValue) ?? 0;
1591
- (record._count as Record<string, number>)[rel.name] = cnt;
1592
- }
1593
- return;
1594
- }
1595
-
1596
- // Find the FK column on the related model (with relationName disambiguation)
1597
- const reverseRel = relatedModelMeta.relationFields.find(
1598
- (r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0 &&
1599
- (!rel.relationName || r.relationName === rel.relationName)
1600
- );
1601
- if (!reverseRel) return;
1602
-
1603
- const fkField = reverseRel.fields[0]!;
1604
- const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
1605
- const fkScalar = relatedSfMap.get(fkField);
1606
- const fkDbName = fkScalar?.dbName ?? fkField;
1607
- const relatedTable = `"${relatedModelMeta.dbName}"`;
1608
-
1609
- // Build: SELECT "fk" AS "__fk", COUNT(*) AS "__count" FROM "related" WHERE "fk" = ANY($1) GROUP BY "fk"
1610
- const text = `SELECT ${relatedTable}."${fkDbName}" AS "__fk", COUNT(*) AS "__count" FROM ${relatedTable} WHERE ${relatedTable}."${fkDbName}" = ANY($1) GROUP BY ${relatedTable}."${fkDbName}"`;
1611
- const result = await executor({ text, values: [new PgArray(parentIds)] });
1612
-
1613
- // Map counts back to parent records
1614
- const countMap = new Map<unknown, number>();
1615
- for (const row of result) {
1616
- countMap.set(row.__fk, Number(row.__count ?? 0));
1617
- }
1618
-
1619
- for (const record of records) {
1620
- const pkValue = record[parentPk];
1621
- const cnt = countMap.get(pkValue) ?? 0;
1622
- (record._count as Record<string, number>)[rel.name] = cnt;
1623
- }
1624
- })
1625
- );
1603
+ return false;
1626
1604
  }
1627
1605
 
1628
- type NestedOp = {
1629
- relationField: ModelMeta["relationFields"][number];
1630
- ops: Record<string, unknown>;
1631
- };
1632
-
1633
1606
  /**
1634
1607
  * Separate scalar data from nested relation operations in update data.
1635
1608
  */
package/src/coerce.ts ADDED
@@ -0,0 +1,184 @@
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
+ * 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
+
123
+ /**
124
+ * Coerce scalar field values on the given records to their JS-native types.
125
+ *
126
+ * Today only `BigInt` fields are affected. Driver behaviour:
127
+ * - `bun:sql` returns PG `bigint` as a JS `string`.
128
+ * - `node-postgres` returns PG `bigint` as a JS `string` by default too.
129
+ * - In both cases the Prisma type is `BigInt`, so we coerce.
130
+ *
131
+ * No-op when the model has no `BigInt` fields, or when `records` is empty.
132
+ */
133
+ export function coerceFieldTypes(params: {
134
+ records: Record<string, unknown>[];
135
+ modelMeta: ModelMeta;
136
+ }): void {
137
+ const { records, modelMeta } = params;
138
+ if (records.length === 0) return;
139
+
140
+ const bigintNames = getBigIntFieldNames(modelMeta);
141
+ const enumListNames = getEnumListFieldNames(modelMeta);
142
+ if (bigintNames.length === 0 && enumListNames.length === 0) return;
143
+
144
+ for (const record of records) {
145
+ for (const name of bigintNames) {
146
+ const val = record[name];
147
+ if (typeof val === "string") {
148
+ record[name] = BigInt(val);
149
+ } else if (typeof val === "number") {
150
+ record[name] = BigInt(val);
151
+ }
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
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * True iff the model has any `BigInt` fields that require coercion.
168
+ * Cheap O(1) lookup after first call per model. Hot-path callers (lateral-join
169
+ * builder, relation loaders) use this to skip the per-row coercion loop
170
+ * entirely for relations whose related model has no `BigInt` columns.
171
+ */
172
+ export function modelHasBigInt(modelMeta: ModelMeta): boolean {
173
+ return getBigIntFieldNames(modelMeta).length > 0;
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
+ }
@@ -0,0 +1,152 @@
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 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,6 +49,8 @@ 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, modelNeedsCoercion } from "./coerce.ts";
53
+ import { resolveCountSpec, loadRelationCounts } from "./count-loader.ts";
52
54
 
53
55
  type SqlExecutor = (params: {
54
56
  text: string;
@@ -452,12 +454,33 @@ export function buildLateralJoinQuery(params: {
452
454
  joinCondition = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
453
455
  }
454
456
 
457
+ // Nested where filter from include/select args — mirrors the to-many branch.
458
+ // Without this the join silently ignored `include: { author: { where: {...} } }`.
459
+ let nestedWhereSql = "";
460
+ if (nestedArgs.where) {
461
+ const relMetaWithAlias = {
462
+ ...relatedModelMeta,
463
+ dbName: "__rel",
464
+ } as typeof relatedModelMeta;
465
+ const nestedWhereResult = buildWhereClause({
466
+ where: nestedArgs.where as Record<string, unknown>,
467
+ modelMeta: relMetaWithAlias,
468
+ allModelsMeta,
469
+ paramOffset: paramIdx,
470
+ });
471
+ if (nestedWhereResult.sql) {
472
+ nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
473
+ paramIdx += nestedWhereResult.values.length;
474
+ allValues.push(...nestedWhereResult.values);
475
+ }
476
+ }
477
+
455
478
  lateralSubquery = `LEFT JOIN LATERAL (
456
479
  SELECT to_jsonb("__sub".*) AS "${colAlias}"
457
480
  FROM (
458
481
  SELECT ${columnAliases}
459
482
  FROM "${relatedModelMeta.dbName}" ${relatedAlias}
460
- WHERE ${joinCondition}
483
+ WHERE ${joinCondition}${nestedWhereSql}
461
484
  LIMIT 1
462
485
  ) "__sub"
463
486
  ) ${alias} ON true`;
@@ -647,8 +670,24 @@ export async function executeLateralJoinQuery(params: {
647
670
 
648
671
  const t2 = profiling ? performance.now() : 0;
649
672
 
650
- // Parse JSON relation columns
651
- const pkField = modelMeta.primaryKey[0]!;
673
+ // Parse JSON relation columns + apply BigInt coercion in a single pass.
674
+ //
675
+ // The "query" strategy coerces both parents (via `loadRelationsForStrategy`)
676
+ // and children (via the per-loader calls in `relation-loader.ts`). The
677
+ // lateral-join strategy bypasses both, so we do the equivalent work here
678
+ // (Bug #6).
679
+ //
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
+ const nestedModelMap = getModelByNameMap({ allModelsMeta });
685
+ const relationCoerceMeta = relationAliases.map(({ relationMeta }) => {
686
+ const relatedModelMeta = nestedModelMap.get(relationMeta.relatedModel);
687
+ if (!relatedModelMeta) return null;
688
+ return modelNeedsCoercion(relatedModelMeta) ? relatedModelMeta : null;
689
+ });
690
+
652
691
  const results: Record<string, unknown>[] = [];
653
692
 
654
693
  for (const row of rows) {
@@ -661,30 +700,44 @@ export async function executeLateralJoinQuery(params: {
661
700
  }
662
701
  }
663
702
 
664
- // Parse relation columns from JSON
665
- for (const { alias, relationMeta } of relationAliases) {
703
+ // Parse relation columns from JSON (and coerce inline if needed)
704
+ for (let i = 0; i < relationAliases.length; i++) {
705
+ const { alias, relationMeta } = relationAliases[i]!;
666
706
  const jsonValue = row[alias];
707
+ let parsed: unknown;
667
708
  if (jsonValue === null || jsonValue === undefined) {
668
- record[relationMeta.name] = relationMeta.isList ? [] : null;
709
+ parsed = relationMeta.isList ? [] : null;
669
710
  } else if (typeof jsonValue === "string") {
670
711
  try {
671
- record[relationMeta.name] = JSON.parse(jsonValue);
712
+ parsed = JSON.parse(jsonValue);
672
713
  } catch {
673
- record[relationMeta.name] = relationMeta.isList ? [] : null;
714
+ parsed = relationMeta.isList ? [] : null;
674
715
  }
675
716
  } else {
676
717
  // Already parsed by bun:sql (json/jsonb columns auto-parse)
677
- record[relationMeta.name] = jsonValue;
718
+ parsed = jsonValue;
719
+ }
720
+ record[relationMeta.name] = parsed;
721
+
722
+ const coerceMeta = relationCoerceMeta[i];
723
+ if (coerceMeta) {
724
+ if (Array.isArray(parsed)) {
725
+ coerceFieldTypes({ records: parsed as Record<string, unknown>[], modelMeta: coerceMeta });
726
+ } else if (parsed && typeof parsed === "object") {
727
+ coerceFieldTypes({ records: [parsed as Record<string, unknown>], modelMeta: coerceMeta });
728
+ }
678
729
  }
679
730
  }
680
731
 
681
732
  results.push(record);
682
733
  }
683
734
 
735
+ // Coerce parent records once (whole batch) after the parse loop.
736
+ coerceFieldTypes({ records: results, modelMeta });
737
+
684
738
  const t3 = profiling ? performance.now() : 0;
685
739
 
686
740
  // Recursively load nested relations (deeper levels fall back to batched)
687
- const nestedModelMap = getModelByNameMap({ allModelsMeta });
688
741
  await Promise.all(
689
742
  relationAliases
690
743
  .filter(({ nestedArgs }) => hasNestedRelations({ nestedArgs }))
@@ -713,6 +766,23 @@ export async function executeLateralJoinQuery(params: {
713
766
  })
714
767
  );
715
768
 
769
+ // ─── _count support (Bug #5) ─────────────────────────────────────
770
+ // The "query" strategy resolves `_count` after `loadRelations` (see
771
+ // `loadRelationsForStrategy` in client.ts). The lateral-join strategy
772
+ // historically returned BEFORE `_count` ran, silently dropping it whenever
773
+ // the user combined `include: { posts: true, _count: { … } }` with the
774
+ // join strategy. We mirror the query-strategy behaviour here.
775
+ const countSpec = resolveCountSpec({ args });
776
+ if (countSpec && results.length > 0) {
777
+ await loadRelationCounts({
778
+ records: results,
779
+ modelMeta,
780
+ allModelsMeta,
781
+ countSpec,
782
+ executor,
783
+ });
784
+ }
785
+
716
786
  // Populate profiling context with per-phase timings
717
787
  if (profilingCtx) {
718
788
  profilingCtx.queryBuildMs = t1 - t0;
@@ -19,6 +19,7 @@ import { getModelByNameMap, getScalarFieldMap, PgArray } from "./types.ts";
19
19
  import { buildRelationQuery, buildManyToManyQuery, buildSelectQuery } from "./query-builder.ts";
20
20
  import type { RelationSqlQuery } from "./query-builder.ts";
21
21
  import { buildWhereClause } from "./where-builder.ts";
22
+ import { coerceFieldTypes } from "./coerce.ts";
22
23
 
23
24
  type SqlExecutor = (params: {
24
25
  text: string;
@@ -242,6 +243,10 @@ async function loadToManyRelation(params: {
242
243
  grouped.get(fk)!.push(row);
243
244
  }
244
245
 
246
+ // Coerce scalar types on the loaded child records (e.g., BigInt from string).
247
+ // Mirrors what `loadRelationsForStrategy` does on parent records.
248
+ coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
249
+
245
250
  // Stitch onto parent records
246
251
  for (const parent of parentRecords) {
247
252
  const parentId = parent[pkField];
@@ -345,6 +350,9 @@ async function loadToOneWithFk(params: {
345
350
  byPk.set(pk, row);
346
351
  }
347
352
 
353
+ // Coerce scalar types on the loaded child records before stitching.
354
+ coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
355
+
348
356
  // Stitch
349
357
  for (const parent of parentRecords) {
350
358
  const fkValue = parent[fkFieldName];
@@ -411,6 +419,9 @@ async function loadToOneWithoutFk(params: {
411
419
  byFk.set(fk, row);
412
420
  }
413
421
 
422
+ // Coerce scalar types on the loaded child records before stitching.
423
+ coerceFieldTypes({ records: rows, modelMeta: relatedModelMeta });
424
+
414
425
  // Stitch
415
426
  for (const parent of parentRecords) {
416
427
  const parentId = parent[pkField];