@vibeorm/runtime 1.0.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/src/client.ts ADDED
@@ -0,0 +1,2055 @@
1
+ /**
2
+ * VibeORM Client Runtime
3
+ *
4
+ * Creates the actual client instance that the generated code uses.
5
+ * This connects to PostgreSQL via a DatabaseAdapter and executes
6
+ * queries built by the query builder.
7
+ */
8
+
9
+ import type {
10
+ VibeClientOptions,
11
+ ModelMeta,
12
+ ModelMetaMap,
13
+ ModelSchemas,
14
+ Operation,
15
+ QueryProfile,
16
+ ProfilingContext,
17
+ } from "./types.ts";
18
+ import { getScalarFieldMap, getModelByNameMap, PgArray } from "./types.ts";
19
+ import type { DatabaseAdapter } from "./adapter.ts";
20
+ import { VibeValidationError } from "./errors.ts";
21
+ import {
22
+ buildSelectQuery,
23
+ buildInsertQuery,
24
+ buildInsertManyQuery,
25
+ buildUpdateQuery,
26
+ buildUpdateManyQuery,
27
+ buildDeleteQuery,
28
+ buildCountQuery,
29
+ buildAggregateQuery,
30
+ buildGroupByQuery,
31
+ buildUpsertQuery,
32
+ } from "./query-builder.ts";
33
+ import { loadRelations } from "./relation-loader.ts";
34
+ import {
35
+ loadRelationsWithLateralJoin,
36
+ executeLateralJoinQuery,
37
+ resolveRelationsToLoad,
38
+ } from "./lateral-join-builder.ts";
39
+ import { generateDefault } from "./id-generators.ts";
40
+
41
+ /**
42
+ * Creates a VibeORM client instance.
43
+ * Called by the generated index.ts file.
44
+ */
45
+ export function createClient(params: {
46
+ options: VibeClientOptions;
47
+ modelMeta: Record<string, ModelMeta>;
48
+ schemas?: Record<string, ModelSchemas>;
49
+ }): Record<string, unknown> {
50
+ const { options, modelMeta } = params;
51
+ const adapter = options.adapter;
52
+ const shouldLog = options?.log === true || options?.log === "query";
53
+ const shouldDebug = !!options?.debug;
54
+
55
+ /** Emit a query profile to the user's debug handler or console. */
56
+ function debugEmit(profile: QueryProfile): void {
57
+ if (!shouldDebug) return;
58
+ if (typeof options?.debug === "function") {
59
+ options.debug({ profile });
60
+ } else {
61
+ // Pretty-print to console
62
+ const pad = (s: string, n: number) => s.padEnd(n);
63
+ console.log(
64
+ `[vibeorm:debug] ${profile.model}.${profile.operation} — ${profile.totalMs.toFixed(2)}ms total`
65
+ );
66
+ console.log(
67
+ ` query build: ${profile.queryBuildMs.toFixed(2)}ms`
68
+ );
69
+ console.log(
70
+ ` sql exec: ${profile.sqlExecMs.toFixed(2)}ms (${profile.rowCount} rows)`
71
+ );
72
+ if (profile.resultMapMs > 0) {
73
+ console.log(
74
+ ` result map: ${profile.resultMapMs.toFixed(2)}ms`
75
+ );
76
+ }
77
+ if (profile.relationLoadMs > 0) {
78
+ console.log(
79
+ ` relation load: ${profile.relationLoadMs.toFixed(2)}ms`
80
+ );
81
+ for (const rp of profile.relationProfiles) {
82
+ console.log(
83
+ ` ${pad(rp.relation + ":", 20)} ${rp.sqlExecMs.toFixed(2)}ms (${rp.rowCount} rows)`
84
+ );
85
+ }
86
+ }
87
+ if (profile.resultSizeBytes > 0) {
88
+ const kb = (profile.resultSizeBytes / 1024).toFixed(1);
89
+ console.log(` result size: ~${kb} KB`);
90
+ }
91
+ console.log(` SQL: ${profile.sql.slice(0, 200)}${profile.sql.length > 200 ? "..." : ""}`);
92
+ console.log("");
93
+ }
94
+ }
95
+
96
+ /** Fast approximate result size in bytes (avoids full JSON.stringify). */
97
+ function estimateResultSize(params: { records: Record<string, unknown>[] }): number {
98
+ let size = 0;
99
+ for (const record of params.records) {
100
+ for (const key in record) {
101
+ const val = record[key];
102
+ size += key.length;
103
+ if (val === null || val === undefined) {
104
+ size += 4;
105
+ } else if (typeof val === "string") {
106
+ size += val.length;
107
+ } else if (typeof val === "number" || typeof val === "boolean") {
108
+ size += 8;
109
+ } else if (val instanceof Date) {
110
+ size += 24;
111
+ } else if (Array.isArray(val)) {
112
+ // Rough estimate for relation arrays: count items * avg row size
113
+ size += val.length * 200;
114
+ } else if (typeof val === "object") {
115
+ size += 200; // rough estimate for nested objects
116
+ }
117
+ }
118
+ }
119
+ return size;
120
+ }
121
+
122
+ /** Create a profiling-aware executor that records per-call SQL timing. */
123
+ function createProfiledExecutor(profilingCtx: ProfilingContext): {
124
+ executor: typeof executeSql;
125
+ getTimings: () => { totalExecMs: number; calls: number };
126
+ } {
127
+ let totalExecMs = 0;
128
+ let calls = 0;
129
+
130
+ const executor = async (execParams: {
131
+ text: string;
132
+ values: unknown[];
133
+ }): Promise<Record<string, unknown>[]> => {
134
+ const t0 = performance.now();
135
+ const result = await executeSql(execParams);
136
+ const elapsed = performance.now() - t0;
137
+ totalExecMs += elapsed;
138
+ calls++;
139
+
140
+ // Record as a relation profile if this is a sub-query (not the main query)
141
+ // The main query is handled separately; relation queries are tracked via
142
+ // the profilingCtx being passed through loadRelations.
143
+ return result;
144
+ };
145
+
146
+ return {
147
+ executor,
148
+ getTimings: () => ({ totalExecMs, calls }),
149
+ };
150
+ }
151
+
152
+ // Normalize model meta into a lookup by camelCase name
153
+ const allModelsMeta: ModelMetaMap = {};
154
+ for (const [key, meta] of Object.entries(modelMeta)) {
155
+ allModelsMeta[key] = meta;
156
+ }
157
+
158
+ // SQL executor function — delegates to the adapter.
159
+ // Array values are converted via adapter.formatArrayParam so that
160
+ // = ANY($1) works correctly.
161
+ async function executeSql(params: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
162
+ const { text, values: rawValues } = params;
163
+ // Convert PgArray instances - let the adapter handle the format
164
+ const values = rawValues.map((v) => (v instanceof PgArray ? adapter.formatArrayParam(v.values) : v));
165
+
166
+ if (shouldLog) {
167
+ console.log(`[vibeorm] ${text}`);
168
+ if (values.length > 0) {
169
+ console.log(`[vibeorm] params:`, values);
170
+ }
171
+ }
172
+
173
+ return adapter.execute({ text, values });
174
+ }
175
+
176
+ // Create delegate for a model
177
+ function createDelegate(params: {
178
+ modelKey: string;
179
+ modelMeta: ModelMeta;
180
+ executor: typeof executeSql;
181
+ schemas?: ModelSchemas;
182
+ }): Record<string, Function> {
183
+ const { modelKey, modelMeta, executor, schemas } = params;
184
+
185
+ // Resolve validation mode from options
186
+ const validateOpt = options?.validate;
187
+ const shouldValidateInput = !!schemas && (
188
+ validateOpt === true || validateOpt === "all" || validateOpt === "input"
189
+ );
190
+ const shouldValidateOutput = !!schemas && (
191
+ validateOpt === true || validateOpt === "all" || validateOpt === "output"
192
+ );
193
+
194
+ function validateInput(innerParams: {
195
+ data: unknown;
196
+ schemaKey: keyof ModelSchemas;
197
+ operation: string;
198
+ }): void {
199
+ if (!shouldValidateInput) return;
200
+ const schema = schemas?.[innerParams.schemaKey];
201
+ if (!schema) return;
202
+ const result = schema.safeParse(innerParams.data);
203
+ if (!result.success) {
204
+ throw new VibeValidationError({
205
+ model: modelMeta.name,
206
+ operation: innerParams.operation,
207
+ direction: "input",
208
+ zodError: result.error,
209
+ });
210
+ }
211
+ }
212
+
213
+ function validateOutput(innerParams: {
214
+ records: Record<string, unknown>[];
215
+ operation: string;
216
+ }): void {
217
+ if (!shouldValidateOutput) return;
218
+ const schema = schemas?.model;
219
+ if (!schema) return;
220
+ for (const record of innerParams.records) {
221
+ const result = schema.safeParse(record);
222
+ if (!result.success) {
223
+ throw new VibeValidationError({
224
+ model: modelMeta.name,
225
+ operation: innerParams.operation,
226
+ direction: "output",
227
+ zodError: result.error,
228
+ });
229
+ }
230
+ }
231
+ }
232
+
233
+ function shouldUseLateralJoin(args: Record<string, unknown>): boolean {
234
+ const strategy =
235
+ (args.relationStrategy as string) ??
236
+ options?.relationStrategy ??
237
+ "query";
238
+ if (strategy !== "join") return false;
239
+ // Only use lateral join if there are relations to load
240
+ const rels = resolveRelationsToLoad({ parentModelMeta: modelMeta, args });
241
+ return rels.length > 0;
242
+ }
243
+
244
+ async function findMany(args: Record<string, unknown> = {}) {
245
+ if (shouldUseLateralJoin(args)) {
246
+ if (shouldDebug) {
247
+ const profilingCtx: ProfilingContext = { relationProfiles: [] };
248
+ const t0 = performance.now();
249
+ const result = await executeLateralJoinQuery({
250
+ modelMeta,
251
+ allModelsMeta,
252
+ args,
253
+ executor: executeSql,
254
+ profilingCtx,
255
+ });
256
+ const t1 = performance.now();
257
+ const sizeBytes = estimateResultSize({ records: result });
258
+ debugEmit({
259
+ model: modelMeta.name,
260
+ operation: "findMany",
261
+ totalMs: t1 - t0,
262
+ queryBuildMs: profilingCtx.queryBuildMs ?? 0,
263
+ sqlExecMs: profilingCtx.sqlExecMs ?? 0,
264
+ rowCount: result.length,
265
+ relationLoadMs: 0,
266
+ relationProfiles: profilingCtx.relationProfiles,
267
+ sql: profilingCtx.sql ?? "(lateral join)",
268
+ resultMapMs: profilingCtx.resultMapMs ?? 0,
269
+ resultSizeBytes: sizeBytes,
270
+ sqlValues: profilingCtx.sqlValues,
271
+ });
272
+ validateOutput({ records: result, operation: "findMany" });
273
+ return result;
274
+ }
275
+
276
+ const result = await executeLateralJoinQuery({
277
+ modelMeta,
278
+ allModelsMeta,
279
+ args,
280
+ executor,
281
+ });
282
+ validateOutput({ records: result, operation: "findMany" });
283
+ return result;
284
+ }
285
+
286
+ if (shouldDebug) {
287
+ const profilingCtx: ProfilingContext = { relationProfiles: [] };
288
+ const { executor: profExec } = createProfiledExecutor(profilingCtx);
289
+ const t0 = performance.now();
290
+ const query = buildSelectQuery({
291
+ modelMeta,
292
+ allModelsMeta,
293
+ args,
294
+ });
295
+ const t1 = performance.now();
296
+ let records = await profExec(query);
297
+ const t2 = performance.now();
298
+ records = await loadRelationsForStrategy({
299
+ records,
300
+ modelMeta,
301
+ allModelsMeta,
302
+ args,
303
+ executor: profExec,
304
+ profilingCtx,
305
+ });
306
+ const t3 = performance.now();
307
+ const sizeBytes = estimateResultSize({ records });
308
+ debugEmit({
309
+ model: modelMeta.name,
310
+ operation: "findMany",
311
+ totalMs: t3 - t0,
312
+ queryBuildMs: t1 - t0,
313
+ sqlExecMs: t2 - t1,
314
+ rowCount: records.length,
315
+ relationLoadMs: t3 - t2,
316
+ relationProfiles: profilingCtx.relationProfiles,
317
+ sql: query.text,
318
+ resultMapMs: 0,
319
+ resultSizeBytes: sizeBytes,
320
+ });
321
+ records = applySelectFiltering({ records, args, modelMeta });
322
+ validateOutput({ records, operation: "findMany" });
323
+ return records;
324
+ }
325
+
326
+ const query = buildSelectQuery({
327
+ modelMeta,
328
+ allModelsMeta,
329
+ args,
330
+ });
331
+ let records = await executor(query);
332
+
333
+ records = await loadRelationsForStrategy({
334
+ records,
335
+ modelMeta,
336
+ allModelsMeta,
337
+ args,
338
+ executor,
339
+ });
340
+
341
+ records = applySelectFiltering({ records, args, modelMeta });
342
+ validateOutput({ records, operation: "findMany" });
343
+ return records;
344
+ }
345
+
346
+ async function findFirst(args: Record<string, unknown> = {}) {
347
+ const argsWithLimit = { ...args, take: 1 };
348
+
349
+ if (shouldUseLateralJoin(args)) {
350
+ const records = await executeLateralJoinQuery({
351
+ modelMeta,
352
+ allModelsMeta,
353
+ args: argsWithLimit,
354
+ executor,
355
+ });
356
+ validateOutput({ records, operation: "findFirst" });
357
+ return records[0] ?? null;
358
+ }
359
+
360
+ if (shouldDebug) {
361
+ const profilingCtx: ProfilingContext = { relationProfiles: [] };
362
+ const { executor: profExec } = createProfiledExecutor(profilingCtx);
363
+ const t0 = performance.now();
364
+ const query = buildSelectQuery({ modelMeta, allModelsMeta, args: argsWithLimit });
365
+ const t1 = performance.now();
366
+ let records = await profExec(query);
367
+ const t2 = performance.now();
368
+ records = await loadRelationsForStrategy({
369
+ records, modelMeta, allModelsMeta, args, executor: profExec, profilingCtx,
370
+ });
371
+ const t3 = performance.now();
372
+ debugEmit({
373
+ model: modelMeta.name, operation: "findFirst",
374
+ totalMs: t3 - t0, queryBuildMs: t1 - t0, sqlExecMs: t2 - t1,
375
+ rowCount: records.length, relationLoadMs: t3 - t2,
376
+ relationProfiles: profilingCtx.relationProfiles, sql: query.text,
377
+ resultMapMs: 0, resultSizeBytes: estimateResultSize({ records }),
378
+ });
379
+ records = applySelectFiltering({ records, args, modelMeta });
380
+ validateOutput({ records, operation: "findFirst" });
381
+ return records[0] ?? null;
382
+ }
383
+
384
+ const query = buildSelectQuery({
385
+ modelMeta,
386
+ allModelsMeta,
387
+ args: argsWithLimit,
388
+ });
389
+ let records = await executor(query);
390
+
391
+ records = await loadRelationsForStrategy({
392
+ records,
393
+ modelMeta,
394
+ allModelsMeta,
395
+ args,
396
+ executor,
397
+ });
398
+
399
+ records = applySelectFiltering({ records, args, modelMeta });
400
+ validateOutput({ records, operation: "findFirst" });
401
+ return records[0] ?? null;
402
+ }
403
+
404
+ async function findUnique(args: Record<string, unknown>) {
405
+ // Expand compound unique keys: { userId_planId: { userId: 1, planId: 2 } } → { userId: 1, planId: 2 }
406
+ if (args.where) {
407
+ args = { ...args, where: convertUniqueToWhere({ uniqueWhere: args.where as Record<string, unknown>, modelMeta }) };
408
+ }
409
+ const argsWithLimit = { ...args, take: 1 };
410
+
411
+ if (shouldUseLateralJoin(args)) {
412
+ const records = await executeLateralJoinQuery({
413
+ modelMeta,
414
+ allModelsMeta,
415
+ args: argsWithLimit,
416
+ executor,
417
+ });
418
+ validateOutput({ records, operation: "findUnique" });
419
+ return records[0] ?? null;
420
+ }
421
+
422
+ if (shouldDebug) {
423
+ const profilingCtx: ProfilingContext = { relationProfiles: [] };
424
+ const { executor: profExec } = createProfiledExecutor(profilingCtx);
425
+ const t0 = performance.now();
426
+ const query = buildSelectQuery({ modelMeta, allModelsMeta, args: argsWithLimit });
427
+ const t1 = performance.now();
428
+ let records = await profExec(query);
429
+ const t2 = performance.now();
430
+ records = await loadRelationsForStrategy({
431
+ records, modelMeta, allModelsMeta, args, executor: profExec, profilingCtx,
432
+ });
433
+ const t3 = performance.now();
434
+ debugEmit({
435
+ model: modelMeta.name, operation: "findUnique",
436
+ totalMs: t3 - t0, queryBuildMs: t1 - t0, sqlExecMs: t2 - t1,
437
+ rowCount: records.length, relationLoadMs: t3 - t2,
438
+ relationProfiles: profilingCtx.relationProfiles, sql: query.text,
439
+ resultMapMs: 0, resultSizeBytes: estimateResultSize({ records }),
440
+ });
441
+ records = applySelectFiltering({ records, args, modelMeta });
442
+ validateOutput({ records, operation: "findUnique" });
443
+ return records[0] ?? null;
444
+ }
445
+
446
+ const query = buildSelectQuery({
447
+ modelMeta,
448
+ allModelsMeta,
449
+ args: argsWithLimit,
450
+ });
451
+ let records = await executor(query);
452
+
453
+ records = await loadRelationsForStrategy({
454
+ records,
455
+ modelMeta,
456
+ allModelsMeta,
457
+ args,
458
+ executor,
459
+ });
460
+
461
+ records = applySelectFiltering({ records, args, modelMeta });
462
+ validateOutput({ records, operation: "findUnique" });
463
+ return records[0] ?? null;
464
+ }
465
+
466
+ async function findUniqueOrThrow(args: Record<string, unknown>) {
467
+ const result = await findUnique(args);
468
+ if (!result) {
469
+ throw new Error(
470
+ `No ${modelMeta.name} found for the given where clause`
471
+ );
472
+ }
473
+ return result;
474
+ }
475
+
476
+ async function findFirstOrThrow(args: Record<string, unknown> = {}) {
477
+ const result = await findFirst(args);
478
+ if (!result) {
479
+ throw new Error(
480
+ `No ${modelMeta.name} found for the given where clause`
481
+ );
482
+ }
483
+ return result;
484
+ }
485
+
486
+ async function create(args: Record<string, unknown>) {
487
+ const data = args.data as Record<string, unknown>;
488
+
489
+ // Validate input
490
+ validateInput({ data, schemaKey: "createInput", operation: "create" });
491
+
492
+ // Auto-inject @updatedAt fields and auto-generated defaults (uuid, cuid, etc.)
493
+ injectUpdatedAt({ data, modelMeta });
494
+ injectAutoDefaults({ data, modelMeta, idGenerator: options?.idGenerator });
495
+
496
+ const { processedData, deferredCreates } = await processNestedCreates({
497
+ data,
498
+ modelMeta,
499
+ allModelsMeta,
500
+ executor,
501
+ });
502
+
503
+ const query = buildInsertQuery({
504
+ modelMeta,
505
+ data: processedData,
506
+ });
507
+ let records = await executor(query);
508
+
509
+ // Execute deferred creates (related records that hold the FK to this parent)
510
+ // Group by related model to batch multiple creates into single INSERT statements.
511
+ const parentRecord = records[0];
512
+ if (parentRecord && deferredCreates.length > 0) {
513
+ for (const deferred of deferredCreates) {
514
+ const parentRefValue = parentRecord[deferred.parentRefField];
515
+ if (parentRefValue != null) {
516
+ const createItems = Array.isArray(deferred.createData) ? deferred.createData : [deferred.createData];
517
+ const dataArray = createItems.map((createData) => {
518
+ (createData as Record<string, unknown>)[deferred.fkField] = parentRefValue;
519
+ return createData as Record<string, unknown>;
520
+ });
521
+ if (dataArray.length === 1) {
522
+ const insertQuery = buildInsertQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray[0]! });
523
+ await executor(insertQuery);
524
+ } else if (dataArray.length > 1) {
525
+ const insertQuery = buildInsertManyQuery({ modelMeta: deferred.relatedModelMeta, data: dataArray, returning: false });
526
+ await executor(insertQuery);
527
+ }
528
+ }
529
+ }
530
+ }
531
+
532
+ records = await loadRelationsForStrategy({
533
+ records,
534
+ modelMeta,
535
+ allModelsMeta,
536
+ args,
537
+ executor,
538
+ });
539
+
540
+ records = applySelectFiltering({ records, args, modelMeta });
541
+
542
+ validateOutput({ records, operation: "create" });
543
+ return records[0]!;
544
+ }
545
+
546
+ async function update(args: Record<string, unknown>) {
547
+ const where = args.where as Record<string, unknown>;
548
+ const data = args.data as Record<string, unknown>;
549
+
550
+ // Validate input
551
+ validateInput({ data, schemaKey: "updateInput", operation: "update" });
552
+
553
+ // Auto-inject @updatedAt fields
554
+ injectUpdatedAt({ data, modelMeta });
555
+
556
+ const whereInput = convertUniqueToWhere({
557
+ uniqueWhere: where,
558
+ modelMeta,
559
+ });
560
+
561
+ // Separate scalar data from nested relation operations
562
+ const { scalarData, nestedOps } = separateNestedOps({
563
+ data,
564
+ modelMeta,
565
+ });
566
+
567
+ const query = buildUpdateQuery({
568
+ modelMeta,
569
+ allModelsMeta,
570
+ where: whereInput,
571
+ data: scalarData,
572
+ });
573
+ let records = await executor(query);
574
+
575
+ if (records.length === 0) {
576
+ throw new Error(
577
+ `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.`
578
+ );
579
+ }
580
+
581
+ const updatedRecord = records[0]!;
582
+
583
+ // Process nested relation operations
584
+ if (nestedOps.length > 0) {
585
+ await processNestedUpdateOps({
586
+ parentRecord: updatedRecord,
587
+ nestedOps,
588
+ modelMeta,
589
+ allModelsMeta,
590
+ executor,
591
+ });
592
+ }
593
+
594
+ records = await loadRelationsForStrategy({
595
+ records,
596
+ modelMeta,
597
+ allModelsMeta,
598
+ args,
599
+ executor,
600
+ });
601
+
602
+ records = applySelectFiltering({ records, args, modelMeta });
603
+
604
+ validateOutput({ records, operation: "update" });
605
+ return records[0]!;
606
+ }
607
+
608
+ async function upsert(args: Record<string, unknown>) {
609
+ const where = args.where as Record<string, unknown>;
610
+ const createData = args.create as Record<string, unknown>;
611
+ const updateData = args.update as Record<string, unknown>;
612
+
613
+ // Validate inputs
614
+ validateInput({ data: createData, schemaKey: "createInput", operation: "upsert" });
615
+ validateInput({ data: updateData, schemaKey: "updateInput", operation: "upsert" });
616
+
617
+ // Auto-inject @updatedAt fields and auto-generated defaults for create data
618
+ injectUpdatedAt({ data: createData, modelMeta });
619
+ injectUpdatedAt({ data: updateData, modelMeta });
620
+ injectAutoDefaults({ data: createData, modelMeta, idGenerator: options?.idGenerator });
621
+
622
+ const whereInput = convertUniqueToWhere({
623
+ uniqueWhere: where,
624
+ modelMeta,
625
+ });
626
+
627
+ const query = buildUpsertQuery({
628
+ modelMeta,
629
+ where: whereInput,
630
+ create: createData,
631
+ update: updateData,
632
+ });
633
+
634
+ let records = await executor(query);
635
+
636
+ // If ON CONFLICT DO NOTHING returned no rows (rare edge case),
637
+ // fall back to reading the existing record
638
+ if (records.length === 0) {
639
+ const existing = await findUnique({ where, select: args.select, include: args.include });
640
+ if (existing) return existing;
641
+ throw new Error(`Upsert failed: could not insert or find ${modelMeta.name}`);
642
+ }
643
+
644
+ records = await loadRelationsForStrategy({
645
+ records,
646
+ modelMeta,
647
+ allModelsMeta,
648
+ args,
649
+ executor,
650
+ });
651
+
652
+ records = applySelectFiltering({ records, args, modelMeta });
653
+
654
+ validateOutput({ records, operation: "upsert" });
655
+ return records[0]!;
656
+ }
657
+
658
+ async function del(args: Record<string, unknown>) {
659
+ const where = args.where as Record<string, unknown>;
660
+ const whereInput = convertUniqueToWhere({
661
+ uniqueWhere: where,
662
+ modelMeta,
663
+ });
664
+
665
+ const query = buildDeleteQuery({
666
+ modelMeta,
667
+ allModelsMeta,
668
+ where: whereInput,
669
+ });
670
+ let records = await executor(query);
671
+
672
+ if (records.length === 0) {
673
+ throw new Error(
674
+ `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.`
675
+ );
676
+ }
677
+
678
+ coerceFieldTypes({ records, modelMeta });
679
+ records = applySelectFiltering({ records, args, modelMeta });
680
+
681
+ validateOutput({ records, operation: "delete" });
682
+ return records[0]!;
683
+ }
684
+
685
+ async function deleteMany(args: Record<string, unknown> = {}) {
686
+ const where = (args.where ?? {}) as Record<string, unknown>;
687
+ // Use a CTE with RETURNING 1 to count deleted rows server-side
688
+ // instead of transferring full row payloads over the wire.
689
+ const baseQuery = buildDeleteQuery({
690
+ modelMeta,
691
+ allModelsMeta,
692
+ where,
693
+ });
694
+ // Replace RETURNING <all cols> with RETURNING 1 inside a CTE
695
+ const returningIdx = baseQuery.text.indexOf(" RETURNING ");
696
+ const deleteText = returningIdx >= 0 ? baseQuery.text.slice(0, returningIdx) : baseQuery.text;
697
+ const countQuery = {
698
+ text: `WITH deleted AS (${deleteText} RETURNING 1) SELECT COUNT(*) AS "count" FROM deleted`,
699
+ values: baseQuery.values,
700
+ };
701
+ const result = await executor(countQuery);
702
+ const row = result[0] as { count: string | number } | undefined;
703
+ return { count: Number(row?.count ?? 0) };
704
+ }
705
+
706
+ async function count(args: Record<string, unknown> = {}) {
707
+ const countStrategy = options?.countStrategy ?? "direct";
708
+
709
+ if (shouldDebug) {
710
+ const t0 = performance.now();
711
+ const query = buildCountQuery({ modelMeta, allModelsMeta, args, countStrategy });
712
+ const t1 = performance.now();
713
+ const records = await executor(query);
714
+ const t2 = performance.now();
715
+ const row = records[0] as { count: string | number } | undefined;
716
+ debugEmit({
717
+ model: modelMeta.name, operation: "count",
718
+ totalMs: t2 - t0, queryBuildMs: t1 - t0, sqlExecMs: t2 - t1,
719
+ rowCount: 1, relationLoadMs: 0, relationProfiles: [], sql: query.text,
720
+ resultMapMs: 0, resultSizeBytes: 0,
721
+ });
722
+ return Number(row?.count ?? 0);
723
+ }
724
+
725
+ const query = buildCountQuery({
726
+ modelMeta,
727
+ allModelsMeta,
728
+ args,
729
+ countStrategy,
730
+ });
731
+ const records = await executor(query);
732
+ const row = records[0] as { count: string | number } | undefined;
733
+ return Number(row?.count ?? 0);
734
+ }
735
+
736
+ async function createMany(args: Record<string, unknown>) {
737
+ const rawData = args.data as Record<string, unknown> | Record<string, unknown>[];
738
+ const dataArray = Array.isArray(rawData) ? rawData : [rawData];
739
+
740
+ // Short-circuit: empty data array → no rows inserted
741
+ if (dataArray.length === 0) {
742
+ return { count: 0 };
743
+ }
744
+
745
+ // Validate inputs
746
+ for (const record of dataArray) {
747
+ validateInput({ data: record, schemaKey: "createInput", operation: "createMany" });
748
+ }
749
+
750
+ // Auto-inject @updatedAt fields and auto-generated defaults on each record
751
+ for (const record of dataArray) {
752
+ injectUpdatedAt({ data: record, modelMeta });
753
+ injectAutoDefaults({ data: record, modelMeta, idGenerator: options?.idGenerator });
754
+ }
755
+
756
+ // Count inserted rows server-side using CTE with RETURNING 1.
757
+ // This avoids transferring PK values over the wire just to count them.
758
+ // Handles skipDuplicates correctly since ON CONFLICT DO NOTHING rows
759
+ // won't appear in the RETURNING set.
760
+ const query = buildInsertManyQuery({
761
+ modelMeta,
762
+ data: dataArray,
763
+ skipDuplicates: args.skipDuplicates as boolean | undefined,
764
+ returning: true,
765
+ selectFields: [modelMeta.primaryKey[0]!],
766
+ });
767
+ // Wrap in CTE to count server-side
768
+ const returningIdx = query.text.indexOf(" RETURNING ");
769
+ if (returningIdx >= 0) {
770
+ const insertText = query.text.slice(0, returningIdx);
771
+ const countQuery = {
772
+ text: `WITH inserted AS (${insertText} RETURNING 1) SELECT COUNT(*) AS "count" FROM inserted`,
773
+ values: query.values,
774
+ };
775
+ const result = await executor(countQuery);
776
+ const row = result[0] as { count: string | number } | undefined;
777
+ return { count: Number(row?.count ?? 0) };
778
+ }
779
+ // Fallback: if no RETURNING clause (shouldn't happen), execute as-is
780
+ const result = await executor(query);
781
+ return { count: result.length };
782
+ }
783
+
784
+ async function createManyAndReturn(args: Record<string, unknown>) {
785
+ const rawData = args.data as Record<string, unknown> | Record<string, unknown>[];
786
+ const dataArray = Array.isArray(rawData) ? rawData : [rawData];
787
+
788
+ // Short-circuit: empty data array → no rows inserted
789
+ if (dataArray.length === 0) {
790
+ return [];
791
+ }
792
+
793
+ // Validate inputs
794
+ for (const record of dataArray) {
795
+ validateInput({ data: record, schemaKey: "createInput", operation: "createManyAndReturn" });
796
+ }
797
+
798
+ // Auto-inject @updatedAt fields and auto-generated defaults on each record
799
+ for (const record of dataArray) {
800
+ injectUpdatedAt({ data: record, modelMeta });
801
+ injectAutoDefaults({ data: record, modelMeta, idGenerator: options?.idGenerator });
802
+ }
803
+
804
+ const query = buildInsertManyQuery({
805
+ modelMeta,
806
+ data: dataArray,
807
+ skipDuplicates: args.skipDuplicates as boolean | undefined,
808
+ returning: true,
809
+ });
810
+ let records = await executor(query);
811
+
812
+ records = await loadRelationsForStrategy({
813
+ records,
814
+ modelMeta,
815
+ allModelsMeta,
816
+ args,
817
+ executor,
818
+ });
819
+
820
+ records = applySelectFiltering({ records, args, modelMeta });
821
+
822
+ validateOutput({ records, operation: "createManyAndReturn" });
823
+ return records;
824
+ }
825
+
826
+ async function updateMany(args: Record<string, unknown>) {
827
+ const where = (args.where ?? {}) as Record<string, unknown>;
828
+ const data = args.data as Record<string, unknown>;
829
+
830
+ // Validate input
831
+ validateInput({ data, schemaKey: "updateInput", operation: "updateMany" });
832
+
833
+ // Short-circuit: if user provided no data fields, return 0 immediately
834
+ // (check BEFORE injectUpdatedAt so auto-managed fields don't cause a real UPDATE)
835
+ const userDataKeys = Object.keys(data).filter((k) => data[k] !== undefined);
836
+ if (userDataKeys.length === 0) {
837
+ return { count: 0 };
838
+ }
839
+
840
+ // Auto-inject @updatedAt fields
841
+ injectUpdatedAt({ data, modelMeta });
842
+
843
+ const query = buildUpdateManyQuery({
844
+ modelMeta,
845
+ allModelsMeta,
846
+ where,
847
+ data,
848
+ });
849
+
850
+ // Short-circuit: if builder returned a no-op (empty data), return 0 directly
851
+ // This avoids wrapping `SELECT 0 AS "count"` in an invalid CTE with RETURNING
852
+ if (query.text.startsWith("SELECT 0")) {
853
+ return { count: 0 };
854
+ }
855
+
856
+ // updateMany without RETURNING — we need row count
857
+ // Use a CTE with RETURNING trick to get count
858
+ const countQuery = {
859
+ text: `WITH updated AS (${query.text} RETURNING 1) SELECT COUNT(*) AS "count" FROM updated`,
860
+ values: query.values,
861
+ };
862
+ const result = await executor(countQuery);
863
+ const row = result[0] as { count: string | number } | undefined;
864
+ return { count: Number(row?.count ?? 0) };
865
+ }
866
+
867
+ async function aggregate(args: Record<string, unknown>) {
868
+ const query = buildAggregateQuery({
869
+ modelMeta,
870
+ allModelsMeta,
871
+ args,
872
+ });
873
+ const records = await executor(query);
874
+ const row = records[0] as Record<string, unknown> | undefined;
875
+
876
+ if (!row) {
877
+ return {};
878
+ }
879
+
880
+ // Transform flat row with keys like "_count__all", "_avg__viewCount"
881
+ // into nested structure: { _count: 5, _avg: { viewCount: 45.2 } }
882
+ return parseAggregateResult({ row, args });
883
+ }
884
+
885
+ async function groupBy(args: Record<string, unknown>) {
886
+ const query = buildGroupByQuery({
887
+ modelMeta,
888
+ allModelsMeta,
889
+ args,
890
+ });
891
+ const records = await executor(query);
892
+
893
+ // Transform each row's flat aggregate columns into nested structure
894
+ return records.map((row) => parseAggregateResult({ row, args }));
895
+ }
896
+
897
+ return {
898
+ findMany,
899
+ findFirst,
900
+ findUnique,
901
+ findUniqueOrThrow,
902
+ findFirstOrThrow,
903
+ create,
904
+ createMany,
905
+ createManyAndReturn,
906
+ update,
907
+ upsert,
908
+ delete: del,
909
+ deleteMany,
910
+ updateMany,
911
+ count,
912
+ aggregate,
913
+ groupBy,
914
+ };
915
+ }
916
+
917
+ // Helper: load relations using the configured strategy
918
+ async function loadRelationsForStrategy(params: {
919
+ records: Record<string, unknown>[];
920
+ modelMeta: ModelMeta;
921
+ allModelsMeta: ModelMetaMap;
922
+ args: Record<string, unknown>;
923
+ executor: typeof executeSql;
924
+ profilingCtx?: ProfilingContext;
925
+ }): Promise<Record<string, unknown>[]> {
926
+ // Coerce field types (e.g., BigInt from string) before any processing
927
+ coerceFieldTypes({ records: params.records, modelMeta: params.modelMeta });
928
+
929
+ const strategy =
930
+ (params.args.relationStrategy as string) ??
931
+ options?.relationStrategy ??
932
+ "query";
933
+
934
+ let records: Record<string, unknown>[];
935
+ if (strategy === "join") {
936
+ records = await loadRelationsWithLateralJoin({
937
+ parentRecords: params.records,
938
+ parentModelMeta: params.modelMeta,
939
+ allModelsMeta: params.allModelsMeta,
940
+ args: params.args,
941
+ executor: params.executor,
942
+ profilingCtx: params.profilingCtx,
943
+ });
944
+ } else {
945
+ records = await loadRelations({
946
+ parentRecords: params.records,
947
+ parentModelMeta: params.modelMeta,
948
+ allModelsMeta: params.allModelsMeta,
949
+ args: params.args,
950
+ executor: params.executor,
951
+ profilingCtx: params.profilingCtx,
952
+ });
953
+ }
954
+
955
+ // Load _count if requested in include or select
956
+ const countSpec = resolveCountSpec({ args: params.args });
957
+ if (countSpec && records.length > 0) {
958
+ await loadRelationCounts({
959
+ records,
960
+ modelMeta: params.modelMeta,
961
+ allModelsMeta: params.allModelsMeta,
962
+ countSpec,
963
+ executor: params.executor,
964
+ });
965
+ }
966
+
967
+ return records;
968
+ }
969
+
970
+ // Build the client object with delegates for each model
971
+ const client: Record<string, unknown> = {};
972
+
973
+ for (const [key, meta] of Object.entries(allModelsMeta)) {
974
+ client[key] = createDelegate({
975
+ modelKey: key,
976
+ modelMeta: meta,
977
+ executor: executeSql,
978
+ schemas: params.schemas?.[key],
979
+ });
980
+ }
981
+
982
+ // $transaction — supports both callback style and array-of-promises style
983
+ client.$transaction = async function <T>(
984
+ fnOrPromises: ((tx: Record<string, unknown>) => Promise<T>) | Promise<unknown>[]
985
+ ): Promise<T | unknown[]> {
986
+ // Array-of-promises style
987
+ if (Array.isArray(fnOrPromises)) {
988
+ return adapter.transaction(async () => {
989
+ return Promise.all(fnOrPromises);
990
+ });
991
+ }
992
+
993
+ // Callback style
994
+ const fn = fnOrPromises as (tx: Record<string, unknown>) => Promise<T>;
995
+ return adapter.transaction(async (txAdapter) => {
996
+ // Create a transactional executor
997
+ async function txExecutor(txParams: { text: string; values: unknown[] }): Promise<Record<string, unknown>[]> {
998
+ const values = txParams.values.map((v) => (v instanceof PgArray ? txAdapter.formatArrayParam(v.values) : v));
999
+ if (shouldLog) {
1000
+ console.log(`[vibeorm:tx] ${txParams.text}`);
1001
+ if (values.length > 0) {
1002
+ console.log(`[vibeorm:tx] params:`, values);
1003
+ }
1004
+ }
1005
+ return txAdapter.execute({ text: txParams.text, values });
1006
+ }
1007
+
1008
+ // Build transactional delegates
1009
+ const txClient: Record<string, unknown> = {};
1010
+ for (const [key, meta] of Object.entries(allModelsMeta)) {
1011
+ txClient[key] = createDelegate({
1012
+ modelKey: key,
1013
+ modelMeta: meta,
1014
+ executor: txExecutor,
1015
+ schemas: params.schemas?.[key],
1016
+ });
1017
+ }
1018
+
1019
+ // Add $queryRaw and $executeRaw to transactional client
1020
+ txClient.$queryRaw = async function <T = unknown>(
1021
+ strings: TemplateStringsArray,
1022
+ ...values: unknown[]
1023
+ ): Promise<T[]> {
1024
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1025
+ if (shouldLog) {
1026
+ console.log(`[vibeorm:tx] ${text}`);
1027
+ if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1028
+ }
1029
+ const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1030
+ return result.rows as T[];
1031
+ };
1032
+
1033
+ txClient.$executeRaw = async function (
1034
+ strings: TemplateStringsArray,
1035
+ ...values: unknown[]
1036
+ ): Promise<number> {
1037
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1038
+ if (shouldLog) {
1039
+ console.log(`[vibeorm:tx] ${text}`);
1040
+ if (sqlParams.length > 0) console.log(`[vibeorm:tx] params:`, sqlParams);
1041
+ }
1042
+ const result = await txAdapter.executeUnsafe({ text, values: sqlParams });
1043
+ return result.affectedRows;
1044
+ };
1045
+
1046
+ return fn(txClient);
1047
+ });
1048
+ };
1049
+
1050
+ // $queryRaw — tagged template literal for safe parameterized queries
1051
+ client.$queryRaw = async function <T = unknown>(
1052
+ strings: TemplateStringsArray,
1053
+ ...values: unknown[]
1054
+ ): Promise<T[]> {
1055
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1056
+ if (shouldLog) {
1057
+ console.log(`[vibeorm:raw] ${text}`);
1058
+ if (sqlParams.length > 0) {
1059
+ console.log(`[vibeorm:raw] params:`, sqlParams);
1060
+ }
1061
+ }
1062
+ const result = await adapter.executeUnsafe({ text, values: sqlParams });
1063
+ return result.rows as T[];
1064
+ };
1065
+
1066
+ // $executeRaw — tagged template literal for INSERT/UPDATE/DELETE returning affected count
1067
+ client.$executeRaw = async function (
1068
+ strings: TemplateStringsArray,
1069
+ ...values: unknown[]
1070
+ ): Promise<number> {
1071
+ const { text, params: sqlParams } = buildTaggedTemplateSql({ strings, values });
1072
+ if (shouldLog) {
1073
+ console.log(`[vibeorm:raw] ${text}`);
1074
+ if (sqlParams.length > 0) {
1075
+ console.log(`[vibeorm:raw] params:`, sqlParams);
1076
+ }
1077
+ }
1078
+ const result = await adapter.executeUnsafe({ text, values: sqlParams });
1079
+ return result.affectedRows;
1080
+ };
1081
+
1082
+ // $queryRawUnsafe — accepts a plain SQL string + params array
1083
+ client.$queryRawUnsafe = async function <T = unknown>(
1084
+ query: string,
1085
+ ...values: unknown[]
1086
+ ): Promise<T[]> {
1087
+ if (shouldLog) {
1088
+ console.log(`[vibeorm:raw] ${query}`);
1089
+ if (values.length > 0) {
1090
+ console.log(`[vibeorm:raw] params:`, values);
1091
+ }
1092
+ }
1093
+ if (values.length === 0) {
1094
+ const result = await adapter.executeUnsafe({ text: query });
1095
+ return result.rows as T[];
1096
+ }
1097
+ const rows = await adapter.execute({ text: query, values });
1098
+ return rows as T[];
1099
+ };
1100
+
1101
+ // $executeRawUnsafe — accepts a plain SQL string + params array, returns affected count
1102
+ client.$executeRawUnsafe = async function (
1103
+ query: string,
1104
+ ...values: unknown[]
1105
+ ): Promise<number> {
1106
+ if (shouldLog) {
1107
+ console.log(`[vibeorm:raw] ${query}`);
1108
+ if (values.length > 0) {
1109
+ console.log(`[vibeorm:raw] params:`, values);
1110
+ }
1111
+ }
1112
+ const result = await adapter.executeUnsafe({ text: query, values: values.length > 0 ? values : undefined });
1113
+ return result.affectedRows;
1114
+ };
1115
+
1116
+ // $connect
1117
+ client.$connect = async function (): Promise<void> {
1118
+ await adapter.connect();
1119
+ };
1120
+
1121
+ // $disconnect
1122
+ client.$disconnect = async function (): Promise<void> {
1123
+ await adapter.disconnect();
1124
+ };
1125
+
1126
+ // Eager connection: warm up the pool immediately on client creation.
1127
+ // This runs in the background — it won't block client construction,
1128
+ // but will ensure connections are ready before the first real query.
1129
+ if (options.eager) {
1130
+ adapter.connect().catch(() => {
1131
+ // Swallow connection errors during eager warmup.
1132
+ // They'll surface on the first actual query instead.
1133
+ });
1134
+ }
1135
+
1136
+ return client;
1137
+ }
1138
+
1139
+ /**
1140
+ * Coerce scalar field values to their correct JS types.
1141
+ * Currently handles BigInt fields: bun:sql returns PostgreSQL bigint as string,
1142
+ * but the application expects native BigInt values.
1143
+ */
1144
+ function coerceFieldTypes(params: {
1145
+ records: Record<string, unknown>[];
1146
+ modelMeta: ModelMeta;
1147
+ }): Record<string, unknown>[] {
1148
+ const { records, modelMeta } = params;
1149
+ if (records.length === 0) return records;
1150
+
1151
+ // Find fields that need coercion
1152
+ const bigintFields = modelMeta.scalarFields.filter(
1153
+ (f) => (f as { type?: string }).type === "BigInt"
1154
+ );
1155
+
1156
+ if (bigintFields.length === 0) return records;
1157
+
1158
+ for (const record of records) {
1159
+ for (const field of bigintFields) {
1160
+ const val = record[field.name];
1161
+ if (typeof val === "string") {
1162
+ record[field.name] = BigInt(val);
1163
+ } else if (typeof val === "number") {
1164
+ record[field.name] = BigInt(val);
1165
+ }
1166
+ }
1167
+ }
1168
+
1169
+ return records;
1170
+ }
1171
+
1172
+ /**
1173
+ * Apply select filtering to returned records.
1174
+ * When args.select is specified, strips fields not in the select object
1175
+ * from each record. This ensures mutation results (create, update, delete, upsert)
1176
+ * only contain the selected scalar fields (plus any relation fields loaded separately).
1177
+ */
1178
+ function applySelectFiltering(params: {
1179
+ records: Record<string, unknown>[];
1180
+ args: Record<string, unknown>;
1181
+ modelMeta: ModelMeta;
1182
+ }): Record<string, unknown>[] {
1183
+ const { records, args, modelMeta } = params;
1184
+ const select = args.select as Record<string, boolean | object> | undefined;
1185
+ if (!select) return records;
1186
+
1187
+ // Build set of allowed scalar field names
1188
+ const allowedFields = new Set<string>();
1189
+ for (const [key, val] of Object.entries(select)) {
1190
+ if (val === true || (typeof val === "object" && val !== null)) {
1191
+ allowedFields.add(key);
1192
+ }
1193
+ }
1194
+
1195
+ // Also allow relation fields that were loaded (they won't be in scalarFields)
1196
+ const scalarFieldNames = new Set(modelMeta.scalarFields.map((f) => f.name));
1197
+
1198
+ return records.map((record) => {
1199
+ const filtered: Record<string, unknown> = {};
1200
+ for (const [key, value] of Object.entries(record)) {
1201
+ if (allowedFields.has(key)) {
1202
+ filtered[key] = value;
1203
+ } else if (!scalarFieldNames.has(key)) {
1204
+ // Keep non-scalar fields that aren't in the model (e.g., _count, relation data)
1205
+ // Only strip scalar fields that weren't selected
1206
+ filtered[key] = value;
1207
+ }
1208
+ }
1209
+ return filtered;
1210
+ });
1211
+ }
1212
+
1213
+ // ─── Helpers ──────────────────────────────────────────────────────
1214
+
1215
+ /**
1216
+ * Convert a WhereUniqueInput (e.g. { id: 1 } or { email: "test@test.com" })
1217
+ * to a WhereInput format suitable for the where builder.
1218
+ *
1219
+ * Handles compound unique keys: { userId_planId: { userId: 1, planId: 2 } }
1220
+ * is expanded to { userId: 1, planId: 2 }.
1221
+ */
1222
+ function convertUniqueToWhere(params: {
1223
+ uniqueWhere: Record<string, unknown>;
1224
+ modelMeta: ModelMeta;
1225
+ }): Record<string, unknown> {
1226
+ const { uniqueWhere, modelMeta } = params;
1227
+ const result: Record<string, unknown> = {};
1228
+
1229
+ for (const [key, value] of Object.entries(uniqueWhere)) {
1230
+ if (value === undefined) continue;
1231
+
1232
+ // Check if this is a scalar field — pass through directly
1233
+ const isScalar = modelMeta.scalarFields.some((f) => f.name === key);
1234
+ if (isScalar) {
1235
+ result[key] = value;
1236
+ continue;
1237
+ }
1238
+
1239
+ // Check if this is a compound unique key (e.g. "userId_planId")
1240
+ // These contain an object with the individual field values
1241
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
1242
+ const compound = value as Record<string, unknown>;
1243
+ // Verify all sub-keys are scalar fields on this model
1244
+ const allScalar = Object.keys(compound).every((k) =>
1245
+ modelMeta.scalarFields.some((f) => f.name === k)
1246
+ );
1247
+ if (allScalar) {
1248
+ // Expand compound key into individual field conditions
1249
+ for (const [subKey, subValue] of Object.entries(compound)) {
1250
+ result[subKey] = subValue;
1251
+ }
1252
+ continue;
1253
+ }
1254
+ }
1255
+
1256
+ // Pass through anything else (e.g. relation fields in select/include context)
1257
+ result[key] = value;
1258
+ }
1259
+
1260
+ return result;
1261
+ }
1262
+
1263
+ /**
1264
+ * Auto-inject current timestamp for fields with @updatedAt.
1265
+ */
1266
+ function injectUpdatedAt(params: {
1267
+ data: Record<string, unknown>;
1268
+ modelMeta: ModelMeta;
1269
+ }): void {
1270
+ for (const sf of params.modelMeta.scalarFields) {
1271
+ if (sf.isUpdatedAt && params.data[sf.name] === undefined) {
1272
+ params.data[sf.name] = new Date();
1273
+ }
1274
+ }
1275
+ }
1276
+
1277
+ /**
1278
+ * Auto-inject generated values for fields with @default(uuid/cuid/nanoid/ulid)
1279
+ * when the user hasn't provided a value.
1280
+ *
1281
+ * If a custom idGenerator is provided in options, it takes precedence.
1282
+ * If the field has an idPrefix (from /// @vibeorm.idPrefix("...")), it is prepended.
1283
+ */
1284
+ function injectAutoDefaults(params: {
1285
+ data: Record<string, unknown>;
1286
+ modelMeta: ModelMeta;
1287
+ idGenerator?: (params: { model: string; field: string; defaultKind: string }) => string;
1288
+ }): void {
1289
+ const { data, modelMeta, idGenerator } = params;
1290
+ for (const sf of modelMeta.scalarFields) {
1291
+ // Only inject for app-level defaults when the field is not provided
1292
+ if (!sf.hasDefault || sf.hasDefault === true) continue;
1293
+ if (data[sf.name] !== undefined) continue;
1294
+
1295
+ let value: string | undefined;
1296
+
1297
+ if (idGenerator) {
1298
+ value = idGenerator({ model: modelMeta.name, field: sf.name, defaultKind: sf.hasDefault });
1299
+ } else {
1300
+ value = generateDefault({ kind: sf.hasDefault });
1301
+ }
1302
+
1303
+ if (value !== undefined) {
1304
+ // Prepend idPrefix if configured
1305
+ if (sf.idPrefix) {
1306
+ value = sf.idPrefix + value;
1307
+ }
1308
+ data[sf.name] = value;
1309
+ }
1310
+ }
1311
+ }
1312
+
1313
+ /**
1314
+ * Build parameterized SQL from a tagged template literal.
1315
+ * Converts: sql`SELECT * FROM "User" WHERE id = ${1}`
1316
+ * Into: { text: 'SELECT * FROM "User" WHERE id = $1', params: [1] }
1317
+ */
1318
+ function buildTaggedTemplateSql(params: {
1319
+ strings: TemplateStringsArray;
1320
+ values: unknown[];
1321
+ }): { text: string; params: unknown[] } {
1322
+ const { strings, values } = params;
1323
+ let text = strings[0]!;
1324
+ const sqlParams: unknown[] = [];
1325
+
1326
+ for (let i = 0; i < values.length; i++) {
1327
+ sqlParams.push(values[i]);
1328
+ text += `$${i + 1}${strings[i + 1] ?? ""}`;
1329
+ }
1330
+
1331
+ return { text, params: sqlParams };
1332
+ }
1333
+
1334
+ /**
1335
+ * Parse a flat aggregate result row into the nested Prisma-style structure.
1336
+ * Converts: { "_count__all": 5, "_avg__viewCount": 45.2, "authorId": 1 }
1337
+ * Into: { _count: 5, _avg: { viewCount: 45.2 }, authorId: 1 }
1338
+ */
1339
+ function parseAggregateResult(params: {
1340
+ row: Record<string, unknown>;
1341
+ args: Record<string, unknown>;
1342
+ }): Record<string, unknown> {
1343
+ const { row, args } = params;
1344
+ const result: Record<string, unknown> = {};
1345
+
1346
+ for (const [key, value] of Object.entries(row)) {
1347
+ if (key.startsWith("_") && key.includes("__")) {
1348
+ // Aggregate column: "_count__all", "_avg__viewCount"
1349
+ const [aggFn, field] = key.split("__") as [string, string];
1350
+
1351
+ if (aggFn === "_count") {
1352
+ // _count: true returns a number, _count: { field: true } returns an object
1353
+ const countArg = args._count;
1354
+ if (countArg === true) {
1355
+ result._count = Number(value ?? 0);
1356
+ } else {
1357
+ if (!result._count || typeof result._count !== "object") {
1358
+ result._count = {};
1359
+ }
1360
+ if (field === "all") {
1361
+ (result._count as Record<string, unknown>)._all = Number(value ?? 0);
1362
+ } else {
1363
+ (result._count as Record<string, unknown>)[field!] = Number(value ?? 0);
1364
+ }
1365
+ }
1366
+ } else {
1367
+ // _avg, _sum, _min, _max — always nested objects
1368
+ if (!result[aggFn!]) {
1369
+ result[aggFn!] = {};
1370
+ }
1371
+ let parsed: unknown = value;
1372
+ if (value !== null && value !== undefined) {
1373
+ const asNum = Number(value);
1374
+ parsed = Number.isNaN(asNum) ? value : asNum;
1375
+ }
1376
+ (result[aggFn!] as Record<string, unknown>)[field!] = parsed;
1377
+ }
1378
+ } else {
1379
+ // Regular field (for groupBy results)
1380
+ result[key] = value;
1381
+ }
1382
+ }
1383
+
1384
+ return result;
1385
+ }
1386
+
1387
+ /**
1388
+ * Resolve _count specification from include or select args.
1389
+ * Returns the list of relation names to count, or null if _count not requested.
1390
+ */
1391
+ function resolveCountSpec(params: { args: Record<string, unknown> }): string[] | null {
1392
+ const { args } = params;
1393
+ const include = args.include as Record<string, unknown> | undefined;
1394
+ const select = args.select as Record<string, unknown> | undefined;
1395
+
1396
+ const countArg = include?._count ?? select?._count;
1397
+ if (!countArg) return null;
1398
+
1399
+ if (countArg === true) {
1400
+ // Count all list relations — will be resolved by loadRelationCounts
1401
+ return ["__all__"];
1402
+ }
1403
+
1404
+ if (typeof countArg === "object" && countArg !== null) {
1405
+ const countObj = countArg as Record<string, unknown>;
1406
+ const selectObj = countObj.select as Record<string, boolean> | undefined;
1407
+ if (selectObj) {
1408
+ return Object.entries(selectObj)
1409
+ .filter(([_, enabled]) => enabled)
1410
+ .map(([name]) => name);
1411
+ }
1412
+ }
1413
+
1414
+ return null;
1415
+ }
1416
+
1417
+ /**
1418
+ * Load relation counts and attach _count object to each record.
1419
+ * Uses COUNT subqueries grouped by parent FK.
1420
+ */
1421
+ async function loadRelationCounts(params: {
1422
+ records: Record<string, unknown>[];
1423
+ modelMeta: ModelMeta;
1424
+ allModelsMeta: ModelMetaMap;
1425
+ countSpec: string[];
1426
+ executor: (params: { text: string; values: unknown[] }) => Promise<Record<string, unknown>[]>;
1427
+ }): Promise<void> {
1428
+ const { records, modelMeta, allModelsMeta, countSpec, executor } = params;
1429
+ const modelMap = getModelByNameMap({ allModelsMeta });
1430
+ const parentPk = modelMeta.primaryKey[0];
1431
+ if (!parentPk) return;
1432
+
1433
+ const parentIds = records.map((r) => r[parentPk]).filter((id) => id != null);
1434
+ if (parentIds.length === 0) return;
1435
+
1436
+ // Resolve which relations to count
1437
+ const listRelations = modelMeta.relationFields.filter((r) => r.isList);
1438
+ const relationsToCount = countSpec.includes("__all__")
1439
+ ? listRelations
1440
+ : listRelations.filter((r) => countSpec.includes(r.name));
1441
+
1442
+ // Initialize _count on all records
1443
+ for (const record of records) {
1444
+ const countObj: Record<string, number> = {};
1445
+ for (const rel of relationsToCount) {
1446
+ countObj[rel.name] = 0;
1447
+ }
1448
+ record._count = countObj;
1449
+ }
1450
+
1451
+ // Run all relation COUNT queries in parallel — each hits a different table
1452
+ // so there are no data races on the parent records.
1453
+ await Promise.all(
1454
+ relationsToCount.map(async (rel) => {
1455
+ const relatedModelMeta = modelMap.get(rel.relatedModel);
1456
+ if (!relatedModelMeta) return;
1457
+
1458
+ // M:N relation: count via join table
1459
+ if (rel.type === "manyToMany" && (rel as { joinTable?: string }).joinTable) {
1460
+ const joinTableName = (rel as { joinTable?: string }).joinTable!;
1461
+ const sorted = [modelMeta.name, relatedModelMeta.name].sort();
1462
+ const parentIsA = modelMeta.name === sorted[0];
1463
+ const parentCol = parentIsA ? "A" : "B";
1464
+
1465
+ const text = `SELECT "${joinTableName}"."${parentCol}" AS "__fk", COUNT(*) AS "__count" FROM "${joinTableName}" WHERE "${joinTableName}"."${parentCol}" = ANY($1) GROUP BY "${joinTableName}"."${parentCol}"`;
1466
+ const result = await executor({ text, values: [new PgArray(parentIds)] });
1467
+
1468
+ const countMap = new Map<unknown, number>();
1469
+ for (const row of result) {
1470
+ countMap.set(row.__fk, Number(row.__count ?? 0));
1471
+ }
1472
+
1473
+ for (const record of records) {
1474
+ const pkValue = record[parentPk];
1475
+ const cnt = countMap.get(pkValue) ?? 0;
1476
+ (record._count as Record<string, number>)[rel.name] = cnt;
1477
+ }
1478
+ return;
1479
+ }
1480
+
1481
+ // Find the FK column on the related model (with relationName disambiguation)
1482
+ const reverseRel = relatedModelMeta.relationFields.find(
1483
+ (r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0 &&
1484
+ (!rel.relationName || r.relationName === rel.relationName)
1485
+ );
1486
+ if (!reverseRel) return;
1487
+
1488
+ const fkField = reverseRel.fields[0]!;
1489
+ const relatedSfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
1490
+ const fkScalar = relatedSfMap.get(fkField);
1491
+ const fkDbName = fkScalar?.dbName ?? fkField;
1492
+ const relatedTable = `"${relatedModelMeta.dbName}"`;
1493
+
1494
+ // Build: SELECT "fk" AS "__fk", COUNT(*) AS "__count" FROM "related" WHERE "fk" = ANY($1) GROUP BY "fk"
1495
+ const text = `SELECT ${relatedTable}."${fkDbName}" AS "__fk", COUNT(*) AS "__count" FROM ${relatedTable} WHERE ${relatedTable}."${fkDbName}" = ANY($1) GROUP BY ${relatedTable}."${fkDbName}"`;
1496
+ const result = await executor({ text, values: [new PgArray(parentIds)] });
1497
+
1498
+ // Map counts back to parent records
1499
+ const countMap = new Map<unknown, number>();
1500
+ for (const row of result) {
1501
+ countMap.set(row.__fk, Number(row.__count ?? 0));
1502
+ }
1503
+
1504
+ for (const record of records) {
1505
+ const pkValue = record[parentPk];
1506
+ const cnt = countMap.get(pkValue) ?? 0;
1507
+ (record._count as Record<string, number>)[rel.name] = cnt;
1508
+ }
1509
+ })
1510
+ );
1511
+ }
1512
+
1513
+ type NestedOp = {
1514
+ relationField: ModelMeta["relationFields"][number];
1515
+ ops: Record<string, unknown>;
1516
+ };
1517
+
1518
+ /**
1519
+ * Separate scalar data from nested relation operations in update data.
1520
+ */
1521
+ function separateNestedOps(params: {
1522
+ data: Record<string, unknown>;
1523
+ modelMeta: ModelMeta;
1524
+ }): { scalarData: Record<string, unknown>; nestedOps: NestedOp[] } {
1525
+ const { data, modelMeta } = params;
1526
+ const scalarData: Record<string, unknown> = {};
1527
+ const nestedOps: NestedOp[] = [];
1528
+
1529
+ for (const [key, value] of Object.entries(data)) {
1530
+ if (value === undefined) continue;
1531
+
1532
+ const relationField = modelMeta.relationFields.find((f) => f.name === key);
1533
+ if (relationField && typeof value === "object" && value !== null && !Array.isArray(value) && !(value instanceof Date)) {
1534
+ nestedOps.push({ relationField, ops: value as Record<string, unknown> });
1535
+ } else {
1536
+ scalarData[key] = value;
1537
+ }
1538
+ }
1539
+
1540
+ return { scalarData, nestedOps };
1541
+ }
1542
+
1543
+ /**
1544
+ * Process nested relation operations for an update.
1545
+ * Handles: connect, disconnect, create, delete, set, connectOrCreate, update, upsert, updateMany, deleteMany
1546
+ */
1547
+ async function processNestedUpdateOps(params: {
1548
+ parentRecord: Record<string, unknown>;
1549
+ nestedOps: NestedOp[];
1550
+ modelMeta: ModelMeta;
1551
+ allModelsMeta: ModelMetaMap;
1552
+ executor: (params: { text: string; values: unknown[] }) => Promise<Record<string, unknown>[]>;
1553
+ }): Promise<void> {
1554
+ const { parentRecord, nestedOps, modelMeta, allModelsMeta, executor } = params;
1555
+ const modelMap = getModelByNameMap({ allModelsMeta });
1556
+
1557
+ for (const { relationField, ops } of nestedOps) {
1558
+ const relatedModelMeta = modelMap.get(relationField.relatedModel);
1559
+ if (!relatedModelMeta) continue;
1560
+
1561
+ const parentPk = modelMeta.primaryKey[0];
1562
+ if (!parentPk) continue;
1563
+ const parentId = parentRecord[parentPk];
1564
+
1565
+ // To-one relation where parent holds FK (e.g., Post.author / Post.authorId)
1566
+ if (!relationField.isList && relationField.isForeignKey && relationField.fields.length > 0) {
1567
+ const fkField = relationField.fields[0]!;
1568
+ const refField = relationField.references[0]!;
1569
+
1570
+ if (ops.connect) {
1571
+ const connectData = ops.connect as Record<string, unknown>;
1572
+ const fkValue = connectData[refField];
1573
+ if (fkValue !== undefined) {
1574
+ const query = buildUpdateQuery({
1575
+ modelMeta,
1576
+ allModelsMeta,
1577
+ where: { [parentPk]: parentId },
1578
+ data: { [fkField]: fkValue },
1579
+ });
1580
+ await executor(query);
1581
+ }
1582
+ }
1583
+
1584
+ if (ops.disconnect === true) {
1585
+ const query = buildUpdateQuery({
1586
+ modelMeta,
1587
+ allModelsMeta,
1588
+ where: { [parentPk]: parentId },
1589
+ data: { [fkField]: null },
1590
+ });
1591
+ await executor(query);
1592
+ }
1593
+
1594
+ if (ops.delete === true) {
1595
+ // First read FK, then delete the related record, then null the FK
1596
+ const fkValue = parentRecord[fkField];
1597
+ if (fkValue != null) {
1598
+ // Null out FK first
1599
+ const nullQuery = buildUpdateQuery({
1600
+ modelMeta,
1601
+ allModelsMeta,
1602
+ where: { [parentPk]: parentId },
1603
+ data: { [fkField]: null },
1604
+ });
1605
+ await executor(nullQuery);
1606
+ // Then delete the related record
1607
+ const delQuery = buildDeleteQuery({
1608
+ modelMeta: relatedModelMeta,
1609
+ allModelsMeta,
1610
+ where: { [refField]: fkValue },
1611
+ });
1612
+ await executor(delQuery);
1613
+ }
1614
+ }
1615
+
1616
+ if (ops.create) {
1617
+ const createData = ops.create as Record<string, unknown>;
1618
+ const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: createData });
1619
+ const insertedRows = await executor(insertQuery);
1620
+ const inserted = insertedRows[0];
1621
+ if (inserted) {
1622
+ const relatedPk = relatedModelMeta.primaryKey[0]!;
1623
+ const query = buildUpdateQuery({
1624
+ modelMeta,
1625
+ allModelsMeta,
1626
+ where: { [parentPk]: parentId },
1627
+ data: { [fkField]: inserted[relatedPk] },
1628
+ });
1629
+ await executor(query);
1630
+ }
1631
+ }
1632
+
1633
+ // connectOrCreate: find existing or create, then set FK on parent
1634
+ if (ops.connectOrCreate) {
1635
+ const { where: corWhere, create: corCreate } = ops.connectOrCreate as { where: Record<string, unknown>; create: Record<string, unknown> };
1636
+ const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: corWhere, take: 1 } });
1637
+ const existing = await executor(findQuery);
1638
+ if (existing.length > 0) {
1639
+ const fkValue = existing[0]![refField];
1640
+ if (fkValue !== undefined) {
1641
+ const query = buildUpdateQuery({ modelMeta, allModelsMeta, where: { [parentPk]: parentId }, data: { [fkField]: fkValue } });
1642
+ await executor(query);
1643
+ }
1644
+ } else {
1645
+ const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: corCreate });
1646
+ const insertedRows = await executor(insertQuery);
1647
+ const inserted = insertedRows[0];
1648
+ if (inserted) {
1649
+ const relatedPk = relatedModelMeta.primaryKey[0]!;
1650
+ const query = buildUpdateQuery({ modelMeta, allModelsMeta, where: { [parentPk]: parentId }, data: { [fkField]: inserted[relatedPk] } });
1651
+ await executor(query);
1652
+ }
1653
+ }
1654
+ }
1655
+
1656
+ // update (nested): update the currently connected related record
1657
+ if (ops.update) {
1658
+ const updateData = ops.update as Record<string, unknown>;
1659
+ const fkValue = parentRecord[fkField];
1660
+ if (fkValue != null) {
1661
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [refField]: fkValue }, data: updateData });
1662
+ await executor(query);
1663
+ }
1664
+ }
1665
+
1666
+ // upsert: update related if connected, create if not
1667
+ if (ops.upsert) {
1668
+ const { create: upsCreate, update: upsUpdate } = ops.upsert as { create: Record<string, unknown>; update: Record<string, unknown> };
1669
+ const fkValue = parentRecord[fkField];
1670
+ if (fkValue != null) {
1671
+ // Related record exists — update it
1672
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [refField]: fkValue }, data: upsUpdate });
1673
+ await executor(query);
1674
+ } else {
1675
+ // No related record — create one and set FK on parent
1676
+ const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: upsCreate });
1677
+ const insertedRows = await executor(insertQuery);
1678
+ const inserted = insertedRows[0];
1679
+ if (inserted) {
1680
+ const relatedPk = relatedModelMeta.primaryKey[0]!;
1681
+ const query = buildUpdateQuery({ modelMeta, allModelsMeta, where: { [parentPk]: parentId }, data: { [fkField]: inserted[relatedPk] } });
1682
+ await executor(query);
1683
+ }
1684
+ }
1685
+ }
1686
+
1687
+ continue;
1688
+ }
1689
+
1690
+ // To-one relation where related holds FK (e.g., User.profile where Profile.userId → User.id)
1691
+ if (!relationField.isList && !relationField.isForeignKey) {
1692
+ const reverseRel = relatedModelMeta.relationFields.find(
1693
+ (r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
1694
+ );
1695
+ if (!reverseRel) continue;
1696
+ const reverseFk = reverseRel.fields[0]!;
1697
+ const reverseRef = reverseRel.references[0]!;
1698
+ const parentRefValue = parentRecord[reverseRef];
1699
+
1700
+ if (ops.connect) {
1701
+ const connectData = ops.connect as Record<string, unknown>;
1702
+ const relatedPk = relatedModelMeta.primaryKey[0]!;
1703
+ const relatedId = connectData[relatedPk];
1704
+ if (relatedId !== undefined) {
1705
+ const query = buildUpdateQuery({
1706
+ modelMeta: relatedModelMeta,
1707
+ allModelsMeta,
1708
+ where: { [relatedPk]: relatedId },
1709
+ data: { [reverseFk]: parentRefValue },
1710
+ });
1711
+ await executor(query);
1712
+ }
1713
+ }
1714
+
1715
+ if (ops.disconnect === true) {
1716
+ const query = buildUpdateManyQuery({
1717
+ modelMeta: relatedModelMeta,
1718
+ allModelsMeta,
1719
+ where: { [reverseFk]: parentRefValue },
1720
+ data: { [reverseFk]: null },
1721
+ });
1722
+ await executor(query);
1723
+ }
1724
+
1725
+ if (ops.delete === true) {
1726
+ const query = buildDeleteQuery({
1727
+ modelMeta: relatedModelMeta,
1728
+ allModelsMeta,
1729
+ where: { [reverseFk]: parentRefValue },
1730
+ });
1731
+ await executor(query);
1732
+ }
1733
+
1734
+ if (ops.create) {
1735
+ const createData = ops.create as Record<string, unknown>;
1736
+ (createData as Record<string, unknown>)[reverseFk] = parentRefValue;
1737
+ const query = buildInsertQuery({ modelMeta: relatedModelMeta, data: createData });
1738
+ await executor(query);
1739
+ }
1740
+
1741
+ // connectOrCreate: find existing or create, then set FK on related record
1742
+ if (ops.connectOrCreate) {
1743
+ const { where: corWhere, create: corCreate } = ops.connectOrCreate as { where: Record<string, unknown>; create: Record<string, unknown> };
1744
+ const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: corWhere, take: 1 } });
1745
+ const existing = await executor(findQuery);
1746
+ if (existing.length > 0) {
1747
+ const relatedPk = relatedModelMeta.primaryKey[0]!;
1748
+ const relatedId = existing[0]![relatedPk];
1749
+ if (relatedId !== undefined) {
1750
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [relatedPk]: relatedId }, data: { [reverseFk]: parentRefValue } });
1751
+ await executor(query);
1752
+ }
1753
+ } else {
1754
+ (corCreate as Record<string, unknown>)[reverseFk] = parentRefValue;
1755
+ const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: corCreate });
1756
+ await executor(insertQuery);
1757
+ }
1758
+ }
1759
+
1760
+ // update (nested): update the related record that points to this parent
1761
+ if (ops.update) {
1762
+ const updateData = ops.update as Record<string, unknown>;
1763
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [reverseFk]: parentRefValue }, data: updateData });
1764
+ await executor(query);
1765
+ }
1766
+
1767
+ // upsert: update related if exists, create if not
1768
+ if (ops.upsert) {
1769
+ const { create: upsCreate, update: upsUpdate } = ops.upsert as { create: Record<string, unknown>; update: Record<string, unknown> };
1770
+ // Check if related record exists
1771
+ const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: { [reverseFk]: parentRefValue }, take: 1 } });
1772
+ const existing = await executor(findQuery);
1773
+ if (existing.length > 0) {
1774
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [reverseFk]: parentRefValue }, data: upsUpdate });
1775
+ await executor(query);
1776
+ } else {
1777
+ (upsCreate as Record<string, unknown>)[reverseFk] = parentRefValue;
1778
+ const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: upsCreate });
1779
+ await executor(insertQuery);
1780
+ }
1781
+ }
1782
+
1783
+ continue;
1784
+ }
1785
+
1786
+ // To-many relation (e.g., User.posts)
1787
+ if (relationField.isList) {
1788
+ const reverseRel = relatedModelMeta.relationFields.find(
1789
+ (r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
1790
+ );
1791
+ if (!reverseRel) continue;
1792
+ const reverseFk = reverseRel.fields[0]!;
1793
+ const reverseRef = reverseRel.references[0]!;
1794
+ const parentRefValue = parentRecord[reverseRef];
1795
+ const relatedPk = relatedModelMeta.primaryKey[0]!;
1796
+
1797
+ // set: replace all — disconnect all, then connect the specified ones
1798
+ if (ops.set) {
1799
+ const setItems = ops.set as Record<string, unknown>[];
1800
+ // Disconnect all existing
1801
+ const disconnectAllQuery = buildUpdateManyQuery({
1802
+ modelMeta: relatedModelMeta,
1803
+ allModelsMeta,
1804
+ where: { [reverseFk]: parentRefValue },
1805
+ data: { [reverseFk]: null },
1806
+ });
1807
+ await executor(disconnectAllQuery);
1808
+ // Batch connect: single UPDATE ... SET fk = $1 WHERE pk = ANY($2)
1809
+ const setIds = setItems.map((item) => item[relatedPk]).filter((id) => id !== undefined);
1810
+ if (setIds.length > 0) {
1811
+ const query = buildUpdateManyQuery({
1812
+ modelMeta: relatedModelMeta,
1813
+ allModelsMeta,
1814
+ where: { [relatedPk]: { in: setIds } },
1815
+ data: { [reverseFk]: parentRefValue },
1816
+ });
1817
+ await executor(query);
1818
+ }
1819
+ }
1820
+
1821
+ // Batch connect: single UPDATE ... SET fk = $1 WHERE pk = ANY($2)
1822
+ if (ops.connect) {
1823
+ const connectItems = Array.isArray(ops.connect) ? ops.connect : [ops.connect];
1824
+ const ids = (connectItems as Record<string, unknown>[]).map((item) => item[relatedPk]).filter((id) => id !== undefined);
1825
+ if (ids.length > 0) {
1826
+ const query = buildUpdateManyQuery({
1827
+ modelMeta: relatedModelMeta,
1828
+ allModelsMeta,
1829
+ where: { [relatedPk]: { in: ids } },
1830
+ data: { [reverseFk]: parentRefValue },
1831
+ });
1832
+ await executor(query);
1833
+ }
1834
+ }
1835
+
1836
+ // Batch disconnect: single UPDATE ... SET fk = NULL WHERE pk = ANY($1)
1837
+ if (ops.disconnect) {
1838
+ const disconnectItems = Array.isArray(ops.disconnect) ? ops.disconnect : [ops.disconnect];
1839
+ const ids = (disconnectItems as Record<string, unknown>[]).map((item) => item[relatedPk]).filter((id) => id !== undefined);
1840
+ if (ids.length > 0) {
1841
+ const query = buildUpdateManyQuery({
1842
+ modelMeta: relatedModelMeta,
1843
+ allModelsMeta,
1844
+ where: { [relatedPk]: { in: ids } },
1845
+ data: { [reverseFk]: null },
1846
+ });
1847
+ await executor(query);
1848
+ }
1849
+ }
1850
+
1851
+ // Batch delete: single DELETE ... WHERE pk = ANY($1)
1852
+ if (ops.delete) {
1853
+ const deleteItems = Array.isArray(ops.delete) ? ops.delete : [ops.delete];
1854
+ const ids = (deleteItems as Record<string, unknown>[]).map((item) => item[relatedPk]).filter((id) => id !== undefined);
1855
+ if (ids.length > 0) {
1856
+ const query = buildDeleteQuery({
1857
+ modelMeta: relatedModelMeta,
1858
+ allModelsMeta,
1859
+ where: { [relatedPk]: { in: ids } },
1860
+ });
1861
+ await executor(query);
1862
+ }
1863
+ }
1864
+
1865
+ // Batch create: single multi-row INSERT
1866
+ if (ops.create) {
1867
+ const createItems = Array.isArray(ops.create) ? ops.create : [ops.create];
1868
+ const dataArray = (createItems as Record<string, unknown>[]).map((createData) => {
1869
+ (createData as Record<string, unknown>)[reverseFk] = parentRefValue;
1870
+ return createData;
1871
+ });
1872
+ if (dataArray.length === 1) {
1873
+ const query = buildInsertQuery({ modelMeta: relatedModelMeta, data: dataArray[0]! });
1874
+ await executor(query);
1875
+ } else if (dataArray.length > 1) {
1876
+ const query = buildInsertManyQuery({ modelMeta: relatedModelMeta, data: dataArray, returning: false });
1877
+ await executor(query);
1878
+ }
1879
+ }
1880
+
1881
+ // connectOrCreate: find existing or create, then set FK on related record
1882
+ if (ops.connectOrCreate) {
1883
+ const items = Array.isArray(ops.connectOrCreate) ? ops.connectOrCreate : [ops.connectOrCreate];
1884
+ for (const item of items as { where: Record<string, unknown>; create: Record<string, unknown> }[]) {
1885
+ const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: item.where, take: 1 } });
1886
+ const existing = await executor(findQuery);
1887
+ if (existing.length > 0) {
1888
+ const relatedId = existing[0]![relatedPk];
1889
+ if (relatedId !== undefined) {
1890
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { [relatedPk]: relatedId }, data: { [reverseFk]: parentRefValue } });
1891
+ await executor(query);
1892
+ }
1893
+ } else {
1894
+ (item.create as Record<string, unknown>)[reverseFk] = parentRefValue;
1895
+ const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: item.create });
1896
+ await executor(insertQuery);
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ // update (nested): update related records by where + data
1902
+ if (ops.update) {
1903
+ const items = Array.isArray(ops.update) ? ops.update : [ops.update];
1904
+ for (const item of items as { where: Record<string, unknown>; data: Record<string, unknown> }[]) {
1905
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item.where, [reverseFk]: parentRefValue }, data: item.data });
1906
+ await executor(query);
1907
+ }
1908
+ }
1909
+
1910
+ // upsert: upsert related records by where + create/update
1911
+ if (ops.upsert) {
1912
+ const items = Array.isArray(ops.upsert) ? ops.upsert : [ops.upsert];
1913
+ for (const item of items as { where: Record<string, unknown>; create: Record<string, unknown>; update: Record<string, unknown> }[]) {
1914
+ const findQuery = buildSelectQuery({ modelMeta: relatedModelMeta, allModelsMeta, args: { where: { ...item.where, [reverseFk]: parentRefValue }, take: 1 } });
1915
+ const existing = await executor(findQuery);
1916
+ if (existing.length > 0) {
1917
+ const query = buildUpdateQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item.where, [reverseFk]: parentRefValue }, data: item.update });
1918
+ await executor(query);
1919
+ } else {
1920
+ (item.create as Record<string, unknown>)[reverseFk] = parentRefValue;
1921
+ const insertQuery = buildInsertQuery({ modelMeta: relatedModelMeta, data: item.create });
1922
+ await executor(insertQuery);
1923
+ }
1924
+ }
1925
+ }
1926
+
1927
+ // updateMany: update multiple related records by filter
1928
+ if (ops.updateMany) {
1929
+ const items = Array.isArray(ops.updateMany) ? ops.updateMany : [ops.updateMany];
1930
+ for (const item of items as { where: Record<string, unknown>; data: Record<string, unknown> }[]) {
1931
+ const query = buildUpdateManyQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item.where, [reverseFk]: parentRefValue }, data: item.data });
1932
+ await executor(query);
1933
+ }
1934
+ }
1935
+
1936
+ // deleteMany: delete multiple related records by filter
1937
+ if (ops.deleteMany) {
1938
+ const items = Array.isArray(ops.deleteMany) ? ops.deleteMany : [ops.deleteMany];
1939
+ for (const item of items as Record<string, unknown>[]) {
1940
+ const query = buildDeleteQuery({ modelMeta: relatedModelMeta, allModelsMeta, where: { ...item, [reverseFk]: parentRefValue } });
1941
+ await executor(query);
1942
+ }
1943
+ }
1944
+ }
1945
+ }
1946
+ }
1947
+
1948
+ type DeferredCreate = {
1949
+ relatedModelMeta: ModelMeta;
1950
+ fkField: string;
1951
+ parentRefField: string;
1952
+ createData: unknown;
1953
+ };
1954
+
1955
+ /**
1956
+ * Process nested create/connect operations in the data object.
1957
+ * Extracts relation fields and handles them separately.
1958
+ *
1959
+ * Returns:
1960
+ * - processedData: scalar data ready for INSERT (with FK values resolved from connect/create)
1961
+ * - deferredCreates: nested creates where the related model holds the FK (must run after parent INSERT)
1962
+ */
1963
+ async function processNestedCreates(params: {
1964
+ data: Record<string, unknown>;
1965
+ modelMeta: ModelMeta;
1966
+ allModelsMeta: ModelMetaMap;
1967
+ executor: (params: {
1968
+ text: string;
1969
+ values: unknown[];
1970
+ }) => Promise<Record<string, unknown>[]>;
1971
+ }): Promise<{ processedData: Record<string, unknown>; deferredCreates: DeferredCreate[] }> {
1972
+ const { data, modelMeta, allModelsMeta, executor } = params;
1973
+ const processedData: Record<string, unknown> = {};
1974
+ const deferredCreates: DeferredCreate[] = [];
1975
+ const modelMap = getModelByNameMap({ allModelsMeta });
1976
+
1977
+ for (const [key, value] of Object.entries(data)) {
1978
+ if (value === undefined) continue;
1979
+
1980
+ // Check if this is a relation field
1981
+ const relationField = modelMeta.relationFields.find(
1982
+ (f) => f.name === key
1983
+ );
1984
+
1985
+ if (!relationField) {
1986
+ // Scalar field — keep as is
1987
+ processedData[key] = value;
1988
+ continue;
1989
+ }
1990
+
1991
+ // Handle nested relation operations
1992
+ if (typeof value === "object" && value !== null) {
1993
+ const ops = value as Record<string, unknown>;
1994
+
1995
+ // Case 1: Parent holds the FK (e.g., Post.author where Post has authorId)
1996
+ if (relationField.isForeignKey && relationField.fields.length > 0) {
1997
+ const fkField = relationField.fields[0]!;
1998
+ const refField = relationField.references[0]!;
1999
+
2000
+ if (ops.connect) {
2001
+ // Connect: set the FK value from the connected record's unique fields
2002
+ const connectData = ops.connect as Record<string, unknown>;
2003
+ if (connectData[refField] !== undefined) {
2004
+ processedData[fkField] = connectData[refField];
2005
+ }
2006
+ }
2007
+
2008
+ if (ops.create) {
2009
+ // Create: insert the related record first, then use its PK as FK value
2010
+ const relatedMeta = modelMap.get(relationField.relatedModel);
2011
+ if (relatedMeta) {
2012
+ const createData = ops.create as Record<string, unknown>;
2013
+ const insertQuery = buildInsertQuery({ modelMeta: relatedMeta, data: createData });
2014
+ const insertedRows = await executor(insertQuery);
2015
+ const inserted = insertedRows[0];
2016
+ if (inserted) {
2017
+ processedData[fkField] = inserted[refField];
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ // Case 2: Related model holds the FK (e.g., User.posts where Post has userId)
2023
+ else if (!relationField.isForeignKey) {
2024
+ const relatedMeta = modelMap.get(relationField.relatedModel);
2025
+ if (!relatedMeta) continue;
2026
+
2027
+ const reverseRel = relatedMeta.relationFields.find(
2028
+ (r) => r.relatedModel === modelMeta.name && r.isForeignKey && r.fields.length > 0
2029
+ );
2030
+ if (!reverseRel) continue;
2031
+
2032
+ const reverseFk = reverseRel.fields[0]!;
2033
+ const reverseRef = reverseRel.references[0]!;
2034
+
2035
+ if (ops.connect) {
2036
+ // For connect on the non-FK side, we need deferred processing
2037
+ // (need parent PK first to set on the related record)
2038
+ // This is uncommon for CREATE — usually you'd connect from the FK side
2039
+ }
2040
+
2041
+ if (ops.create) {
2042
+ // Defer: create after parent insert so we have parent's PK
2043
+ deferredCreates.push({
2044
+ relatedModelMeta: relatedMeta,
2045
+ fkField: reverseFk,
2046
+ parentRefField: reverseRef,
2047
+ createData: ops.create,
2048
+ });
2049
+ }
2050
+ }
2051
+ }
2052
+ }
2053
+
2054
+ return { processedData, deferredCreates };
2055
+ }