turbine-orm 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +194 -26
  2. package/dist/cjs/cli/config.js +5 -15
  3. package/dist/cjs/cli/index.js +240 -41
  4. package/dist/cjs/cli/migrate.js +71 -46
  5. package/dist/cjs/cli/ui.js +5 -9
  6. package/dist/cjs/client.js +109 -46
  7. package/dist/cjs/errors.js +293 -0
  8. package/dist/cjs/generate.js +33 -13
  9. package/dist/cjs/index.js +39 -20
  10. package/dist/cjs/introspect.js +3 -5
  11. package/dist/cjs/pipeline.js +9 -2
  12. package/dist/cjs/query.js +442 -109
  13. package/dist/cjs/schema-builder.js +93 -24
  14. package/dist/cjs/schema-sql.js +157 -19
  15. package/dist/cjs/schema.js +5 -2
  16. package/dist/cjs/serverless.js +87 -176
  17. package/dist/cli/config.js +6 -16
  18. package/dist/cli/index.js +245 -46
  19. package/dist/cli/migrate.d.ts +6 -1
  20. package/dist/cli/migrate.js +72 -47
  21. package/dist/cli/ui.js +5 -9
  22. package/dist/client.d.ts +77 -4
  23. package/dist/client.js +109 -46
  24. package/dist/errors.d.ts +138 -0
  25. package/dist/errors.js +278 -0
  26. package/dist/generate.d.ts +1 -1
  27. package/dist/generate.js +36 -16
  28. package/dist/index.d.ts +11 -9
  29. package/dist/index.js +16 -12
  30. package/dist/introspect.d.ts +1 -1
  31. package/dist/introspect.js +4 -6
  32. package/dist/pipeline.d.ts +1 -1
  33. package/dist/pipeline.js +9 -2
  34. package/dist/query.d.ts +257 -36
  35. package/dist/query.js +443 -110
  36. package/dist/schema-builder.d.ts +2 -2
  37. package/dist/schema-builder.js +93 -25
  38. package/dist/schema-sql.d.ts +7 -3
  39. package/dist/schema-sql.js +157 -19
  40. package/dist/schema.d.ts +1 -1
  41. package/dist/schema.js +5 -2
  42. package/dist/serverless.d.ts +91 -139
  43. package/dist/serverless.js +86 -173
  44. package/package.json +33 -16
  45. package/dist/types.d.ts +0 -93
  46. package/dist/types.js +0 -126
package/dist/pipeline.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @batadata/turbine — Pipeline execution
2
+ * turbine-orm — Pipeline execution
3
3
  *
4
4
  * Pipelines batch multiple independent queries into a single database round-trip.
5
5
  * Instead of N sequential awaits (N round-trips), you get 1 round-trip for all N queries.
@@ -15,6 +15,7 @@
15
15
  * we simulate it by running queries concurrently on a single connection or via
16
16
  * a multi-statement batch.
17
17
  */
18
+ import { wrapPgError } from './errors.js';
18
19
  // ---------------------------------------------------------------------------
19
20
  // Pipeline executor
20
21
  // ---------------------------------------------------------------------------
@@ -52,7 +53,13 @@ export async function executePipeline(pool, queries) {
52
53
  // Future: use actual Postgres pipeline protocol for true pipelining.
53
54
  const results = [];
54
55
  for (const q of queries) {
55
- const raw = await client.query(q.sql, q.params);
56
+ let raw;
57
+ try {
58
+ raw = await client.query(q.sql, q.params);
59
+ }
60
+ catch (err) {
61
+ throw wrapPgError(err);
62
+ }
56
63
  results.push(q.transform(raw));
57
64
  }
58
65
  await client.query('COMMIT');
package/dist/query.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  /**
2
- * @batadata/turbine — Query builder
2
+ * turbine-orm — Query builder
3
3
  *
4
4
  * Each table accessor (db.users, db.posts, etc.) returns a QueryInterface<T>
5
5
  * that builds parameterized SQL and executes it through the connection pool.
6
6
  *
7
7
  * Nested relations use json_build_object + json_agg subqueries for single-query
8
- * resolution — the technique that benchmarks proved 2-3x faster than Prisma.
8
+ * resolution — a PostgreSQL-native approach that eliminates N+1 query patterns.
9
9
  *
10
10
  * Schema-driven: all column names, types, and relations come from introspected
11
11
  * metadata — nothing is hardcoded.
@@ -61,10 +61,22 @@ export type WhereClause<T> = {
61
61
  /** Relation filters — keyed by relation name, value is { some, every, none } */
62
62
  [relationName: string]: unknown;
63
63
  };
