turbine-orm 0.5.0 → 0.7.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.
Files changed (50) hide show
  1. package/README.md +292 -26
  2. package/dist/cjs/cli/config.js +5 -15
  3. package/dist/cjs/cli/index.js +311 -43
  4. package/dist/cjs/cli/loader.js +129 -0
  5. package/dist/cjs/cli/migrate.js +96 -47
  6. package/dist/cjs/cli/ui.js +5 -9
  7. package/dist/cjs/client.js +158 -49
  8. package/dist/cjs/errors.js +424 -0
  9. package/dist/cjs/generate.js +145 -14
  10. package/dist/cjs/index.js +43 -20
  11. package/dist/cjs/introspect.js +3 -5
  12. package/dist/cjs/pipeline.js +9 -2
  13. package/dist/cjs/query.js +544 -115
  14. package/dist/cjs/schema-builder.js +150 -30
  15. package/dist/cjs/schema-sql.js +241 -37
  16. package/dist/cjs/schema.js +5 -2
  17. package/dist/cjs/serverless.js +88 -176
  18. package/dist/cli/config.js +6 -16
  19. package/dist/cli/index.js +316 -48
  20. package/dist/cli/loader.d.ts +45 -0
  21. package/dist/cli/loader.js +91 -0
  22. package/dist/cli/migrate.d.ts +13 -2
  23. package/dist/cli/migrate.js +97 -48
  24. package/dist/cli/ui.d.ts +1 -1
  25. package/dist/cli/ui.js +5 -9
  26. package/dist/client.d.ts +92 -4
  27. package/dist/client.js +158 -49
  28. package/dist/errors.d.ts +225 -0
  29. package/dist/errors.js +405 -0
  30. package/dist/generate.d.ts +7 -1
  31. package/dist/generate.js +148 -18
  32. package/dist/index.d.ts +11 -9
  33. package/dist/index.js +16 -12
  34. package/dist/introspect.d.ts +1 -1
  35. package/dist/introspect.js +4 -6
  36. package/dist/pipeline.d.ts +1 -1
  37. package/dist/pipeline.js +9 -2
  38. package/dist/query.d.ts +374 -38
  39. package/dist/query.js +545 -116
  40. package/dist/schema-builder.d.ts +38 -5
  41. package/dist/schema-builder.js +150 -31
  42. package/dist/schema-sql.d.ts +7 -3
  43. package/dist/schema-sql.js +241 -37
  44. package/dist/schema.d.ts +1 -1
  45. package/dist/schema.js +5 -2
  46. package/dist/serverless.d.ts +92 -139
  47. package/dist/serverless.js +87 -173
  48. package/package.json +33 -16
  49. package/dist/types.d.ts +0 -93
  50. package/dist/types.js +0 -126
package/dist/cjs/query.js CHANGED
@@ -1,12 +1,12 @@
1
1
  "use strict";
2
2
  /**
3
- * @batadata/turbine — Query builder
3
+ * turbine-orm — Query builder
4
4
  *
5
5
  * Each table accessor (db.users, db.posts, etc.) returns a QueryInterface<T>
6
6
  * that builds parameterized SQL and executes it through the connection pool.
7
7
  *
8
8
  * Nested relations use json_build_object + json_agg subqueries for single-query
9
- * resolution — the technique that benchmarks proved 2-3x faster than Prisma.
9
+ * resolution — a PostgreSQL-native approach that eliminates N+1 query patterns.
10
10
  *
11
11
  * Schema-driven: all column names, types, and relations come from introspected
12
12
  * metadata — nothing is hardcoded.
@@ -14,6 +14,7 @@
14
14
  Object.defineProperty(exports, "__esModule", { value: true });
15
15
  exports.QueryInterface = void 0;
16
16
  exports.quoteIdent = quoteIdent;
17
+ const errors_js_1 = require("./errors.js");
17
18
  const schema_js_1 = require("./schema.js");
18
19
  // ---------------------------------------------------------------------------
19
20
  // Identifier quoting — prevents SQL injection via table/column names
@@ -46,37 +47,98 @@ function escapeLike(value) {
46
47
  }
47
48
  /** Known operator keys — used to detect operator objects vs plain values */
48
49
  const OPERATOR_KEYS = new Set([
49
- 'gt', 'gte', 'lt', 'lte', 'not', 'in', 'notIn',
50
- 'contains', 'startsWith', 'endsWith', 'mode',
50
+ 'gt',
51
+ 'gte',
52
+ 'lt',
53
+ 'lte',
54
+ 'not',
55
+ 'in',
56
+ 'notIn',
57
+ 'contains',
58
+ 'startsWith',
59
+ 'endsWith',
60
+ 'mode',
51
61
  ]);
52
62
  /** Check if a value is a where operator object (has at least one known operator key) */
53
63
  function isWhereOperator(value) {
54
- if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
64
+ if (value === null ||
65
+ value === undefined ||
66
+ typeof value !== 'object' ||
67
+ Array.isArray(value) ||
68
+ value instanceof Date) {
55
69
  return false;
56
70
  }
57
71
  const keys = Object.keys(value);
58
72
  return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
59
73
  }
74
+ /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
75
+ const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
60
76
  /** Known JSONB operator keys */
61
77
  const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
78
+ /**
79
+ * JSONB operator keys that are *unique* to {@link JsonFilter} — they cannot
80
+ * appear in any other where-filter shape, so the presence of one of these is
81
+ * an unambiguous signal that the user meant a JSON filter. Used by the
82
+ * strict-validation path so that `{ contains: 'foo' }` (which is also a valid
83
+ * `WhereOperator` for LIKE) is not misclassified.
84
+ */
85
+ const JSONB_UNIQUE_KEYS = new Set(['path', 'equals', 'hasKey']);
62
86
  /** Check if a value is a JSONB filter object */
