@vibeorm/runtime 1.0.2 → 1.1.1

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.
@@ -37,13 +37,20 @@ import type {
37
37
  } from "./types.ts";
38
38
  import { getScalarFieldMap, getModelByNameMap } from "./types.ts";
39
39
  import { buildWhereClause } from "./where-builder.ts";
40
- import { loadRelations } from "./relation-loader.ts";
40
+ import { sanitizeDirection } from "./query-builder.ts";
41
+ import { loadRelations, resolveRelationsToLoad, hasNestedRelations } from "./relation-loader.ts";
42
+ import type { RelationToLoad } from "./relation-loader.ts";
41
43
 
42
44
  type SqlExecutor = (params: {
43
45
  text: string;
44
46
  values: unknown[];
45
47
  }) => Promise<Record<string, unknown>[]>;
46
48
 
49
+ function reverseDirection(params: { direction: string }): string {
50
+ const normalized = sanitizeDirection({ direction: params.direction });
51
+ return normalized === "ASC" ? "DESC" : "ASC";
52
+ }
53
+
47
54
  // ─── Query Builder ────────────────────────────────────────────────
48
55
 
49
56
  /**
@@ -55,8 +62,9 @@ export function buildLateralJoinQuery(params: {
55
62
  allModelsMeta: ModelMetaMap;
56
63
  args: Record<string, unknown>;
57
64
  relationsToLoad: RelationToLoad[];
65
+ defaultOrderByPk?: boolean;
58
66
  }): { query: SqlQuery; relationAliases: RelationAlias[] } {
59
- const { modelMeta, allModelsMeta, args, relationsToLoad } = params;
67
+ const { modelMeta, allModelsMeta, args, relationsToLoad, defaultOrderByPk = false } = params;
60
68
  const table = `"${modelMeta.dbName}"`;
61
69
  const sfMap = getScalarFieldMap({ scalarFields: modelMeta.scalarFields });
62
70
  const modelMap = getModelByNameMap({ allModelsMeta });
@@ -87,6 +95,71 @@ export function buildLateralJoinQuery(params: {
87
95
  const allValues: unknown[] = [];
88
96
  let paramIdx = 0;
89
97
 
98
+ const take = typeof args.take === "number" ? (args.take as number) : undefined;
99
+ const isBackwardCursor = args.cursor !== undefined && take !== undefined && take < 0;
100
+
101
+ // Build WHERE — start with explicit where, then merge cursor conditions.
102
+ let where = args.where as Record<string, unknown> | undefined;
103
+ if (args.cursor) {
104
+ const cursorInput = args.cursor as Record<string, unknown>;
105
+ const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
106
+
107
+ const orderByDirMap = new Map<string, string>();
108
+ if (args.orderBy) {
109
+ const orderByItems = Array.isArray(args.orderBy)
110
+ ? (args.orderBy as Record<string, unknown>[])
111
+ : [args.orderBy as Record<string, unknown>];
112
+ for (const item of orderByItems) {
113
+ for (const [field, dir] of Object.entries(item)) {
114
+ if (!sfMap.has(field)) continue;
115
+ if (typeof dir === "string") {
116
+ orderByDirMap.set(field, dir.toUpperCase());
117
+ }
118
+ }
119
+ }
120
+ }
121
+
122
+ const cmpOpForField = (field: string): string => {
123
+ const dir = orderByDirMap.get(field) ?? "ASC";
124
+ const isDesc = dir === "DESC";
125
+ return (isDesc !== isBackwardCursor) ? "lt" : "gt";
126
+ };
127
+
128
+ let cursorWhere: Record<string, unknown> | undefined;
129
+ if (cursorEntries.length === 1) {
130
+ const [field, value] = cursorEntries[0]!;
131
+ if (!sfMap.has(field)) {
132
+ throw new Error(`Unknown field "${field}" in cursor for model "${modelMeta.name}"`);
133
+ }
134
+ cursorWhere = { [field]: { [cmpOpForField(field)]: value } };
135
+ } else if (cursorEntries.length > 1) {
136
+ const orBranches: Record<string, unknown>[] = [];
137
+ for (let i = 0; i < cursorEntries.length; i++) {
138
+ const branch: Record<string, unknown> = {};
139
+ for (let j = 0; j < i; j++) {
140
+ const [eqField, eqValue] = cursorEntries[j]!;
141
+ if (!sfMap.has(eqField)) {
142
+ throw new Error(`Unknown field "${eqField}" in cursor for model "${modelMeta.name}"`);
143
+ }
144
+ branch[eqField] = { equals: eqValue };
145
+ }
146
+ const [cmpField, cmpValue] = cursorEntries[i]!;
147
+ if (!sfMap.has(cmpField)) {
148
+ throw new Error(`Unknown field "${cmpField}" in cursor for model "${modelMeta.name}"`);
149
+ }
150
+ branch[cmpField] = { [cmpOpForField(cmpField)]: cmpValue };
151
+ orBranches.push(branch);
152
+ }
153
+ cursorWhere = { OR: orBranches };
154
+ }
155
+
156
+ if (cursorWhere) {
157
+ where = where
158
+ ? { AND: [where, cursorWhere] }
159
+ : cursorWhere;
160
+ }
161
+ }
162
+
90
163
  // Build LATERAL JOINs for each relation
91
164
  const lateralJoins: string[] = [];
92
165
  const lateralSelects: string[] = [];
@@ -127,25 +200,20 @@ export function buildLateralJoinQuery(params: {
127
200
  // Nested where filter from include/select args
128
201
  let nestedWhereSql = "";
129
202
  if (nestedArgs.where) {
130
- // Build nested where using __rel alias for the related model
203
+ // Build nested where using __rel alias for the related model.
204
+ // This avoids brittle regex rewrites of generated SQL table refs.
131
205
  const relMetaWithAlias = {
132
206
  ...relatedModelMeta,
133
- dbName: "__rel" as string, // temporarily override for SQL generation
134
- } as unknown as typeof relatedModelMeta;
135
- // We can't easily re-alias, so build with original and replace table ref
207
+ dbName: "__rel",
208
+ } as typeof relatedModelMeta;
136
209
  const nestedWhereResult = buildWhereClause({
137
210
  where: nestedArgs.where as Record<string, unknown>,
138
- modelMeta: relatedModelMeta,
211
+ modelMeta: relMetaWithAlias,
139
212
  allModelsMeta,
140
213
  paramOffset: paramIdx,
141
214
  });
142
215
  if (nestedWhereResult.sql) {
143
- // Replace "TableName". with __rel. for the subquery alias
144
- const aliasedSql = nestedWhereResult.sql.replace(
145
- new RegExp(`"${relatedModelMeta.dbName}"\\."`, "g"),
146
- `__rel."`
147
- );
148
- nestedWhereSql = ` AND ${aliasedSql}`;
216
+ nestedWhereSql = ` AND ${nestedWhereResult.sql}`;
149
217
  paramIdx += nestedWhereResult.values.length;
150
218
  allValues.push(...nestedWhereResult.values);
151
219
  }
@@ -160,21 +228,42 @@ export function buildLateralJoinQuery(params: {
160
228
  : [nestedArgs.orderBy as Record<string, string>];
161
229
  const clauses = orderByItems.flatMap((item) =>
162
230
  Object.entries(item).map(([field, dir]) => {
231
+ if (typeof dir !== "string") {
232
+ throw new Error(
233
+ `Unsupported nested orderBy direction for field "${field}" on relation "${relationMeta.name}"`
234
+ );
235
+ }
163
236
  const sf = relatedSfMap.get(field);
164
- const col = sf ? sf.dbName : field;
165
- return `__rel."${col}" ${(dir as string).toUpperCase()}`;
237
+ if (!sf) {
238
+ throw new Error(
239
+ `Unknown nested orderBy field "${field}" on relation "${relationMeta.name}"`
240
+ );
241
+ }
242
+ return `__rel."${sf.dbName}" ${sanitizeDirection({ direction: dir })}`;
166
243
  })
167
244
  );
168
245
  if (clauses.length > 0) {
169
246
  orderBySql = ` ORDER BY ${clauses.join(", ")}`;
170
247
  }
248
+ } else if (defaultOrderByPk) {
249
+ // Default ORDER BY PK for deterministic array ordering in jsonb_agg
250
+ const pkOrderClauses: string[] = [];
251
+ for (const rpkName of relatedModelMeta.primaryKey) {
252
+ const rpkSf = relatedSfMap.get(rpkName);
253
+ if (rpkSf) {
254
+ pkOrderClauses.push(`__rel."${rpkSf.dbName}" ASC`);
255
+ }
256
+ }
257
+ if (pkOrderClauses.length > 0) {
258
+ orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
259
+ }
171
260
  }
172
261
 
173
262
  let limitSql = "";
174
263
  if (nestedArgs.take !== undefined) {
175
264
  paramIdx++;
176
265
  limitSql = ` LIMIT $${paramIdx}`;
177
- allValues.push(nestedArgs.take);
266
+ allValues.push(Math.abs(nestedArgs.take as number));
178
267
  }
179
268
 
180
269
  let offsetSql = "";
@@ -234,7 +323,7 @@ export function buildLateralJoinQuery(params: {
234
323
 
235
324
  // Build WHERE from args
236
325
  const whereResult = buildWhereClause({
237
- where: args.where as Record<string, unknown> | undefined,
326
+ where,
238
327
  modelMeta,
239
328
  allModelsMeta,
240
329
  paramOffset: paramIdx,
@@ -251,23 +340,59 @@ export function buildLateralJoinQuery(params: {
251
340
 
252
341
  const orderClauses = orderByItems.flatMap((item) =>
253
342
  Object.entries(item).map(([field, direction]) => {
343
+ if (typeof direction !== "string") {
344
+ throw new Error(
345
+ `Unsupported orderBy direction for field "${field}" on model "${modelMeta.name}"`
346
+ );
347
+ }
254
348
  const scalarField = sfMap.get(field);
255
- const col = scalarField ? scalarField.dbName : field;
256
- return `${table}."${col}" ${direction.toUpperCase()}`;
349
+ if (!scalarField) {
350
+ throw new Error(`Unknown orderBy field "${field}" on model "${modelMeta.name}"`);
351
+ }
352
+ const resolvedDirection = isBackwardCursor
353
+ ? reverseDirection({ direction })
354
+ : direction;
355
+ return `${table}."${scalarField.dbName}" ${sanitizeDirection({ direction: resolvedDirection })}`;
257
356
  })
258
357
  );
259
358
 
260
359
  if (orderClauses.length > 0) {
261
360
  orderBySql = ` ORDER BY ${orderClauses.join(", ")}`;
262
361
  }
362
+ } else if (args.cursor) {
363
+ const cursorInput = args.cursor as Record<string, unknown>;
364
+ const cursorEntries = Object.entries(cursorInput).filter(([, v]) => v !== undefined);
365
+ const defaultDir = isBackwardCursor ? "DESC" : "ASC";
366
+ const cursorOrderClauses = cursorEntries.map(([field]) => {
367
+ const sf = sfMap.get(field);
368
+ if (!sf) {
369
+ throw new Error(`Unknown field "${field}" in cursor for model "${modelMeta.name}"`);
370
+ }
371
+ return `${table}."${sf.dbName}" ${defaultDir}`;
372
+ });
373
+ if (cursorOrderClauses.length > 0) {
374
+ orderBySql = ` ORDER BY ${cursorOrderClauses.join(", ")}`;
375
+ }
376
+ } else if (defaultOrderByPk) {
377
+ // Default ORDER BY primary key for deterministic results
378
+ const pkOrderClauses: string[] = [];
379
+ for (const pn of modelMeta.primaryKey) {
380
+ const pSf = sfMap.get(pn);
381
+ if (pSf) {
382
+ pkOrderClauses.push(`${table}."${pSf.dbName}" ASC`);
383
+ }
384
+ }
385
+ if (pkOrderClauses.length > 0) {
386
+ orderBySql = ` ORDER BY ${pkOrderClauses.join(", ")}`;
387
+ }
263
388
  }
264
389
 
265
390
  // Build LIMIT / OFFSET
266
391
  let limitSql = "";
267
- if (args.take !== undefined) {
392
+ if (take !== undefined) {
268
393
  paramIdx++;
269
394
  limitSql = ` LIMIT $${paramIdx}`;
270
- allValues.push(args.take);
395
+ allValues.push(Math.abs(take));
271
396
  }
272
397
 
273
398
  let offsetSql = "";
@@ -295,7 +420,7 @@ export function buildLateralJoinQuery(params: {
295
420
  // lateral joins. Without it Postgres may evaluate every lateral for
296
421
  // every candidate row before the LIMIT prunes the result set.
297
422
  const hasRelFilter = whereHasRelationFilter({
298
- where: args.where as Record<string, unknown> | undefined,
423
+ where,
299
424
  modelMeta,
300
425
  });
301
426
  const hasHeavyIncludes =
@@ -342,8 +467,9 @@ export async function executeLateralJoinQuery(params: {
342
467
  args: Record<string, unknown>;
343
468
  executor: SqlExecutor;
344
469
  profilingCtx?: ProfilingContext;
470
+ defaultOrderByPk?: boolean;
345
471
  }): Promise<Record<string, unknown>[]> {
346
- const { modelMeta, allModelsMeta, args, executor, profilingCtx } = params;
472
+ const { modelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } = params;
347
473
  const profiling = !!profilingCtx;
348
474
 
349
475
  const t0 = profiling ? performance.now() : 0;
@@ -364,6 +490,7 @@ export async function executeLateralJoinQuery(params: {
364
490
  allModelsMeta,
365
491
  args,
366
492
  relationsToLoad,
493
+ defaultOrderByPk,
367
494
  });
368
495
 
369
496
  const t1 = profiling ? performance.now() : 0;
@@ -464,11 +591,12 @@ export async function loadRelationsWithLateralJoin(params: {
464
591
  args: Record<string, unknown>;
465
592
  executor: SqlExecutor;
466
593
  profilingCtx?: ProfilingContext;
594
+ defaultOrderByPk?: boolean;
467
595
  }): Promise<Record<string, unknown>[]> {
468
596
  // For the post-processing path (create, update, etc.), we still need
469
597
  // the 2-query approach since the parent records are already fetched.
470
598
  // But we can use the lateral join to load relations in 1 query instead of N.
471
- const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx } = params;
599
+ const { parentRecords, parentModelMeta, allModelsMeta, args, executor, profilingCtx, defaultOrderByPk = false } = params;
472
600
 
473
601
  if (parentRecords.length === 0) return parentRecords;
474
602
 
@@ -505,6 +633,7 @@ export async function loadRelationsWithLateralJoin(params: {
505
633
  allModelsMeta,
506
634
  args: syntheticArgs,
507
635
  relationsToLoad,
636
+ defaultOrderByPk,
508
637
  });
509
638
 
510
639
  const rows = await executor(query);
@@ -581,67 +710,12 @@ export async function loadRelationsWithLateralJoin(params: {
581
710
 
582
711
  // ─── Helpers ──────────────────────────────────────────────────────
583
712
 
584
- type RelationToLoad = {
585
- relationMeta: RelationFieldMeta;
586
- nestedArgs: Record<string, unknown>;
587
- };
588
-
589
713
  type RelationAlias = {
590
714
  alias: string;
591
715
  relationMeta: RelationFieldMeta;
592
716
  nestedArgs: Record<string, unknown>;
593
717
  };
594
718
 
595
- export function resolveRelationsToLoad(params: {
596
- parentModelMeta: ModelMeta;
597
- args: Record<string, unknown>;
598
- }): RelationToLoad[] {
599
- const { parentModelMeta, args } = params;
600
- const result: RelationToLoad[] = [];
601
-
602
- const select = args.select as Record<string, unknown> | undefined;
603
- const include = args.include as Record<string, unknown> | undefined;
604
-
605
- for (const relationMeta of parentModelMeta.relationFields) {
606
- let shouldLoad = false;
607
- let nestedArgs: Record<string, unknown> = {};
608
-
609
- if (select) {
610
- const val = select[relationMeta.name];
611
- if (val === true) {
612
- shouldLoad = true;
613
- } else if (typeof val === "object" && val !== null) {
614
- shouldLoad = true;
615
- nestedArgs = val as Record<string, unknown>;
616
- }
617
- }
618
-
619
- if (include) {
620
- const val = include[relationMeta.name];
621
- if (val === true) {
622
- shouldLoad = true;
623
- } else if (typeof val === "object" && val !== null) {
624
- shouldLoad = true;
625
- nestedArgs = val as Record<string, unknown>;
626
- }
627
- }
628
-
629
- if (shouldLoad) {
630
- result.push({ relationMeta, nestedArgs });
631
- }
632
- }
633
-
634
- return result;
635
- }
636
-
637
- function hasNestedRelations(params: { nestedArgs: Record<string, unknown> }): boolean {
638
- const { nestedArgs } = params;
639
- return (
640
- nestedArgs.include !== undefined ||
641
- (typeof nestedArgs.select === "object" && nestedArgs.select !== null)
642
- );
643
- }
644
-
645
719
  /**
646
720
  * Check whether a Prisma-style where object references any relation field
647
721
  * on the given model. Walks AND / OR / NOT combinators recursively.
@@ -701,15 +775,17 @@ function resolveRelatedScalars(params: {
701
775
  const omitArg = nestedArgs.omit as Record<string, boolean> | undefined;
702
776
 
703
777
  if (selectArg) {
778
+ const sfMap = getScalarFieldMap({ scalarFields: relatedModelMeta.scalarFields });
704
779
  const selected = relatedModelMeta.scalarFields.filter((f) => {
705
780
  const val = selectArg[f.name];
706
781
  return val === true || (typeof val === "object" && val !== null);
707
782
  });
708
783
 
709
784
  // Always include PK for relation stitching
785
+ const selectedNames = new Set(selected.map((f) => f.name));
710
786
  for (const pkName of relatedModelMeta.primaryKey) {
711
- if (!selected.some((f) => f.name === pkName)) {
712
- const sf = relatedModelMeta.scalarFields.find((f) => f.name === pkName);
787
+ if (!selectedNames.has(pkName)) {
788
+ const sf = sfMap.get(pkName);
713
789
  if (sf) selected.push(sf);
714
790
  }
715
791
  }