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