63
87
  function isJsonFilter(value) {
64
- if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
88
+ if (value === null ||
89
+ value === undefined ||
90
+ typeof value !== 'object' ||
91
+ Array.isArray(value) ||
92
+ value instanceof Date) {
65
93
  return false;
66
94
  }
67
95
  const keys = Object.keys(value);
68
96
  return keys.length > 0 && keys.some((k) => JSONB_OPERATOR_KEYS.has(k));
69
97
  }
98
+ /**
99
+ * Returns the first JSON-unique key found in `value`, or `null` if none.
100
+ * Used to drive the strict-validation error message.
101
+ */
102
+ function findJsonUniqueKey(value) {
103
+ for (const k of Object.keys(value)) {
104
+ if (JSONB_UNIQUE_KEYS.has(k))
105
+ return k;
106
+ }
107
+ return null;
108
+ }
70
109
  /** Known Array operator keys */
71
110
  const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
111
+ /**
112
+ * Array operator keys that are *unique* to {@link ArrayFilter}. None of the
113
+ * array operators currently overlap with `WhereOperator` or `JsonFilter`, so
114
+ * this set equals {@link ARRAY_OPERATOR_KEYS}; it is kept as a separate
115
+ * constant so a future overlap (e.g. a `contains` for arrays) is easy to
116
+ * carve out.
117
+ */
118
+ const ARRAY_UNIQUE_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
72
119
  /** Check if a value is an Array filter object */
73
120
  function isArrayFilter(value) {
74
- if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
121
+ if (value === null ||
122
+ value === undefined ||
123
+ typeof value !== 'object' ||
124
+ Array.isArray(value) ||
125
+ value instanceof Date) {
75
126
  return false;
76
127
  }
77
128
  const keys = Object.keys(value);
78
129
  return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
79
130
  }
131
+ /**
132
+ * Returns the first array-unique key found in `value`, or `null` if none.
133
+ * Used to drive the strict-validation error message.
134
+ */
135
+ function findArrayUniqueKey(value) {
136
+ for (const k of Object.keys(value)) {
137
+ if (ARRAY_UNIQUE_KEYS.has(k))
138
+ return k;
139
+ }
140
+ return null;
141
+ }
80
142
  // ---------------------------------------------------------------------------
81
143
  // LRU cache — bounded SQL template cache to prevent memory leaks
82
144
  // ---------------------------------------------------------------------------
@@ -112,7 +174,9 @@ class LRUCache {
112
174
  }
113
175
  this.cache.set(key, value);
114
176
  }
115
- get size() { return this.cache.size; }
177
+ get size() {
178
+ return this.cache.size;
179
+ }
116
180
  }
