@vibeorm/runtime 1.1.0 → 1.1.2

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.0",
3
+ "version": "1.1.2",
4
4
  "description": "Driver-agnostic query engine and client runtime for VibeORM",
5
5
  "license": "MIT",
6
6
  "keywords": [
package/src/client.ts CHANGED
@@ -615,6 +615,18 @@ export function createClient(params: {
615
615
  allModelsMeta,
616
616
  executor,
617
617
  });
618
+
619
+ // Re-read the parent record after nested ops to reflect FK changes
620
+ // (e.g., connect/disconnect may have updated FK columns)
621
+ const refreshQuery = buildSelectQuery({
622
+ modelMeta,
623
+ allModelsMeta,
624
+ args: { where: whereInput, take: 1 },
625
+ });
626
+ const refreshed = await executor(refreshQuery);
627
+ if (refreshed.length > 0) {
628
+ records = refreshed;
629
+ }
618
630
  }
619
631
 
620
632
  records = await loadRelationsForStrategy({
@@ -1032,70 +1044,86 @@ export function createClient(params: {
1032
1044
  ): Promise<T | unknown[]> {
1033
1045
  // Array-of-promises style
1034
1046
  if (Array.isArray(fnOrPromises)) {
1035
- return adapter.transaction(async () => {
1036
- return Promise.all(fnOrPromises);
1037
- }, txOptions);
1047
+ try {
1048
+ return await adapter.transaction(async () => {
1049
+ return Promise.all(fnOrPromises);
1050
+ }, txOptions);
1051
+ } catch (err) {
1052
+ throw normalizeError({ error: err });
1053
+ }
1038
1054
  }
1039
1055
 
1040
1056
  // Callback style
1041
1057
  const fn = fnOrPromises as (tx: Record<string, unknown>) => Promise<T>;
1042
- return adapter.transaction(async (txAdapter) => {
1043
- // Create a transactional executor — normalizes DB errors
1044
- async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
1045
- const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
1046
- if (shouldLog) {
1047
- console.log(`[vibeorm:tx] ${txParams.text}`);
1048
- if (values.length > 0) {
1049
- console.log(`[vibeorm:tx] params:`, values);
1058
+ try {
1059
+ return await adapter.transaction(async (txAdapter) => {
1060
+ // Create a transactional executor normalizes DB errors
1061
+ async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
1062
+ const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
1063
+ if (shouldLog) {
1064
+ console.log(`[vibeorm:tx] ${txParams.text}`);
1065
+ if (values.length > 0) {
1066
+ console.log(`[vibeorm:tx] params:`, values);
1067
+ }
1068
+ }
1069
+ try {
1070
+ return await txAdapter.execute({ text: txParams.text, values });
1071
+ } catch (err) {
1072
+ throw normalizeError({ error: err });
1050
1073
  }
1051
1074
  }
1052
- try {
1053
- return await txAdapter.execute({ text: txParams.text, values });
1054
- } catch (err) {
1055
- throw normalizeError({ error: err });
1056
- }
1057
- }
1058
-
1059
- // Build transactional delegates
1060
- const txClient: Record<string, unknown> = {};
1061
- for (const [key, meta] of Object.entries(allModelsMeta)) {
1062
- txClient[key] = createDelegate({
1063
- modelKey: key,
1064
- modelMeta: meta,
1065
- executor: txExecutor,
1066
- schemas: params.schemas?.[key],
1067
- });
1068
- }
1069
1075
 
1070
- // Add $queryRaw and $executeRaw to transactional client
1071
- txClient.$queryRaw = async function <T = unknown>(
1072
- strings: TemplateStringsArray,
1073
- ...values: unknown[]
1074
- ): Promise<T[]> {
1075
- const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1076
- if (shouldLog) {
1077
- console.log(`[vibeorm:tx] ${text}`);
1078
- if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1076
+ // Build transactional delegates
1077
+ const txClient: Record<string, unknown> = {};
1078
+ for (const [key, meta] of Object.entries(allModelsMeta)) {
1079
+ txClient[key] = createDelegate({
1080
+ modelKey: key,
1081
+ modelMeta: meta,
1082
+ executor: txExecutor,
1083
+ schemas: params.schemas?.[key],
1084
+ });
1079
1085
  }
1080
- const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1081
- return result.rows as T[];
1082
- };
1083
1086
 
1084
- txClient.$executeRaw = async function (
1085
- strings: TemplateStringsArray,
1086
- ...values: unknown[]
1087
- ): Promise<number> {
1088
- const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1089
- if (shouldLog) {
1090
- console.log(`[vibeorm:tx] ${text}`);
1091
- if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1092
- }
1093
- const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1094
- return result.affectedRows;
1095
- };
1087
+ // Add $queryRaw and $executeRaw to transactional client
1088
+ txClient.$queryRaw = async function <T = unknown>(
1089
+ strings: TemplateStringsArray,
1090
+ ...values: unknown[]
1091
+ ): Promise<T[]> {
1092
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1093
+ if (shouldLog) {
1094
+ console.log(`[vibeorm:tx] ${text}`);
1095
+ if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1096
+ }
1097
+ try {
1098
+ const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1099
+ return result.rows as T[];
1100
+ } catch (err) {
1101
+ throw normalizeError({ error: err });
1102
+ }
1103
+ };
1096
1104
 
1097
- return fn(txClient);
1098
- }, txOptions);
1105
+ txClient.$executeRaw = async function (
1106
+ strings: TemplateStringsArray,
1107
+ ...values: unknown[]
1108
+ ): Promise<number> {
1109
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1110
+ if (shouldLog) {
1111
+ console.log(`[vibeorm:tx] ${text}`);
1112
+ if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1113
+ }
1114
+ try {
1115
+ const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1116
+ return result.affectedRows;
1117
+ } catch (err) {
1118
+ throw normalizeError({ error: err });
1119
+ }
1120
+ };
1121
+
1122
+ return fn(txClient);
1123
+ }, txOptions);
1124
+ } catch (err) {
1125
+ throw normalizeError({ error: err });
1126
+ }
1099
1127
  };
1100
1128
 
1101
1129
  // $queryRaw — tagged template literal for safe parameterized queries
@@ -1110,8 +1138,12 @@ export function createClient(params: {
1110
1138
  console.log(`[vibeorm:raw] params:`, sqlParams);
1111
1139
  }
1112
1140
  }
1113
- const result = await adapter.executeUnsafe({ text, values: sqlParams });
1114
- return result.rows as T[];
1141
+ try {
1142
+ const result = await adapter.executeUnsafe({ text, values: sqlParams });
1143
+ return result.rows as T[];
1144
+ } catch (err) {
1145
+ throw normalizeError({ error: err });
1146
+ }
1115
1147
  };
1116
1148
 
1117
1149
  // $executeRaw — tagged template literal for INSERT/UPDATE/DELETE returning affected count
@@ -1126,8 +1158,12 @@ export function createClient(params: {
1126
1158
  console.log(`[vibeorm:raw] params:`, sqlParams);
1127
1159
  }
1128
1160
  }
1129
- const result = await adapter.executeUnsafe({ text, values: sqlParams });
1130
- return result.affectedRows;
1161
+ try {
1162
+ const result = await adapter.executeUnsafe({ text, values: sqlParams });
1163
+ return result.affectedRows;
1164
+ } catch (err) {
1165
+ throw normalizeError({ error: err });
1166
+ }
1131
1167
  };
1132
1168
 
1133
1169
  // $queryRawUnsafe — accepts a plain SQL string + params array
@@ -1141,12 +1177,16 @@ export function createClient(params: {
1141
1177
  console.log(`[vibeorm:raw] params:`, values);
1142
1178
  }
1143
1179
  }
1144
- if (values.length === 0) {
1145
- const result = await adapter.executeUnsafe({ text: query });
1146
- return result.rows as T[];
1180
+ try {
1181
+ if (values.length === 0) {
1182
+ const result = await adapter.executeUnsafe({ text: query });
1183
+ return result.rows as T[];
1184
+ }
1185
+ const rows = await adapter.execute({ text: query, values });
1186
+ return rows as T[];
1187
+ } catch (err) {
1188
+ throw normalizeError({ error: err });
1147
1189
  }
1148
- const rows = await adapter.execute({ text: query, values });
1149
- return rows as T[];
1150
1190
  };
1151
1191
 
1152
1192
  // $executeRawUnsafe — accepts a plain SQL string + params array, returns affected count
@@ -1160,8 +1200,12 @@ export function createClient(params: {
1160
1200
  console.log(`[vibeorm:raw] params:`, values);
1161
1201
  }
1162
1202
  }
1163
- const result = await adapter.executeUnsafe({ text: query, values: values.length > 0 ? values : undefined });
1164
- return result.affectedRows;
1203
+ try {
1204
+ const result = await adapter.executeUnsafe({ text: query, values: values.length > 0 ? values : undefined });
1205
+ return result.affectedRows;
1206
+ } catch (err) {
1207
+ throw normalizeError({ error: err });
1208
+ }
1165
1209
  };
1166
1210
 
1167
1211
  // $connect
@@ -2073,6 +2117,35 @@ async function processNestedCreates(params: {
2073
2117
  }
2074
2118
  }
2075
2119
  }