64
+ /**
65
+ * Unparameterized with clause — accepts any relation name.
66
+ * Used internally by the query builder at runtime.
67
+ */
64
68
  export interface WithClause {
65
69
  [relation: string]: true | WithOptions;
66
70
  }
67
- export interface WithOptions {
71
+ /**
72
+ * Relation-aware with clause. When R (the relations map) is provided,
73
+ * only keys from R are autocompleted. Used in public method signatures
74
+ * so the compiler can narrow the return type.
75
+ */
76
+ export type TypedWithClause<R extends object = {}> = [keyof R] extends [never] ? WithClause : {
77
+ [K in keyof R]?: true | WithOptions;
78
+ };
79
+ export interface WithOptions<_T = unknown> {
68
80
  with?: WithClause;
69
81
  where?: Record<string, unknown>;
70
82
  orderBy?: Record<string, OrderDirection>;
@@ -74,22 +86,34 @@ export interface WithOptions {
74
86
  /** Exclude these fields from the relation */
75
87
  omit?: Record<string, boolean>;
76
88
  }
77
- export interface FindUniqueArgs<T> {
89
+ /**
90
+ * Compute the result type when relations are included via `with`.
91
+ *
92
+ * - When R is the default ({}) or W is empty, resolves to plain T.
93
+ * - When R is a real relations map and W specifies relation keys, merges matching
94
+ * relation types from R onto T.
95
+ *
96
+ * @typeParam T - Base entity type (e.g. User)
97
+ * @typeParam R - Relations map (e.g. { posts: Post[]; profile: Profile | null })
98
+ * @typeParam W - The with clause object (e.g. { posts: true })
99
+ */
100
+ export type WithResult<T, R extends object, W> = [keyof R] extends [never] ? T : [keyof W & keyof R] extends [never] ? T : T & Pick<R, keyof W & keyof R>;
101
+ export interface FindUniqueArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>> {
78
102
  where: WhereClause<T>;
79
103
  select?: Record<string, boolean>;
80
104
  omit?: Record<string, boolean>;
81
- with?: WithClause;
105
+ with?: W;
82
106
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
83
107
  timeout?: number;
84
108
  }
85
- export interface FindManyArgs<T> {
109
+ export interface FindManyArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>> {
86
110
  where?: WhereClause<T>;
87
111
  select?: Record<string, boolean>;
88
112
  omit?: Record<string, boolean>;
89
113
  orderBy?: Record<string, OrderDirection>;
90
114
  limit?: number;
91
115
  offset?: number;
92
- with?: WithClause;
116
+ with?: W;
93
117
  /** Cursor-based pagination: start after this row */
94
118
  cursor?: Partial<T>;
95
119
  /** Number of records to take (used with cursor) */
@@ -99,6 +123,10 @@ export interface FindManyArgs<T> {
99
123
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
100
124
  timeout?: number;
101
125
  }
126
+ export interface FindManyStreamArgs<T, R extends object = {}, W extends TypedWithClause<R> = TypedWithClause<R>> extends FindManyArgs<T, R, W> {
127
+ /** Number of rows to fetch per internal FETCH batch (default: 100) */
128
+ batchSize?: number;
129
+ }
102
130
  export interface CreateArgs<T> {
103
131
  data: Partial<T>;
104
132
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
@@ -111,27 +139,71 @@ export interface CreateManyArgs<T> {
111
139
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
112
140
  timeout?: number;
113
141
  }
142
+ /**
143
+ * Atomic update operators for a field.
144
+ *
145
+ * `set` works on any type; `increment`, `decrement`, `multiply`, and `divide`
146
+ * are only valid on numeric fields. They generate SQL like
147
+ * `col = col + $n` (and the corresponding `-`, `*`, `/` variants) instead of
148
+ * plain absolute assignments, so they are safe against concurrent writers —
149
+ * the database performs the math atomically.
150
+ *
151
+ * @example
152
+ * db.posts.update({ where: { id: 5 }, data: { viewCount: { increment: 1 } } });
153
+ */
154
+ export type UpdateOperatorInput<V> = {
155
+ set: V;
156
+ } | (V extends number ? {
157
+ increment: number;
158
+ } : never) | (V extends number ? {
159
+ decrement: number;
160
+ } : never) | (V extends number ? {
161
+ multiply: number;
162
+ } : never) | (V extends number ? {
163
+ divide: number;
164
+ } : never);
165
+ /**
166
+ * Update data — each field can be a plain value or an atomic operator object.
167
+ * Back-compatible with `Partial<T>`: plain values still typecheck unchanged.
168
+ */
169
+ export type UpdateInput<T> = {
170
+ [K in keyof T]?: T[K] | UpdateOperatorInput<T[K]>;
171
+ };
114
172
  export interface UpdateArgs<T> {
115
173
  where: WhereClause<T>;
116
- data: Partial<T>;
174
+ data: UpdateInput<T>;
117
175
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
118
176
  timeout?: number;
177
+ /**
178
+ * Opt in to running this mutation when `where` resolves to an empty
179
+ * predicate (e.g. `{}` or `{ id: undefined }`). Default `false` — an
180
+ * empty predicate throws `ValidationError` to catch the common case of
181
+ * a filter value accidentally being `undefined`. Set this to `true` only
182
+ * when an unconditional mutation is the intended behaviour.
183
+ */
184
+ allowFullTableScan?: boolean;
119
185
  }
120
186
  export interface UpdateManyArgs<T> {
121
187
  where: WhereClause<T>;
122
- data: Partial<T>;
188
+ data: UpdateInput<T>;
123
189
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
124
190
  timeout?: number;
191
+ /** See {@link UpdateArgs.allowFullTableScan}. */
192
+ allowFullTableScan?: boolean;
125
193
  }
126
194
  export interface DeleteArgs<T> {
127
195
  where: WhereClause<T>;
128
196
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
129
197
  timeout?: number;
198
+ /** See {@link UpdateArgs.allowFullTableScan}. */
199
+ allowFullTableScan?: boolean;
130
200
  }
131
201
  export interface DeleteManyArgs<T> {
132
202
  where: WhereClause<T>;
133
203
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
134
204
  timeout?: number;
205
+ /** See {@link UpdateArgs.allowFullTableScan}. */
206
+ allowFullTableScan?: boolean;
135
207
  }
136
208
  export interface UpsertArgs<T> {
137
209
  where: WhereClause<T>;
@@ -158,8 +230,6 @@ export interface GroupByArgs<T> {
158
230
  _min?: Partial<Record<keyof T & string, boolean>>;
159
231
  /** Maximum value of fields in each group */
160
232
  _max?: Partial<Record<keyof T & string, boolean>>;
161
- /** Having clause for filtering groups */
162
- having?: Record<string, unknown>;
163
233
  /** Order groups */
164
234
  orderBy?: Record<string, OrderDirection>;
165
235
  /** Query timeout in milliseconds. Rejects with an error if exceeded. */
@@ -244,7 +314,7 @@ export interface QueryInterfaceOptions {
244
314
  /** Log a warning when findMany() is called without a limit */
245
315
  warnOnUnlimited?: boolean;
246
316
  }
247
- export declare class QueryInterface<T extends object> {
317
+ export declare class QueryInterface<T extends object, R extends object = {}> {
248
318
  private readonly pool;
249
319
  private readonly table;
250
320
  private readonly schema;
@@ -261,6 +331,7 @@ export declare class QueryInterface<T extends object> {
261
331
  /**
262
332
  * Execute a pool.query with an optional timeout.
263
333
  * If timeout is set, races the query against a timer and rejects on expiry.
334
+ * pg driver errors are translated to typed Turbine errors via wrapPgError.
264
335
  */
265
336
  private queryWithTimeout;
266
337
  /**
@@ -273,21 +344,32 @@ export declare class QueryInterface<T extends object> {
273
344
  * To intercept queries before SQL generation, use the raw() method instead.
274
345
  */
275
346
  private executeWithMiddleware;
347
+ findUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): Promise<WithResult<T, R, W> | null>;
348
+ buildFindUnique<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): DeferredQuery<T | null>;
349
+ findMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<WithResult<T, R, W>[]>;
350
+ buildFindMany<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T[]>;
276
351
  /**
277
- * Generate a cache key for a query shape.
278
- * Same where-keys + same with-clause = same SQL template.
352
+ * Stream rows from a findMany query using PostgreSQL cursors.
353
+ * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
354
+ *
355
+ * Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
356
+ * The cursor is automatically closed and the connection released when iteration
357
+ * completes or is terminated early (e.g. `break` from `for await`).
358
+ *
359
+ * @example
360
+ * ```ts
361
+ * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
362
+ * process.stdout.write(`${user.email}\n`);
363
+ * }
364
+ * ```
279
365
  */
280
- private cacheKey;
281
- findUnique(args: FindUniqueArgs<T>): Promise<T | null>;
282
- buildFindUnique(args: FindUniqueArgs<T>): DeferredQuery<T | null>;
283
- findMany(args?: FindManyArgs<T>): Promise<T[]>;
284
- buildFindMany(args?: FindManyArgs<T>): DeferredQuery<T[]>;
285
- findFirst(args?: FindManyArgs<T>): Promise<T | null>;
286
- buildFindFirst(args?: FindManyArgs<T>): DeferredQuery<T | null>;
287
- findFirstOrThrow(args?: FindManyArgs<T>): Promise<T>;
288
- buildFindFirstOrThrow(args?: FindManyArgs<T>): DeferredQuery<T>;
289
- findUniqueOrThrow(args: FindUniqueArgs<T>): Promise<T>;
290
- buildFindUniqueOrThrow(args: FindUniqueArgs<T>): DeferredQuery<T>;
366
+ findManyStream<W extends TypedWithClause<R> = {}>(args?: FindManyStreamArgs<T, R, W>): AsyncGenerator<WithResult<T, R, W>, void, undefined>;
367
+ findFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<WithResult<T, R, W> | null>;
368
+ buildFindFirst<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T | null>;
369
+ findFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): Promise<WithResult<T, R, W>>;
370
+ buildFindFirstOrThrow<W extends TypedWithClause<R> = {}>(args?: FindManyArgs<T, R, W>): DeferredQuery<T>;
371
+ findUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): Promise<WithResult<T, R, W>>;
372
+ buildFindUniqueOrThrow<W extends TypedWithClause<R> = {}>(args: FindUniqueArgs<T, R, W>): DeferredQuery<T>;
291
373
  create(args: CreateArgs<T>): Promise<T>;
292
374
  buildCreate(args: CreateArgs<T>): DeferredQuery<T>;
293
375
  createMany(args: CreateManyArgs<T>): Promise<T[]>;
@@ -325,8 +407,33 @@ export declare class QueryInterface<T extends object> {
325
407
  private toColumn;
326
408
  /** Convert camelCase field name to a double-quoted SQL identifier */
327
409
  private toSqlColumn;
410
+ /**
411
+ * Build a single SET clause entry for update/updateMany.
412
+ *
413
+ * Supports plain values and atomic operator objects ({ set, increment,
414
+ * decrement, multiply, divide }). An operator object is detected ONLY when
415
+ * it has EXACTLY one key that is one of the 5 operator keys — this avoids
416
+ * misinterpreting JSON column values like `{ set: 'x' }` as operators
417
+ * (real operator objects always have exactly one key, and a plain JSON
418
+ * payload that happens to have a single `set` key is extremely unusual).
419
+ * Multi-key objects are always treated as plain (JSON) values.
420
+ *
421
+ * Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
422
+ * pushes any required params onto the shared params array so that WHERE
423
+ * clause numbering continues correctly afterward.
424
+ */
425
+ private buildSetClause;
328
426
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
329
427
  private buildWhere;
428
+ /**
429
+ * Refuse mutations with an empty predicate unless explicitly opted in.
430
+ *
431
+ * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
432
+ * mutation with no filter — a common footgun when a caller's filter
433
+ * value accidentally resolves to `undefined`. This guard throws
434
+ * `ValidationError` in that case unless `allowFullTableScan: true`.
435
+ */
436
+ private assertMutationHasPredicate;
330
437
  /**
331
438
  * Build the inner WHERE expression (without the WHERE keyword).
332
439
  * Returns null if no conditions exist.
@@ -355,24 +462,138 @@ export declare class QueryInterface<T extends object> {
355
462
  /** Parse a row that may contain JSON nested relation columns */
356
463
  private parseNestedRow;
357
464
  /**
358
- * Build a SELECT clause with nested relation subqueries.
465
+ * Build a SELECT clause that includes both base columns and nested relation subqueries.
466
+ *
467
+ * For each relation specified in the `with` clause, this method generates a correlated
468
+ * subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
469
+ * is a single SQL SELECT clause that resolves the full object tree in one query --
470
+ * no N+1 problem.
471
+ *
472
+ * **How it works:**
473
+ * 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
474
+ * 2. Iterates over each key in the `with` clause, looking up the relation definition.
475
+ * 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
476
+ * correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
477
+ * 4. Each subquery is aliased as the relation name in the final SELECT.
359
478
  *
360
- * Uses json_build_object + json_agg the same approach as the raw-pg benchmark
361
- * queries. Generates a single SQL statement that resolves the full object tree.
479
+ * **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
480
+ * Each call to `buildRelationSubquery` increments it to produce unique table aliases
481
+ * (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
482
+ * collisions in the generated SQL.
362
483
  *
363
- * Nested where values are parameterized via the shared params array to prevent
364
- * SQL injection.
484
+ * **Example output:**
485
+ * ```sql
486
+ * "users"."id", "users"."name", "users"."email",
487
+ * (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
488
+ * FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
489
+ * ```
490
+ *
491
+ * @param table - The root table name (e.g. `"users"`).
492
+ * @param withClause - An object mapping relation names to their include specs
493
+ * (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
494
+ * @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
495
+ * Nested where/limit values are pushed here to prevent SQL injection.
496
+ * @param columnsList - Optional subset of columns to include in the SELECT. When `null`
497
+ * or omitted, all columns from the table's schema metadata are used.
498
+ * @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
499
+ * for circular-relation detection. Defaults to `0` at the top level.
500
+ * @param path - Breadcrumb trail of relation names traversed so far, used in error
501
+ * messages when circular or too-deep nesting is detected.
502
+ * @returns A complete SELECT clause string (without the `SELECT` keyword) containing
503
+ * base columns and relation subqueries.
365
504
  */
366
505
  private buildSelectWithRelations;
367
506
  /**
368
- * Build a json_agg subquery for a relation.
507
+ * Generate a correlated subquery that returns JSON for a single relation.
508
+ *
509
+ * This is the core of Turbine's single-query nested relation strategy. For a given
510
+ * relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
511
+ * that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
512
+ * a single JSON object (belongsTo / hasOne).
513
+ *
514
+ * ### Algorithm overview
515
+ *
516
+ * 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
517
+ * `aliasCounter` so that deeply nested subqueries never collide.
518
+ *
519
+ * 2. **Column resolution:** Honors `select` / `omit` options to control which columns
520
+ * appear in the output JSON.
521
+ *
522
+ * 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
523
+ * field names to their column values:
524
+ * ```sql
525
+ * json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
526
+ * ```
527
+ *
528
+ * 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
529
+ * `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
530
+ * into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
531
+ * For belongsTo / hasOne, no aggregation is used -- just the single JSON object
532
+ * with `LIMIT 1`.
533
+ *
534
+ * 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
535
+ * - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
536
+ * (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
537
+ * - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
538
+ * (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
539
+ *
540
+ * 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
541
+ * itself recursively for each nested relation, passing the current alias as
542
+ * `parentRef`. The nested subquery appears as an additional key in the
543
+ * `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
544
+ * Depth is incremented and capped at 10 to guard against circular relations.
545
+ *
546
+ * 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
547
+ * the query is restructured into a two-level form:
548
+ * ```sql
549
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
550
+ * FROM (
551
+ * SELECT t0.* FROM "posts" t0
552
+ * WHERE t0."user_id" = "users"."id"
553
+ * ORDER BY t0."created_at" DESC
554
+ * LIMIT $1
555
+ * ) t0i
556
+ * ```
557
+ * This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
558
+ * aggregation. Without the inner subquery, LIMIT would be meaningless because
559
+ * `json_agg` produces a single aggregated row.
560
+ *
561
+ * 8. **Parameter threading:** All user-supplied values (where filters, limit) are
562
+ * pushed to the shared `params` array with `$N` placeholders. No string
563
+ * interpolation of user data ever occurs -- all identifiers go through
564
+ * `quoteIdent()` and all values are parameterized.
369
565
  *
370
- * All user-supplied values in nested where clauses are parameterized
371
- * through the shared params array — no string interpolation.
566
+ * ### Example output (hasMany with nested relation)
567
+ * ```sql
568
+ * SELECT COALESCE(json_agg(json_build_object(
569
+ * 'id', t0."id",
570
+ * 'title', t0."title",
571
+ * 'comments', COALESCE((
572
+ * SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
573
+ * FROM "comments" t1 WHERE t1."post_id" = t0."id"
574
+ * ), '[]'::json)
575
+ * )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
576
+ * ```
372
577
  *
373
- * @param parentRef - The alias (or table name) of the parent in the outer query.
374
- * Used for the correlated WHERE clause: `child.fk = parentRef.pk`.
375
- * @param aliasCounter - Shared counter for generating unique aliases across nested levels.
578
+ * @param relDef - The relation definition from schema metadata (contains `to`, `type`,
579
+ * `foreignKey`, `referenceKey`).
580
+ * @param spec - Either `true` (include with defaults) or a `WithOptions` object that
581
+ * can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
582
+ * @param params - Shared parameter array. User-supplied values are pushed here and
583
+ * referenced as `$1`, `$2`, etc. in the generated SQL.
584
+ * @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
585
+ * parent query. Used to build the correlated WHERE clause that ties
586
+ * child rows to their parent row.
587
+ * @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
588
+ * table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
589
+ * Each call increments `n` by 1.
590
+ * @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
591
+ * call. If it reaches 10, a {@link CircularRelationError} is thrown.
592
+ * @param path - Breadcrumb trail of relation/table names traversed so far
593
+ * (e.g. `["users", "posts", "comments"]`). Used in the error message
594
+ * when circular or too-deep nesting is detected.
595
+ * @returns A complete SQL subquery string (without surrounding parentheses) that
596
+ * evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
376
597
  */
377
598
  private buildRelationSubquery;
378
599
  /**