117
181
  class QueryInterface {
118
182
  pool;
@@ -124,6 +188,15 @@ class QueryInterface {
124
188
  middlewares;
125
189
  defaultLimit;
126
190
  warnOnUnlimited;
191
+ /**
192
+ * Tracks tables that have already triggered an unlimited-query warning so
193
+ * the user is not spammed once per row. Per-instance state — each
194
+ * QueryInterface is bound to a single table, so this set will only ever
195
+ * contain at most one entry, but using a Set keeps the API consistent with
196
+ * the audit's "Set<string>" guidance and leaves room for future
197
+ * cross-table sharing.
198
+ */
199
+ warnedTables = new Set();
127
200
  /** Pre-computed column type lookups (avoids linear scans per query) */
128
201
  columnPgTypeMap;
129
202
  columnArrayTypeMap;
@@ -133,12 +206,15 @@ class QueryInterface {
133
206
  this.schema = schema;
134
207
  const meta = schema.tables[table];
135
208
  if (!meta) {
136
- throw new Error(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
209
+ throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
137
210
  }
138
211
  this.tableMeta = meta;
139
212
  this.middlewares = middlewares ?? [];
140
213
  this.defaultLimit = options?.defaultLimit;
141
- this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
214
+ // Default to ON: surfacing accidental full-table scans is more valuable
215
+ // than the (small) risk of noisy logs. Callers explicitly opt out with
216
+ // `warnOnUnlimited: false`.
217
+ this.warnOnUnlimited = options?.warnOnUnlimited !== false;
142
218
  // Pre-compute column type lookup maps (TASK-26)
143
219
  this.columnPgTypeMap = new Map();
144
220
  this.columnArrayTypeMap = new Map();
@@ -147,21 +223,38 @@ class QueryInterface {
147
223
  this.columnArrayTypeMap.set(col.name, col.pgArrayType);
148
224
  }
149
225
  }
226
+ /**
227
+ * Reset the per-instance unlimited-query warning dedupe set.
228
+ * Exposed for tests so a single test process can verify the warning fires
229
+ * exactly once per table without bleeding state between assertions.
230
+ */
231
+ resetUnlimitedWarnings() {
232
+ this.warnedTables.clear();
233
+ }
150
234
  /**
151
235
  * Execute a pool.query with an optional timeout.
152
236
  * If timeout is set, races the query against a timer and rejects on expiry.
237
+ * pg driver errors are translated to typed Turbine errors via wrapPgError.
153
238
  */
154
239
  async queryWithTimeout(sql, params, timeout) {
155
240
  if (!timeout) {
156
- return this.pool.query(sql, params);
241
+ try {
242
+ return await this.pool.query(sql, params);
243
+ }
244
+ catch (err) {
245
+ throw (0, errors_js_1.wrapPgError)(err);
246
+ }
157
247
  }
158
248
  let timer;
159
249
  const timeoutPromise = new Promise((_, reject) => {
160
- timer = setTimeout(() => reject(new Error(`[turbine] Query timed out after ${timeout}ms`)), timeout);
250
+ timer = setTimeout(() => reject(new errors_js_1.TimeoutError(timeout)), timeout);
161
251
  });
162
252
  try {
163
253
  return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
164
254
  }
255
+ catch (err) {
256
+ throw (0, errors_js_1.wrapPgError)(err);
257
+ }
165
258
  finally {
166
259
  clearTimeout(timer);
167
260
  }
@@ -192,18 +285,6 @@ class QueryInterface {
192
285
  };
193
286
  return next(params);
194
287
  }
195
- /**
196
- * Generate a cache key for a query shape.
197
- * Same where-keys + same with-clause = same SQL template.
198
- */
199
- cacheKey(op, whereKeys, withClause, extra) {
200
- let key = `${op}:${whereKeys.sort().join(',')}`;
201
- if (withClause)
202
- key += `:w=${JSON.stringify(Object.keys(withClause).sort())}`;
203
- if (extra)
204
- key += `:${extra}`;
205
- return key;
206
- }
207
288
  // -------------------------------------------------------------------------
208
289
  // findUnique
209
290
  // -------------------------------------------------------------------------
@@ -219,10 +300,11 @@ class QueryInterface {
219
300
  const whereObj = args.where;
220
301
  // Check if all where values are simple (plain equality, no operators/null/OR)
221
302
  const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
222
- const isSimpleWhere = !whereObj['OR'] && whereKeys.every((k) => {
223
- const v = whereObj[k];
224
- return v !== null && !isWhereOperator(v);
225
- });
303
+ const isSimpleWhere = !whereObj.OR &&
304
+ whereKeys.every((k) => {
305
+ const v = whereObj[k];
306
+ return v !== null && !isWhereOperator(v);
307
+ });
226
308
  // For simple queries (no nested with, no operators), use cached SQL template
227
309
  if (!args.with && isSimpleWhere) {
228
310
  const colKey = columnsList ? columnsList.join(',') : '*';
@@ -233,9 +315,7 @@ class QueryInterface {
233
315
  const qt = quoteIdent(this.table);
234
316
  const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
235
317
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
236
- const selectExpr = columnsList
237
- ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
238
- : `${qt}.*`;
318
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
239
319
  sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
240
320
  this.sqlCache.set(ck, sql);
241
321
  }
@@ -253,9 +333,7 @@ class QueryInterface {
253
333
  const { sql: whereSql, params } = this.buildWhere(args.where);
254
334
  if (!args.with) {
255
335
  const qt = quoteIdent(this.table);
256
- const selectExpr = columnsList
257
- ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
258
- : `${qt}.*`;
336
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
259
337
  const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
260
338
  return {
261
339
  sql,
@@ -284,21 +362,39 @@ class QueryInterface {
284
362
  // findMany
285
363
  // -------------------------------------------------------------------------
286
364
  async findMany(args) {
287
- // Warn if no limit specified and warnOnUnlimited is enabled
288
- const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
289
- if (this.warnOnUnlimited && !hasExplicitLimit) {
290
- console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
291
- }
365
+ this.maybeWarnUnlimited(args);
292
366
  return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
293
367
  const deferred = this.buildFindMany(args);
294
368
  const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
295
369
  return deferred.transform(result);
296
370
  });
297
371
  }
372
+ /**
373
+ * Emit a one-time `console.warn` when {@link findMany} is called without an
374
+ * explicit `limit`/`take` and `warnOnUnlimited` has not been disabled.
375
+ *
376
+ * Deduped per QueryInterface instance via {@link warnedTables} so a busy
377
+ * loop calling `db.users.findMany()` thousands of times only logs once.
378
+ * Suppressed when `defaultLimit` is configured (the caller has already
379
+ * opted in to a bounded query) and when the user passed an explicit
380
+ * `limit`, `take`, or `cursor`.
381
+ */
382
+ maybeWarnUnlimited(args) {
383
+ if (!this.warnOnUnlimited)
384
+ return;
385
+ if (this.defaultLimit !== undefined)
386
+ return;
387
+ const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined || args?.cursor !== undefined;
388
+ if (hasExplicitLimit)
389
+ return;
390
+ if (this.warnedTables.has(this.table))
391
+ return;
392
+ this.warnedTables.add(this.table);
393
+ console.warn(`[turbine] warning: findMany on "${this.table}" has no limit — this will fetch every row. ` +
394
+ 'Pass `limit` or set `warnOnUnlimited: false` in config to silence.');
395
+ }
298
396
  buildFindMany(args) {
299
- const { sql: whereSql, params } = args?.where
300
- ? this.buildWhere(args.where)
301
- : { sql: '', params: [] };
397
+ const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
302
398
  const columnsList = this.resolveColumns(args?.select, args?.omit);
303
399
  const qt = quoteIdent(this.table);
304
400
  // Distinct support
@@ -355,13 +451,68 @@ class QueryInterface {
355
451
  return {
356
452
  sql,
357
453
  params,
358
- transform: (result) => result.rows.map((row) => args?.with
359
- ? this.parseNestedRow(row, this.table)
360
- : this.parseRow(row, this.table)),
454
+ transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
361
455
  tag: `${this.table}.findMany`,
362
456
  };
363
457
  }
364
458
  // -------------------------------------------------------------------------
459
+ // findManyStream — async iterable using PostgreSQL cursors
460
+ // -------------------------------------------------------------------------
461
+ /**
462
+ * Stream rows from a findMany query using PostgreSQL cursors.
463
+ * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
464
+ *
465
+ * Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
466
+ * The cursor is automatically closed and the connection released when iteration
467
+ * completes or is terminated early (e.g. `break` from `for await`).
468
+ *
469
+ * @example
470
+ * ```ts
471
+ * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
472
+ * process.stdout.write(`${user.email}\n`);
473
+ * }
474
+ * ```
475
+ */
476
+ async *findManyStream(args) {
477
+ const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
478
+ const deferred = this.buildFindMany(args);
479
+ const hasRelations = !!args?.with;
480
+ // Acquire a dedicated connection — cursors require a single connection in a transaction
481
+ const client = await this.pool.connect();
482
+ const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
483
+ const quotedCursor = quoteIdent(cursorName);
484
+ try {
485
+ await client.query('BEGIN');
486
+ await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
487
+ while (true) {
488
+ const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
489
+ if (batch.rows.length === 0)
490
+ break;
491
+ for (const row of batch.rows) {
492
+ yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
493
+ }
494
+ if (batch.rows.length < batchSize)
495
+ break;
496
+ }
497
+ await client.query(`CLOSE ${quotedCursor}`);
498
+ await client.query('COMMIT');
499
+ }
500
+ catch (err) {
501
+ // Rollback on error (also closes cursor implicitly)
502
+ try {
503
+ await client.query('ROLLBACK');
504
+ }
505
+ catch {
506
+ // Connection may already be broken — ignore rollback error
507
+ }
508
+ // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
509
+ throw (0, errors_js_1.wrapPgError)(err);
510
+ }
511
+ finally {
512
+ client.release();
513
+ }
514
+ }
515
+ // -------------------------------------------------------------------------
365
516
  // findFirst — like findMany but returns a single row or null
366
517
  // -------------------------------------------------------------------------
367
518
  async findFirst(args) {
@@ -403,7 +554,11 @@ class QueryInterface {
403
554
  transform: (result) => {
404
555
  const row = inner.transform(result);
405
556
  if (row === null) {
406
- throw new Error('Record not found');
557
+ throw new errors_js_1.NotFoundError({
558
+ table: this.table,
559
+ where: args?.where,
560
+ operation: 'findFirstOrThrow',
561
+ });
407
562
  }
408
563
  return row;
409
564
  },
@@ -428,7 +583,11 @@ class QueryInterface {
428
583
  transform: (result) => {
429
584
  const row = inner.transform(result);
430
585
  if (row === null) {
431
- throw new Error('Record not found');
586
+ throw new errors_js_1.NotFoundError({
587
+ table: this.table,
588
+ where: args.where,
589
+ operation: 'findUniqueOrThrow',
590
+ });
432
591
  }
433
592
  return row;
434
593
  },
@@ -456,8 +615,13 @@ class QueryInterface {
456
615
  params,
457
616
  transform: (result) => {
458
617
  const row = result.rows[0];
459
- if (!row)
460
- throw new Error('[turbine] Expected a row but query returned none');
618
+ if (!row) {
619
+ throw new errors_js_1.NotFoundError({
620
+ table: this.table,
621
+ operation: 'create',
622
+ message: `[turbine] create on "${this.table}" returned no row from RETURNING * — this should never happen.`,
623
+ });
624
+ }
461
625
  return this.parseRow(row, this.table);
462
626
  },
463
627
  tag: `${this.table}.create`,
@@ -522,23 +686,26 @@ class QueryInterface {
522
686
  }
523
687
  buildUpdate(args) {
524
688
  const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
525
- // Build SET params first
689
+ // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
526
690
  const params = [];
527
- const setClauses = setEntries.map(([k, v]) => {
528
- params.push(v);
529
- return `${this.toSqlColumn(k)} = $${params.length}`;
530
- });
691
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
531
692
  // Build WHERE using the shared params array (continues numbering after SET params)
532
693
  const whereClause = this.buildWhereClause(args.where, params);
533
694
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
695
+ this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
534
696
  const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
535
697
  return {
536
698
  sql,
537
699
  params,
538
700
  transform: (result) => {
539
701
  const row = result.rows[0];
540
- if (!row)
541
- throw new Error('[turbine] Expected a row but query returned none');
702
+ if (!row) {
703
+ throw new errors_js_1.NotFoundError({
704
+ table: this.table,
705
+ where: args.where,
706
+ operation: 'update',
707
+ });
708
+ }
542
709
  return this.parseRow(row, this.table);
543
710
  },
544
711
  tag: `${this.table}.update`,
@@ -556,14 +723,20 @@ class QueryInterface {
556
723
  }
557
724
  buildDelete(args) {
558
725
  const { sql: whereSql, params } = this.buildWhere(args.where);
726
+ this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
559
727
  const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
560
728
  return {
561
729
  sql,
562
730
  params,
563
731
  transform: (result) => {
564
732
  const row = result.rows[0];
565
- if (!row)
566
- throw new Error('[turbine] Expected a row but query returned none');
733
+ if (!row) {
734
+ throw new errors_js_1.NotFoundError({
735
+ table: this.table,
736
+ where: args.where,
737
+ operation: 'delete',
738
+ });
739
+ }
567
740
  return this.parseRow(row, this.table);
568
741
  },
569
742
  tag: `${this.table}.delete`,
@@ -606,8 +779,14 @@ class QueryInterface {
606
779
  params,
607
780
  transform: (result) => {
608
781
  const row = result.rows[0];
609
- if (!row)
610
- throw new Error('[turbine] Expected a row but query returned none');
782
+ if (!row) {
783
+ throw new errors_js_1.NotFoundError({
784
+ table: this.table,
785
+ where: args.where,
786
+ operation: 'upsert',
787
+ message: `[turbine] upsert on "${this.table}" returned no row from RETURNING * — this should never happen.`,
788
+ });
789
+ }
611
790
  return this.parseRow(row, this.table);
612
791
  },
613
792
  tag: `${this.table}.upsert`,
@@ -625,15 +804,13 @@ class QueryInterface {
625
804
  }
626
805
  buildUpdateMany(args) {
627
806
  const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
628
- // Build SET params first
807
+ // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
629
808
  const params = [];
630
- const setClauses = setEntries.map(([k, v]) => {
631
- params.push(v);
632
- return `${this.toSqlColumn(k)} = $${params.length}`;
633
- });
809
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
634
810
  // Build WHERE using the shared params array (continues numbering after SET params)
635
811
  const whereClause = this.buildWhereClause(args.where, params);
636
812
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
813
+ this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
637
814
  const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
638
815
  return {
639
816
  sql,
@@ -654,6 +831,7 @@ class QueryInterface {
654
831
  }
655
832
  buildDeleteMany(args) {
656
833
  const { sql: whereSql, params } = this.buildWhere(args.where);
834
+ this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
657
835
  const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
658
836
  return {
659
837
  sql,
@@ -673,9 +851,7 @@ class QueryInterface {
673
851
  });
674
852
  }
675
853
  buildCount(args) {
676
- const { sql: whereSql, params } = args?.where
677
- ? this.buildWhere(args.where)
678
- : { sql: '', params: [] };
854
+ const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
679
855
  const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
680
856
  return {
681
857
  sql,
@@ -695,11 +871,17 @@ class QueryInterface {
695
871
  });
696
872
  }
697
873
  buildGroupBy(args) {
874
+ const meta = this.schema.tables[this.table];
875
+ if (meta) {
876
+ for (const key of args.by) {
877
+ if (!(key in meta.columnMap)) {
878
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
879
+ }
880
+ }
881
+ }
698
882
  const groupColsRaw = args.by.map((k) => this.toColumn(k));
699
883
  const groupCols = groupColsRaw.map((c) => quoteIdent(c));
700
- const { sql: whereSql, params } = args.where
701
- ? this.buildWhere(args.where)
702
- : { sql: '', params: [] };
884
+ const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
703
885
  // Build SELECT expressions: group-by columns + aggregate functions
704
886
  const selectExprs = [...groupCols];
705
887
  // _count
@@ -712,7 +894,7 @@ class QueryInterface {
712
894
  for (const [field, enabled] of Object.entries(args._sum)) {
713
895
  if (enabled) {
714
896
  const col = this.toColumn(field);
715
- selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
897
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
716
898
  }
717
899
  }
718
900
  }
@@ -721,7 +903,7 @@ class QueryInterface {
721
903
  for (const [field, enabled] of Object.entries(args._avg)) {
722
904
  if (enabled) {
723
905
  const col = this.toColumn(field);
724
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
906
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
725
907
  }
726
908
  }
727
909
  }
@@ -730,7 +912,7 @@ class QueryInterface {
730
912
  for (const [field, enabled] of Object.entries(args._min)) {
731
913
  if (enabled) {
732
914
  const col = this.toColumn(field);
733
- selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
915
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
734
916
  }
735
917
  }
736
918
  }
@@ -739,7 +921,7 @@ class QueryInterface {
739
921
  for (const [field, enabled] of Object.entries(args._max)) {
740
922
  if (enabled) {
741
923
  const col = this.toColumn(field);
742
- selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
924
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
743
925
  }
744
926
  }
745
927
  }
@@ -822,9 +1004,26 @@ class QueryInterface {
822
1004
  });
823
1005
  }
824
1006
  buildAggregate(args) {
825
- const { sql: whereSql, params } = args.where
826
- ? this.buildWhere(args.where)
827
- : { sql: '', params: [] };
1007
+ const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
1008
+ const meta = this.schema.tables[this.table];
1009
+ if (meta) {
1010
+ for (const group of [args._sum, args._avg, args._min, args._max]) {
1011
+ if (group && typeof group === 'object') {
1012
+ for (const key of Object.keys(group)) {
1013
+ if (!(key in meta.columnMap)) {
1014
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
1015
+ }
1016
+ }
1017
+ }
1018
+ }
1019
+ if (args._count && typeof args._count === 'object') {
1020
+ for (const key of Object.keys(args._count)) {
1021
+ if (!(key in meta.columnMap)) {
1022
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
1023
+ }
1024
+ }
1025
+ }
1026
+ }
828
1027
  const selectExprs = [];
829
1028
  // _count
830
1029
  if (args._count === true) {
@@ -834,7 +1033,7 @@ class QueryInterface {
834
1033
  for (const [field, enabled] of Object.entries(args._count)) {
835
1034
  if (enabled) {
836
1035
  const col = this.toColumn(field);
837
- selectExprs.push(`COUNT(${quoteIdent(col)})::int AS _count_${col}`);
1036
+ selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
838
1037
  }
839
1038
  }
840
1039
  }
@@ -843,7 +1042,7 @@ class QueryInterface {
843
1042
  for (const [field, enabled] of Object.entries(args._sum)) {
844
1043
  if (enabled) {
845
1044
  const col = this.toColumn(field);
846
- selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
1045
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
847
1046
  }
848
1047
  }
849
1048
  }
@@ -852,7 +1051,7 @@ class QueryInterface {
852
1051
  for (const [field, enabled] of Object.entries(args._avg)) {
853
1052
  if (enabled) {
854
1053
  const col = this.toColumn(field);
855
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
1054
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
856
1055
  }
857
1056
  }
858
1057
  }
@@ -861,7 +1060,7 @@ class QueryInterface {
861
1060
  for (const [field, enabled] of Object.entries(args._min)) {
862
1061
  if (enabled) {
863
1062
  const col = this.toColumn(field);
864
- selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
1063
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
865
1064
  }
866
1065
  }
867
1066
  }
@@ -870,7 +1069,7 @@ class QueryInterface {
870
1069
  for (const [field, enabled] of Object.entries(args._max)) {
871
1070
  if (enabled) {
872
1071
  const col = this.toColumn(field);
873
- selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
1072
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
874
1073
  }
875
1074
  }
876
1075
  }
@@ -982,6 +1181,67 @@ class QueryInterface {
982
1181
  toSqlColumn(field) {
983
1182
  return quoteIdent(this.toColumn(field));
984
1183
  }
1184
+ /**
1185
+ * Build a single SET clause entry for update/updateMany.
1186
+ *
1187
+ * Supports plain values and atomic operator objects ({ set, increment,
1188
+ * decrement, multiply, divide }). An operator object is detected ONLY when
1189
+ * it has EXACTLY one key that is one of the 5 operator keys — this avoids
1190
+ * misinterpreting JSON column values like `{ set: 'x' }` as operators
1191
+ * (real operator objects always have exactly one key, and a plain JSON
1192
+ * payload that happens to have a single `set` key is extremely unusual).
1193
+ * Multi-key objects are always treated as plain (JSON) values.
1194
+ *
1195
+ * Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
1196
+ * pushes any required params onto the shared params array so that WHERE
1197
+ * clause numbering continues correctly afterward.
1198
+ */
1199
+ buildSetClause(key, value, params) {
1200
+ const col = this.toSqlColumn(key);
1201
+ // Detect atomic-operator object: plain object (not null, not array, not
1202
+ // Date, not Buffer) with EXACTLY one key matching an operator name.
1203
+ if (value !== null &&
1204
+ typeof value === 'object' &&
1205
+ !Array.isArray(value) &&
1206
+ !(value instanceof Date) &&
1207
+ !Buffer.isBuffer(value)) {
1208
+ const v = value;
1209
+ const keys = Object.keys(v);
1210
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1211
+ const op = keys[0];
1212
+ const opValue = v[op];
1213
+ if (op === 'set') {
1214
+ params.push(opValue);
1215
+ return `${col} = $${params.length}`;
1216
+ }
1217
+ // Arithmetic operators: must be finite numbers
1218
+ if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
1219
+ throw new errors_js_1.ValidationError(`[turbine] update operator "${op}" on "${this.table}.${key}" requires a finite number, got ${typeof opValue}`);
1220
+ }
1221
+ if (op === 'increment') {
1222
+ params.push(opValue);
1223
+ return `${col} = ${col} + $${params.length}`;
1224
+ }
1225
+ if (op === 'decrement') {
1226
+ params.push(opValue);
1227
+ return `${col} = ${col} - $${params.length}`;
1228
+ }
1229
+ if (op === 'multiply') {
1230
+ params.push(opValue);
1231
+ return `${col} = ${col} * $${params.length}`;
1232
+ }
1233
+ if (op === 'divide') {
1234
+ params.push(opValue);
1235
+ return `${col} = ${col} / $${params.length}`;
1236
+ }
1237
+ }
1238
+ // Fall through: multi-key objects or non-operator single-key objects
1239
+ // are treated as plain values (e.g., JSONB column payloads).
1240
+ }
1241
+ // Plain value (including null, Date, Buffer, arrays, JSON objects)
1242
+ params.push(value);
1243
+ return `${col} = $${params.length}`;
1244
+ }
985
1245
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
986
1246
  buildWhere(where) {
987
1247
  const params = [];
@@ -990,6 +1250,22 @@ class QueryInterface {
990
1250
  return { sql: '', params: [] };
991
1251
  return { sql: ` WHERE ${clause}`, params };
992
1252
  }
1253
+ /**
1254
+ * Refuse mutations with an empty predicate unless explicitly opted in.
1255
+ *
1256
+ * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
1257
+ * mutation with no filter — a common footgun when a caller's filter
1258
+ * value accidentally resolves to `undefined`. This guard throws
1259
+ * `ValidationError` in that case unless `allowFullTableScan: true`.
1260
+ */
1261
+ assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
1262
+ if (whereSql.length > 0)
1263
+ return;
1264
+ if (allowFullTableScan === true)
1265
+ return;
1266
+ throw new errors_js_1.ValidationError(`[turbine] ${operation} on "${this.table}" refused: the \`where\` clause is empty. ` +
1267
+ `Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
1268
+ }
993
1269
  /**
994
1270
  * Build the inner WHERE expression (without the WHERE keyword).
995
1271
  * Returns null if no conditions exist.
@@ -1067,6 +1343,16 @@ class QueryInterface {
1067
1343
  andClauses.push(...jsonClauses);
1068
1344
  continue;
1069
1345
  }
1346
+ // Strict validation: a JSON-only operator on a non-JSON column was almost
1347
+ // certainly a typo or schema mismatch. Silently falling through to plain
1348
+ // equality (the previous behaviour) wasted hours of debugging time. Only
1349
+ // throw when the operator is unambiguously JSON-specific — `contains` is
1350
+ // shared with WhereOperator's LIKE so it must continue to fall through.
1351
+ const jsonKey = findJsonUniqueKey(value);
1352
+ if (jsonKey) {
1353
+ throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not a JSON column ` +
1354
+ `(actual type: ${colType}); cannot apply JSON operator '${jsonKey}'.`);
1355
+ }
1070
1356
  }
1071
1357
  // Handle Array filter operators (for array columns)
1072
1358
  if (typeof value === 'object' && !Array.isArray(value) && isArrayFilter(value)) {
@@ -1076,6 +1362,14 @@ class QueryInterface {
1076
1362
  andClauses.push(...arrayClauses);
1077
1363
  continue;
1078
1364
  }
1365
+ // Strict validation: array operators (`has`, `hasEvery`, ...) on a
1366
+ // non-array column always indicate a mistake. None of these keys
1367
+ // overlap with other filter shapes so we can throw unconditionally.
1368
+ const arrayKey = findArrayUniqueKey(value);
1369
+ if (arrayKey) {
1370
+ throw new errors_js_1.ValidationError(`[turbine] Column "${rawColumn}" on table "${this.table}" is not an array column ` +
1371
+ `(actual type: ${colType}); cannot apply array operator '${arrayKey}'.`);
1372
+ }
1079
1373
  }
1080
1374
  // Handle operator objects
1081
1375
  if (isWhereOperator(value)) {
@@ -1226,8 +1520,12 @@ class QueryInterface {
1226
1520
  }
1227
1521
  /** Build ORDER BY clause from an object */
1228
1522
  buildOrderBy(orderBy) {
1523
+ const meta = this.schema.tables[this.table];
1229
1524
  return Object.entries(orderBy)
1230
1525
  .map(([key, dir]) => {
1526
+ if (meta && !(key in meta.columnMap)) {
1527
+ throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
1528
+ }
1231
1529
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1232
1530
  return `${this.toSqlColumn(key)} ${safeDir}`;
1233
1531
  })
@@ -1276,6 +1574,7 @@ class QueryInterface {
1276
1574
  parsed[relName] = JSON.parse(rawValue);
1277
1575
  }
1278
1576
  catch {
1577
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1279
1578
  parsed[relName] = rawValue;
1280
1579
  }
1281
1580
  }
@@ -1295,52 +1594,173 @@ class QueryInterface {
1295
1594
  return parsed;
1296
1595
  }
1297
1596
  /**
1298
- * Build a SELECT clause with nested relation subqueries.
1597
+ * Build a SELECT clause that includes both base columns and nested relation subqueries.
1598
+ *
1599
+ * For each relation specified in the `with` clause, this method generates a correlated
1600
+ * subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
1601
+ * is a single SQL SELECT clause that resolves the full object tree in one query --
1602
+ * no N+1 problem.
1603
+ *
1604
+ * **How it works:**
1605
+ * 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
1606
+ * 2. Iterates over each key in the `with` clause, looking up the relation definition.
1607
+ * 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
1608
+ * correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
1609
+ * 4. Each subquery is aliased as the relation name in the final SELECT.
1610
+ *
1611
+ * **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
1612
+ * Each call to `buildRelationSubquery` increments it to produce unique table aliases
1613
+ * (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
1614
+ * collisions in the generated SQL.
1299
1615
  *
1300
- * Uses json_build_object + json_agg — the same approach as the raw-pg benchmark
1301
- * queries. Generates a single SQL statement that resolves the full object tree.
1616
+ * **Example output:**
1617
+ * ```sql
1618
+ * "users"."id", "users"."name", "users"."email",
1619
+ * (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
1620
+ * FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
1621
+ * ```
1302
1622
  *
1303
- * Nested where values are parameterized via the shared params array to prevent
1304
- * SQL injection.
1623
+ * @param table - The root table name (e.g. `"users"`).
1624
+ * @param withClause - An object mapping relation names to their include specs
1625
+ * (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
1626
+ * @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
1627
+ * Nested where/limit values are pushed here to prevent SQL injection.
1628
+ * @param columnsList - Optional subset of columns to include in the SELECT. When `null`
1629
+ * or omitted, all columns from the table's schema metadata are used.
1630
+ * @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
1631
+ * for circular-relation detection. Defaults to `0` at the top level.
1632
+ * @param path - Breadcrumb trail of relation names traversed so far, used in error
1633
+ * messages when circular or too-deep nesting is detected.
1634
+ * @returns A complete SELECT clause string (without the `SELECT` keyword) containing
1635
+ * base columns and relation subqueries.
1305
1636
  */
1306
- buildSelectWithRelations(table, withClause, params, columnsList) {
1637
+ buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
1307
1638
  const meta = this.schema.tables[table];
1308
1639
  if (!meta)
1309
- throw new Error(`[turbine] Unknown table "${table}"`);
1640
+ throw new errors_js_1.ValidationError(`[turbine] Unknown table "${table}"`);
1310
1641
  const cols = columnsList ?? meta.allColumns;
1311
1642
  const qtbl = quoteIdent(table);
1312
- const baseCols = cols
1313
- .map((col) => `${qtbl}.${quoteIdent(col)}`)
1314
- .join(', ');
1643
+ const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
1315
1644
  const relationSelects = [];
1316
1645
  const aliasCounter = { n: 0 };
1317
1646
  for (const [relName, relSpec] of Object.entries(withClause)) {
1318
1647
  const relDef = meta.relations[relName];
1319
1648
  if (!relDef) {
1320
- throw new Error(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1649
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1321
1650
  `Available: ${Object.keys(meta.relations).join(', ')}`);
1322
1651
  }
1323
1652
  // The main table is not aliased, so pass table name as parentRef
1324
- const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
1653
+ const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
1325
1654
  relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
1326
1655
  }
1327
1656
  return [baseCols, ...relationSelects].join(', ');
1328
1657
  }
1329
1658
  /**
1330
- * Build a json_agg subquery for a relation.
1659
+ * Generate a correlated subquery that returns JSON for a single relation.
1660
+ *
1661
+ * This is the core of Turbine's single-query nested relation strategy. For a given
1662
+ * relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
1663
+ * that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
1664
+ * a single JSON object (belongsTo / hasOne).
1665
+ *
1666
+ * ### Algorithm overview
1667
+ *
1668
+ * 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
1669
+ * `aliasCounter` so that deeply nested subqueries never collide.
1670
+ *
1671
+ * 2. **Column resolution:** Honors `select` / `omit` options to control which columns
1672
+ * appear in the output JSON.
1673
+ *
1674
+ * 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
1675
+ * field names to their column values:
1676
+ * ```sql
1677
+ * json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
1678
+ * ```
1331
1679
  *
1332
- * All user-supplied values in nested where clauses are parameterized
1333
- * through the shared params array no string interpolation.
1680
+ * 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
1681
+ * `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
1682
+ * into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
1683
+ * For belongsTo / hasOne, no aggregation is used -- just the single JSON object
1684
+ * with `LIMIT 1`.
1334
1685
  *
1335
- * @param parentRef - The alias (or table name) of the parent in the outer query.
1336
- * Used for the correlated WHERE clause: `child.fk = parentRef.pk`.
1337
- * @param aliasCounter - Shared counter for generating unique aliases across nested levels.
1686
+ * 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
1687
+ * - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
1688
+ * (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
1689
+ * - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
1690
+ * (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
1691
+ *
1692
+ * 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
1693
+ * itself recursively for each nested relation, passing the current alias as
1694
+ * `parentRef`. The nested subquery appears as an additional key in the
1695
+ * `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
1696
+ * Depth is incremented and capped at 10 to guard against circular relations.
1697
+ *
1698
+ * 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
1699
+ * the query is restructured into a two-level form:
1700
+ * ```sql
1701
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
1702
+ * FROM (
1703
+ * SELECT t0.* FROM "posts" t0
1704
+ * WHERE t0."user_id" = "users"."id"
1705
+ * ORDER BY t0."created_at" DESC
1706
+ * LIMIT $1
1707
+ * ) t0i
1708
+ * ```
1709
+ * This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
1710
+ * aggregation. Without the inner subquery, LIMIT would be meaningless because
1711
+ * `json_agg` produces a single aggregated row.
1712
+ *
1713
+ * 8. **Parameter threading:** All user-supplied values (where filters, limit) are
1714
+ * pushed to the shared `params` array with `$N` placeholders. No string
1715
+ * interpolation of user data ever occurs -- all identifiers go through
1716
+ * `quoteIdent()` and all values are parameterized.
1717
+ *
1718
+ * ### Example output (hasMany with nested relation)
1719
+ * ```sql
1720
+ * SELECT COALESCE(json_agg(json_build_object(
1721
+ * 'id', t0."id",
1722
+ * 'title', t0."title",
1723
+ * 'comments', COALESCE((
1724
+ * SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
1725
+ * FROM "comments" t1 WHERE t1."post_id" = t0."id"
1726
+ * ), '[]'::json)
1727
+ * )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
1728
+ * ```
1729
+ *
1730
+ * @param relDef - The relation definition from schema metadata (contains `to`, `type`,
1731
+ * `foreignKey`, `referenceKey`).
1732
+ * @param spec - Either `true` (include with defaults) or a `WithOptions` object that
1733
+ * can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
1734
+ * @param params - Shared parameter array. User-supplied values are pushed here and
1735
+ * referenced as `$1`, `$2`, etc. in the generated SQL.
1736
+ * @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
1737
+ * parent query. Used to build the correlated WHERE clause that ties
1738
+ * child rows to their parent row.
1739
+ * @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
1740
+ * table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
1741
+ * Each call increments `n` by 1.
1742
+ * @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
1743
+ * call. If it reaches 10, a {@link CircularRelationError} is thrown.
1744
+ * @param path - Breadcrumb trail of relation/table names traversed so far
1745
+ * (e.g. `["users", "posts", "comments"]`). Used in the error message
1746
+ * when circular or too-deep nesting is detected.
1747
+ * @returns A complete SQL subquery string (without surrounding parentheses) that
1748
+ * evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
1338
1749
  */
1339
- buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter) {
1750
+ buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter, depth, path) {
1751
+ const currentDepth = depth ?? 0;
1752
+ const currentPath = path ?? [this.table];
1340
1753
  const targetTable = relDef.to;
1754
+ // Hard depth cap — the `with` clause is a finite JSON structure so users can't
1755
+ // create true infinite recursion, but extremely deep nesting (10+ levels) produces
1756
+ // unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
1757
+ // since they are legitimate queries (Prisma supports the same pattern).
1758
+ if (currentDepth >= 10) {
1759
+ throw new errors_js_1.CircularRelationError([...currentPath, targetTable]);
1760
+ }
1341
1761
  const targetMeta = this.schema.tables[targetTable];
1342
1762
  if (!targetMeta)
1343
- throw new Error(`[turbine] Unknown relation target "${targetTable}"`);
1763
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation target "${targetTable}"`);
1344
1764
  // Generate a unique alias: t0, t1, t2, ...
1345
1765
  const alias = `t${aliasCounter.n++}`;
1346
1766
  // Resolve which columns to include based on select/omit
@@ -1359,17 +1779,23 @@ class QueryInterface {
1359
1779
  }
1360
1780
  // Build json_build_object pairs for resolved columns
1361
1781
  const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col))}', ${alias}.${quoteIdent(col)}`);
1362
- // Nested relations?
1363
- if (spec !== true && spec.with) {
1782
+ // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
1783
+ // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
1784
+ // so we must NOT build them here (they would push orphaned params).
1785
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
1786
+ // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
1787
+ if (!willWrap && spec !== true && spec.with) {
1364
1788
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1365
1789
  const nestedRelDef = targetMeta.relations[nestedRelName];
1366
1790
  if (!nestedRelDef) {
1367
- throw new Error(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1791
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1368
1792
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1369
1793
  }
1370
1794
  // Recursively build nested subquery, passing THIS alias as the parent reference
1371
- const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
1372
- jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), '[]'::json)`);
1795
+ const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
1796
+ // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
1797
+ const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
1798
+ jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
1373
1799
  }
1374
1800
  }
1375
1801
  const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
@@ -1383,7 +1809,7 @@ class QueryInterface {
1383
1809
  .map(([k, dir]) => {
1384
1810
  const col = (0, schema_js_1.camelToSnake)(k);
1385
1811
  if (!targetMeta.allColumns.includes(col)) {
1386
- throw new Error(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
1812
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
1387
1813
  }
1388
1814
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1389
1815
  return `${alias}.${quoteIdent(col)} ${safeDir}`;
@@ -1406,7 +1832,7 @@ class QueryInterface {
1406
1832
  for (const [k, v] of Object.entries(spec.where)) {
1407
1833
  const col = (0, schema_js_1.camelToSnake)(k);
1408
1834
  if (!targetMeta.allColumns.includes(col)) {
1409
- throw new Error(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
1835
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
1410
1836
  }
1411
1837
  params.push(v);
1412
1838
  whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
@@ -1428,14 +1854,17 @@ class QueryInterface {
1428
1854
  const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1429
1855
  // For the json_build_object, reference the inner alias — only include resolved columns
1430
1856
  const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? (0, schema_js_1.snakeToCamel)(col))}', ${innerAlias}.${quoteIdent(col)}`);
1431
- // Re-add nested relation subqueries referencing innerAlias
1857
+ // Build nested relation subqueries referencing innerAlias
1432
1858
  if (spec !== true && spec.with) {
1433
- for (const [nestedRelName] of Object.entries(spec.with)) {
1859
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1434
1860
  const nestedRelDef = targetMeta.relations[nestedRelName];
1435
- if (nestedRelDef) {
1436
- const nestedSub = this.buildRelationSubquery(nestedRelDef, spec.with[nestedRelName], params, innerAlias, aliasCounter);
1437
- innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), '[]'::json)`);
1861
+ if (!nestedRelDef) {
1862
+ throw new errors_js_1.RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1863
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1438
1864
  }
1865
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
1866
+ const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
1867
+ innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
1439
1868
  }
1440
1869
  }
1441
1870
  const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;