2120
+
2121
+ if (ops.connectOrCreate) {
2122
+ // ConnectOrCreate: try to find existing, else create, then set FK on parent
2123
+ const relatedMeta = modelMap.get(relationField.relatedModel);
2124
+ if (relatedMeta) {
2125
+ const { where: corWhere, create: corCreate } = ops.connectOrCreate as {
2126
+ where: Record<string, unknown>;
2127
+ create: Record<string, unknown>;
2128
+ };
2129
+ // Try to find existing record
2130
+ const selectQuery = buildSelectQuery({
2131
+ modelMeta: relatedMeta,
2132
+ allModelsMeta,
2133
+ args: { where: corWhere, take: 1 },
2134
+ });
2135
+ const existingRows = await executor(selectQuery);
2136
+ if (existingRows.length > 0) {
2137
+ processedData[fkField] = existingRows[0]![refField];
2138
+ } else {
2139
+ // Create new record
2140
+ const insertQuery = buildInsertQuery({ modelMeta: relatedMeta, data: corCreate });
2141
+ const insertedRows = await executor(insertQuery);
2142
+ const inserted = insertedRows[0];
2143
+ if (inserted) {
2144
+ processedData[fkField] = inserted[refField];
2145
+ }
2146
+ }
2147
+ }
2148
+ }
2076
2149
  }
2077
2150
  // Case 2: Related model holds the FK (e.g., User.posts where Post has userId)
2078
2151
  else if (!relationField.isForeignKey) {
@@ -96,6 +96,14 @@ export function buildLateralJoinQuery(params: {
96
96
  let paramIdx = 0;
97
97
 
98
98
  const take = typeof args.take === "number" ? (args.take as number) : undefined;
99
+
100
+ // Negative take is only valid with cursor-based pagination (backward page)
101
+ if (take !== undefined && take < 0 && args.cursor === undefined) {
102
+ throw new Error(
103
+ `Negative \`take\` (${take}) is only supported with cursor-based pagination on model "${modelMeta.name}". Provide a \`cursor\` argument or use a positive \`take\` value.`
104
+ );
105
+ }
106
+
99
107
  const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
100
108
 
101
109
  // Build WHERE — start with explicit where, then merge cursor conditions.
@@ -169,18 +177,12 @@ export function buildLateralJoinQuery(params: {
169
177
  const { relationMeta, nestedArgs } = relationsToLoad[i]!;
170
178
  const alias = `__lat_${i}`;
171
179
  const colAlias = `__vibeorm_rel_${relationMeta.name}`;
180
+ const relatedAlias = `"__rel"`;
172
181
 
173
182
  // Find the related model meta
174
183
  const relatedModelMeta = modelMap.get(relationMeta.relatedModel);
175
184
  if (!relatedModelMeta) continue;
176
185
 
177
- // Determine FK column and join condition
178
- const { fkDbName, fkTable } = resolveFkColumn({
179
- parentModelMeta: modelMeta,
180
- relationMeta,
181
- relatedModelMeta,
182
- });
183
-
184
186
  // Build json_build_object fields for the related model.
185
187
  // Respect nested select/omit to avoid serialising columns the caller doesn't need.
186
188
  const relatedScalars = resolveRelatedScalars({
@@ -188,20 +190,36 @@ export function buildLateralJoinQuery(params: {
188
190
  nestedArgs,
189
191
  });
190
192
  const jsonFields = relatedScalars
191
- .map((f) => `'${f.name}', __rel."${f.dbName}"`)
193
+ .map((f) => `'${f.name}', ${relatedAlias}."${f.dbName}"`)
192
194
  .join(", ");
193
195
 
194
196
  let lateralSubquery: string;
195
197
 
196
- if (relationMeta.isList) {
197
- // To-many: use json_agg with COALESCE for empty arrays
198
- const subWhere = `__rel."${fkDbName}" = ${table}."${pkDbName}"`;
198
+ // ─── M:N relations via junction table ─────────────────────────
199
+ const isM2M = relationMeta.type === "manyToMany" && (relationMeta as { joinTable?: string }).joinTable;
200
+
201
+ if (isM2M) {
202
+ const jtName = (relationMeta as { joinTable: string }).joinTable;
203
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
204
+
205
+ // Determine A vs B column: Prisma implicit junction tables use alphabetical model name order
206
+ const sorted = [modelMeta.name, relatedModelMeta.name].sort();
207
+ const parentIsA = modelMeta.name === sorted[0];
208
+ const parentCol = parentIsA ? "A" : "B";
209
+ const relatedCol = parentIsA ? "B" : "A";
210
+
211
+ // Related model PK for junction join condition
212
+ const relatedPk = relatedModelMeta.primaryKey[0]!;
213
+ const relatedPkSf = relatedSfMap.get(relatedPk);
214
+ const relatedPkDb = relatedPkSf?.dbName ?? relatedPk;
215
+
216
+ // Junction join: __jt."B" = __rel."id" WHERE __jt."A" = parent."id"
217
+ const junctionJoin = `INNER JOIN "${jtName}" __jt ON __jt."${relatedCol}" = ${relatedAlias}."${relatedPkDb}"`;
218
+ const subWhere = `__jt."${parentCol}" = ${table}."${pkDbName}"`;
199
219
 
200
220
  // Nested where filter from include/select args
201
221
  let nestedWhereSql = "";
202
222
  if (nestedArgs.where) {
203
- // Build nested where using __rel alias for the related model.
204
- // This avoids brittle regex rewrites of generated SQL table refs.
205
223
  const relMetaWithAlias = {
206
224
  ...relatedModelMeta,
207
225
  dbName: "__rel",
@@ -219,8 +237,6 @@ export function buildLateralJoinQuery(params: {
219
237
  }
220
238
  }
221
239
 
222
- const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
223
-
224
240
  let orderBySql = "";
225
241
  if (nestedArgs.orderBy) {
226
242
  const orderByItems = Array.isArray(nestedArgs.orderBy)
@@ -239,19 +255,18 @@ export function buildLateralJoinQuery(params: {
239
255
  `Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
240
256
  );
241
257
  }
242
- return `__rel."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
258
+ return `${relatedAlias}."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
243
259
  })
244
260
  );
245
261
  if (clauses.length > 0) {
246
262
  orderBySql = ` ORDER BY ${clauses.join(", ")}`;
247
263
  }
248
- } else if (defaultOrderByPk) {
249
- // Default ORDER BY PK for deterministic array ordering in jsonb_agg
264
+ } else if (defaultOrderByPk || nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
250
265
  const pkOrderClauses: string[] = [];
251
266
  for (const rpkName of relatedModelMeta.primaryKey) {
252
267
  const rpkSf = relatedSfMap.get(rpkName);
253
268
  if (rpkSf) {
254
- pkOrderClauses.push(`__rel."${rpkSf.dbName}" ASC`);
269
+ pkOrderClauses.push(`${relatedAlias}."${rpkSf.dbName}" ASC`);
255
270
  }
256
271
  }
257
272
  if (pkOrderClauses.length > 0) {
@@ -276,44 +291,153 @@ export function buildLateralJoinQuery(params: {
276
291
  // When take or skip is specified, use a subquery to apply per-parent LIMIT/OFFSET properly
277
292
  if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
278
293
  lateralSubquery = `LEFT JOIN LATERAL (
279
- SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})), '[]'::jsonb) AS "${colAlias}"
294
+ SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
280
295
  FROM (
281
- SELECT * FROM "${relatedModelMeta.dbName}" __rel
296
+ SELECT ${relatedAlias}.* FROM "${relatedModelMeta.dbName}" ${relatedAlias}
297
+ ${junctionJoin}
282
298
  WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
283
- ) __rel
299
+ ) ${relatedAlias}
284
300
  ) ${alias} ON true`;
285
301
  } else {
286
302
  lateralSubquery = `LEFT JOIN LATERAL (
287
303
  SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
288
- FROM "${relatedModelMeta.dbName}" __rel
304
+ FROM "${relatedModelMeta.dbName}" ${relatedAlias}
305
+ ${junctionJoin}
289
306
  WHERE ${subWhere}${nestedWhereSql}
290
307
  ) ${alias} ON true`;
291
308
  }
309
+
310
+ // ─── Direct FK relations (1:N, 1:1) ──────────────────────────
292
311
  } else {
293
- // To-one: use json_build_object with LIMIT 1
294
- let joinCondition: string;
295
-
296
- if (fkTable === "parent") {
297
- // Parent holds FK (e.g., Post.authorId -> User.id)
298
- const parentFkField = relationMeta.fields[0]!;
299
- const parentFkScalar = sfMap.get(parentFkField);
300
- const parentFkDbName = parentFkScalar?.dbName ?? parentFkField;
301
- const refField = relationMeta.references[0]!;
302
- const relatedSfMapForOne = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
303
- const refScalar = relatedSfMapForOne.get(refField);
304
- const refDbName = refScalar?.dbName ?? refField;
305
- joinCondition = `__rel."${refDbName}" = ${table}."${parentFkDbName}"`;
312
+ // Determine FK column and join condition
313
+ const { fkDbName, fkTable } = resolveFkColumn({
314
+ parentModelMeta: modelMeta,
315
+ relationMeta,
316
+ relatedModelMeta,
317
+ });
318
+
319
+ if (relationMeta.isList) {
320
+ // To-many: use json_agg with COALESCE for empty arrays
321
+ const subWhere = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
322
+
323
+ // Nested where filter from include/select args
324
+ let nestedWhereSql = "";
325
+ if (nestedArgs.where) {
326
+ // Build nested where using __rel alias for the related model.
327
+ // This avoids brittle regex rewrites of generated SQL table refs.
328
+ const relMetaWithAlias = {
329
+ ...relatedModelMeta,
330
+ dbName: "__rel",
331
+ } as typeof relatedModelMeta;
332
+ const nestedWhereResult = buildWhereClause({
333
+ where: nestedArgs.where as Record<string, unknown>,
334
+ modelMeta: relMetaWithAlias,
335
+ allModelsMeta,
336
+ paramOffset: paramIdx,
337
+ });
338
+ if (nestedWhereResult.sql) {
339
+ nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
340
+ paramIdx += nestedWhereResult.values.length;
341
+ allValues.push(...nestedWhereResult.values);
342
+ }
343
+ }
344
+
345
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
346
+
347
+ let orderBySql = "";
348
+ if (nestedArgs.orderBy) {
349
+ const orderByItems = Array.isArray(nestedArgs.orderBy)
350
+ ? (nestedArgs.orderBy as Record<string, string>[])
351
+ : [nestedArgs.orderBy as Record<string, string>];
352
+ const clauses = orderByItems.flatMap((item) =>
353
+ Object.entries(item).map(([field, dir]) => {
354
+ if (typeof dir !== "string") {
355
+ throw new Error(
356
+ `Unsupported nested orderBy direction for field "${field}" on relation "${relationMeta.name}"`
357
+ );
358
+ }
359
+ const sf = relatedSfMap.get(field);
360
+ if (!sf) {
361
+ throw new Error(
362
+ `Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
363
+ );
364
+ }
365
+ return `${relatedAlias}."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
366
+ })
367
+ );
368
+ if (clauses.length > 0) {
369
+ orderBySql = ` ORDER BY ${clauses.join(", ")}`;
370
+ }
371
+ } else if (defaultOrderByPk || nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
372
+ // Default ORDER BY PK for deterministic array ordering in jsonb_agg
373
+ const pkOrderClauses: string[] = [];
374
+ for (const rpkName of relatedModelMeta.primaryKey) {
375
+ const rpkSf = relatedSfMap.get(rpkName);
376
+ if (rpkSf) {
377
+ pkOrderClauses.push(`${relatedAlias}."${rpkSf.dbName}" ASC`);
378
+ }
379
+ }
380
+ if (pkOrderClauses.length > 0) {
381
+ orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
382
+ }
383
+ }
384
+
385
+ let limitSql = "";
386
+ if (nestedArgs.take !== undefined) {
387
+ paramIdx++;
388
+ limitSql = ` LIMIT $${paramIdx}`;
389
+ allValues.push(Math.abs(nestedArgs.take as number));
390
+ }
391
+
392
+ let offsetSql = "";
393
+ if (nestedArgs.skip !== undefined) {
394
+ paramIdx++;
395
+ offsetSql = ` OFFSET $${paramIdx}`;
396
+ allValues.push(nestedArgs.skip);
397
+ }
398
+
399
+ // When take or skip is specified, use a subquery to apply per-parent LIMIT/OFFSET properly
400
+ if (nestedArgs.take !== undefined || nestedArgs.skip !== undefined) {
401
+ lateralSubquery = `LEFT JOIN LATERAL (
402
+ SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
403
+ FROM (
404
+ SELECT * FROM "${relatedModelMeta.dbName}" ${relatedAlias}
405
+ WHERE ${subWhere}${nestedWhereSql}${orderBySql}${limitSql}${offsetSql}
406
+ ) ${relatedAlias}
407
+ ) ${alias} ON true`;
408
+ } else {
409
+ lateralSubquery = `LEFT JOIN LATERAL (
410
+ SELECT COALESCE(jsonb_agg(json_build_object(${jsonFields})${orderBySql}), '[]'::jsonb) AS "${colAlias}"
411
+ FROM "${relatedModelMeta.dbName}" ${relatedAlias}
412
+ WHERE ${subWhere}${nestedWhereSql}
413
+ ) ${alias} ON true`;
414
+ }
306
415
  } else {
307
- // Related model holds FK (e.g., User.profile -> Profile.userId)
308
- joinCondition = `__rel."${fkDbName}" = ${table}."${pkDbName}"`;
309
- }
416
+ // To-one: use json_build_object with LIMIT 1
417
+ let joinCondition: string;
418
+
419
+ if (fkTable === "parent") {
420
+ // Parent holds FK (e.g., Post.authorId -> User.id)
421
+ const parentFkField = relationMeta.fields[0]!;
422
+ const parentFkScalar = sfMap.get(parentFkField);
423
+ const parentFkDbName = parentFkScalar?.dbName ?? parentFkField;
424
+ const refField = relationMeta.references[0]!;
425
+ const relatedSfMapForOne = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
426
+ const refScalar = relatedSfMapForOne.get(refField);
427
+ const refDbName = refScalar?.dbName ?? refField;
428
+ joinCondition = `${relatedAlias}."${refDbName}" = ${table}."${parentFkDbName}"`;
429
+ } else {
430
+ // Related model holds FK (e.g., User.profile -> Profile.userId)
431
+ joinCondition = `${relatedAlias}."${fkDbName}" = ${table}."${pkDbName}"`;
432
+ }
310
433
 
311
- lateralSubquery = `LEFT JOIN LATERAL (
434
+ lateralSubquery = `LEFT JOIN LATERAL (
312
435
  SELECT json_build_object(${jsonFields}) AS "${colAlias}"
313
- FROM "${relatedModelMeta.dbName}" __rel
436
+ FROM "${relatedModelMeta.dbName}" ${relatedAlias}
314
437
  WHERE ${joinCondition}
315
438
  LIMIT 1
316
439
  ) ${alias} ON true`;
440
+ }
317
441
  }
318
442
 
319
443
  lateralJoins.push(lateralSubquery);
@@ -15,6 +15,20 @@ import type {
15
15
  import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
16
16
  import { buildWhereClause } from "./where-builder.ts";
17
17
 
18
+ // ─── Scalar List Helpers ──────────────────────────────────────────
19
+
20
+ /**
21
+ * Wrap a value in PgArray if the corresponding field is a scalar list (e.g. String[], Int[]).
22
+ * This ensures adapters serialize the JS array as a PostgreSQL array literal
23
+ * rather than passing it as a raw array or JSON string.
24
+ */
25
+ function wrapListValue(params: { value: unknown; field: ScalarFieldMeta }): unknown {
26
+ if (params.field.isList && Array.isArray(params.value)) {
27
+ return new PgArray(params.value);
28
+ }
29
+ return params.value;
30
+ }
31
+
18
32
  // ─── SQL Direction Sanitizer ──────────────────────────────────────
19
33
 
20
34
  const VALID_DIRECTIONS = new Set(["ASC", "DESC"]);
@@ -79,6 +93,14 @@ export function buildSelectQuery(params: {
79
93
  const table = `"${modelMeta.dbName}"`;
80
94
  const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
81
95
  const take = getTakeValue({ args });
96
+
97
+ // Negative take is only valid with cursor-based pagination (backward page)
98
+ if (take !== undefined && take < 0 && args.cursor === undefined) {
99
+ throw new Error(
100
+ `Negative \`take\` (${take}) is only supported with cursor-based pagination on model "${modelMeta.name}". Provide a \`cursor\` argument or use a positive \`take\` value.`
101
+ );
102
+ }
103
+
82
104
  const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
83
105
 
84
106
  // Determine which columns to select
@@ -510,7 +532,7 @@ export function buildInsertQuery(params: {
510
532
  if (value === undefined) continue;
511
533
  const scalarField = sfMap.get(key);
512
534
  if (scalarField) {
513
- entries.push({ dbName: scalarField.dbName, value });
535
+ entries.push({ dbName: scalarField.dbName, value: wrapListValue({ value, field: scalarField }) });
514
536
  }
515
537
  }
516
538
 
@@ -560,6 +582,7 @@ export function buildUpdateQuery(params: {
560
582
  dbName: scalarField.dbName,
561
583
  value,
562
584
  paramIdx,
585
+ field: scalarField,
563
586
  });
564
587
  setClauses.push(setResult.sql);
565
588
  allValues.push(...setResult.values);
@@ -839,7 +862,10 @@ export function buildRelationQuery(params: {
839
862
  rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
840
863
  }
841
864
 
842
- const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")}`;
865
+ const fkOrderExpr = fkAlreadySelected && fkFieldName
866
+ ? `__ranked."${fkFieldName}"`
867
+ : `__ranked."__vibeorm_fk"`;
868
+ const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")} ORDER BY ${fkOrderExpr} ASC, __ranked."__vibeorm_rn" ASC`;
843
869
 
844
870
  return { text, values: allValues, fkFieldName: fkAlreadySelected ? fkFieldName : undefined };
845
871
  }
@@ -980,7 +1006,7 @@ export function buildManyToManyQuery(params: {
980
1006
  rnConditions.push(`__ranked."__vibeorm_rn" <= $${paramIdx}`);
981
1007
  }
982
1008
 
983
- const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")}`;
1009
+ const text = `SELECT * FROM (${innerSql}) __ranked WHERE ${rnConditions.join(" AND ")} ORDER BY __ranked."__vibeorm_fk" ASC, __ranked."__vibeorm_rn" ASC`;
984
1010
  return { text, values: allValues };
985
1011
  }
986
1012
 
@@ -1094,10 +1120,10 @@ export function buildInsertManyQuery(params: {
1094
1120
  };
1095
1121
  }
1096
1122
 
1097
- // Map keys to DB column names
1123
+ // Map keys to DB column names (keep field ref for isList wrapping)
1098
1124
  const columnMap = orderedKeys.map((key) => {
1099
1125
  const scalarField = sfMap.get(key)!;
1100
- return { key, dbName: scalarField.dbName };
1126
+ return { key, dbName: scalarField.dbName, field: scalarField };
1101
1127
  });
1102
1128
 
1103
1129
  const columns = columnMap.map((c) => `"${c.dbName}"`).join(", ");
@@ -1109,14 +1135,14 @@ export function buildInsertManyQuery(params: {
1109
1135
 
1110
1136
  for (const record of data) {
1111
1137
  const placeholders: string[] = [];
1112
- for (const { key } of columnMap) {
1138
+ for (const { key, field } of columnMap) {
1113
1139
  const value = record[key];
1114
1140
  if (value === undefined) {
1115
1141
  placeholders.push("DEFAULT");
1116
1142
  } else {
1117
1143
  paramIdx++;
1118
1144
  placeholders.push(`$${paramIdx}`);
1119
- allValues.push(value);
1145
+ allValues.push(wrapListValue({ value, field }));
1120
1146
  }
1121
1147
  }
1122
1148
  valueRows.push(`(${placeholders.join(", ")})`);
@@ -1190,6 +1216,7 @@ export function buildUpdateManyQuery(params: {
1190
1216
  dbName: scalarField.dbName,
1191
1217
  value,
1192
1218
  paramIdx,
1219
+ field: scalarField,
1193
1220
  });
1194
1221
  setClauses.push(setResult.sql);
1195
1222
  allValues.push(...setResult.values);
@@ -1549,9 +1576,14 @@ function buildSetClause(params: {
1549
1576
  dbName: string;
1550
1577
  value: unknown;
1551
1578
  paramIdx: number;
1579
+ field: ScalarFieldMeta;
1580
+ /** Optional table prefix for qualifying column self-references (e.g. in ON CONFLICT DO UPDATE SET) */
1581
+ tablePrefix?: string;
1552
1582
  }): { sql: string; values: unknown[] } {
1553
- const { dbName, value, paramIdx } = params;
1583
+ const { dbName, value, paramIdx, field, tablePrefix } = params;
1554
1584
  let idx = paramIdx;
1585
+ // Qualified column reference for the RHS (e.g. "Product"."price" in upsert context)
1586
+ const colRef = tablePrefix ? `${tablePrefix}."${dbName}"` : `"${dbName}"`;
1555
1587
 
1556
1588
  // Check for atomic number operations: { increment, decrement, multiply, divide, set }
1557
1589
  if (typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
@@ -1559,19 +1591,19 @@ function buildSetClause(params: {
1559
1591
 
1560
1592
  if (ops.increment !== undefined) {
1561
1593
  idx++;
1562
- return { sql: `"${dbName}" = "${dbName}" + $${idx}`, values: [ops.increment] };
1594
+ return { sql: `"${dbName}" = ${colRef} + $${idx}`, values: [ops.increment] };
1563
1595
  }
1564
1596
  if (ops.decrement !== undefined) {
1565
1597
  idx++;
1566
- return { sql: `"${dbName}" = "${dbName}" - $${idx}`, values: [ops.decrement] };
1598
+ return { sql: `"${dbName}" = ${colRef} - $${idx}`, values: [ops.decrement] };
1567
1599
  }
1568
1600
  if (ops.multiply !== undefined) {
1569
1601
  idx++;
1570
- return { sql: `"${dbName}" = "${dbName}" * $${idx}`, values: [ops.multiply] };
1602
+ return { sql: `"${dbName}" = ${colRef} * $${idx}`, values: [ops.multiply] };
1571
1603
  }
1572
1604
  if (ops.divide !== undefined) {
1573
1605
  idx++;
1574
- return { sql: `"${dbName}" = "${dbName}" / $${idx}`, values: [ops.divide] };
1606
+ return { sql: `"${dbName}" = ${colRef} / $${idx}`, values: [ops.divide] };
1575
1607
  }
1576
1608
 
1577
1609
  // Scalar list operations: { set: [...] } or { push: value }
@@ -1580,23 +1612,23 @@ function buildSetClause(params: {
1580
1612
  if (Array.isArray(pushVal)) {
1581
1613
  // Push multiple: "col" = "col" || $N
1582
1614
  idx++;
1583
- return { sql: `"${dbName}" = "${dbName}" || $${idx}`, values: [pushVal] };
1615
+ return { sql: `"${dbName}" = ${colRef} || $${idx}`, values: [wrapListValue({ value: pushVal, field })] };
1584
1616
  } else {
1585
1617
  // Push single: "col" = array_append("col", $N)
1586
1618
  idx++;
1587
- return { sql: `"${dbName}" = array_append("${dbName}", $${idx})`, values: [pushVal] };
1619
+ return { sql: `"${dbName}" = array_append(${colRef}, $${idx})`, values: [pushVal] };
1588
1620
  }
1589
1621
  }
1590
1622
 
1591
1623
  if ("set" in ops) {
1592
1624
  idx++;
1593
- return { sql: `"${dbName}" = $${idx}`, values: [ops.set] };
1625
+ return { sql: `"${dbName}" = $${idx}`, values: [wrapListValue({ value: ops.set, field })] };
1594
1626
  }
1595
1627
  }
1596
1628
 
1597
- // Plain value
1629
+ // Plain value (wrap if scalar list)
1598
1630
  idx++;
1599
- return { sql: `"${dbName}" = $${idx}`, values: [value] };
1631
+ return { sql: `"${dbName}" = $${idx}`, values: [wrapListValue({ value, field })] };
1600
1632
  }
1601
1633
 
1602
1634
  // ─── UPSERT Builder (INSERT ... ON CONFLICT) ─────────────────────
@@ -1651,7 +1683,7 @@ export function buildUpsertQuery(params: {
1651
1683
  if (value === undefined) continue;
1652
1684
  const sf = sfMap.get(key);
1653
1685
  if (sf) {
1654
- insertEntries.push({ dbName: sf.dbName, value });
1686
+ insertEntries.push({ dbName: sf.dbName, value: wrapListValue({ value, field: sf }) });
1655
1687
  }
1656
1688
  }
1657
1689
 
@@ -1666,6 +1698,8 @@ export function buildUpsertQuery(params: {
1666
1698
  }).join(", ");
1667
1699
 
1668
1700
  // Build UPDATE SET part from update data (using atomic operations)
1701
+ // In ON CONFLICT DO UPDATE SET context, column self-references must be table-qualified
1702
+ // to avoid ambiguity (e.g. "Product"."price" instead of just "price").
1669
1703
  const updateClauses: string[] = [];
1670
1704
  for (const [key, value] of Object.entries(update)) {
1671
1705
  if (value === undefined) continue;
@@ -1676,6 +1710,8 @@ export function buildUpsertQuery(params: {
1676
1710
  dbName: sf.dbName,
1677
1711
  value,
1678
1712
  paramIdx,
1713
+ field: sf,
1714
+ tablePrefix: table,
1679
1715
  });
1680
1716
  updateClauses.push(setResult.sql);
1681
1717
  allValues.push(...setResult.values);
@@ -1778,7 +1814,7 @@ function resolveMutationReturningColumns(params: {
1778
1814
  const { modelMeta, args } = params;
1779
1815
  const shouldForceFullReturning = args?.returning === true;
1780
1816
 
1781
- if (!shouldForceFullReturning && args?.select) {
1817
+ if (!shouldForceFullReturning && (args?.select || args?.omit)) {
1782
1818
  const columns = resolveSelectColumns({
1783
1819
  modelMeta,
1784
1820
  args,
package/src/types.ts CHANGED
@@ -191,6 +191,8 @@ export type ScalarFieldMeta = {
191
191
  readonly isUpdatedAt?: boolean;
192
192
  /** Prisma type for runtime coercion (e.g., BigInt values from the DB driver) */
193
193
  readonly type?: string;
194
+ /** Whether this field is a scalar list (e.g., String[], Int[]) */
195
+ readonly isList?: boolean;
194
196
  /**
195
197
  * Default value kind for application-level generation.
196
198
  * - "uuid" | "cuid" | "nanoid" | "ulid": runtime generates the value if not provided
@@ -739,13 +739,13 @@ function buildScalarFilter(params: {
739
739
  case "hasEvery": {
740
740
  idx++;
741
741
  conditions.push(`${col} @> $${idx}`);
742
- values.push(operand);
742
+ values.push(Array.isArray(operand) ? new PgArray(operand) : operand);
743
743
  break;
744
744
  }
745
745
  case "hasSome": {
746
746
  idx++;
747
747
  conditions.push(`${col} && $${idx}`);
748
- values.push(operand);
748
+ values.push(Array.isArray(operand) ? new PgArray(operand) : operand);
749
749
  break;
750
750
  }
751
751
  case "isEmpty": {