turbine-orm 0.4.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 (57) hide show
  1. package/README.md +243 -26
  2. package/dist/cjs/cli/config.js +151 -0
  3. package/dist/cjs/cli/index.js +1176 -0
  4. package/dist/cjs/cli/migrate.js +446 -0
  5. package/dist/cjs/cli/ui.js +233 -0
  6. package/dist/cjs/client.js +512 -0
  7. package/dist/cjs/errors.js +293 -0
  8. package/dist/cjs/generate.js +321 -0
  9. package/dist/cjs/index.js +94 -0
  10. package/dist/cjs/introspect.js +287 -0
  11. package/dist/cjs/package.json +1 -0
  12. package/dist/cjs/pipeline.js +78 -0
  13. package/dist/cjs/query.js +1891 -0
  14. package/dist/cjs/schema-builder.js +238 -0
  15. package/dist/cjs/schema-sql.js +509 -0
  16. package/dist/cjs/schema.js +140 -0
  17. package/dist/cjs/serverless.js +110 -0
  18. package/dist/cli/config.js +6 -16
  19. package/dist/cli/index.js +256 -49
  20. package/dist/cli/migrate.d.ts +35 -6
  21. package/dist/cli/migrate.js +124 -76
  22. package/dist/cli/ui.js +5 -9
  23. package/dist/client.d.ts +87 -3
  24. package/dist/client.js +122 -46
  25. package/dist/errors.d.ts +138 -0
  26. package/dist/errors.js +278 -0
  27. package/dist/generate.js +37 -11
  28. package/dist/index.d.ts +10 -8
  29. package/dist/index.js +15 -11
  30. package/dist/introspect.js +3 -5
  31. package/dist/pipeline.js +8 -1
  32. package/dist/query.d.ts +310 -45
  33. package/dist/query.js +565 -237
  34. package/dist/schema-builder.js +91 -23
  35. package/dist/schema-sql.d.ts +6 -2
  36. package/dist/schema-sql.js +180 -26
  37. package/dist/schema.js +4 -1
  38. package/dist/serverless.d.ts +91 -139
  39. package/dist/serverless.js +86 -173
  40. package/package.json +44 -21
  41. package/dist/cli/config.d.ts.map +0 -1
  42. package/dist/cli/index.d.ts.map +0 -1
  43. package/dist/cli/migrate.d.ts.map +0 -1
  44. package/dist/cli/ui.d.ts.map +0 -1
  45. package/dist/client.d.ts.map +0 -1
  46. package/dist/generate.d.ts.map +0 -1
  47. package/dist/index.d.ts.map +0 -1
  48. package/dist/introspect.d.ts.map +0 -1
  49. package/dist/pipeline.d.ts.map +0 -1
  50. package/dist/query.d.ts.map +0 -1
  51. package/dist/schema-builder.d.ts.map +0 -1
  52. package/dist/schema-sql.d.ts.map +0 -1
  53. package/dist/schema.d.ts.map +0 -1
  54. package/dist/serverless.d.ts.map +0 -1
  55. package/dist/types.d.ts +0 -93
  56. package/dist/types.d.ts.map +0 -1
  57. package/dist/types.js +0 -126
package/dist/query.js CHANGED
@@ -5,12 +5,13 @@
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.
12
12
  */
13
- import { snakeToCamel, camelToSnake } from './schema.js';
13
+ import { CircularRelationError, NotFoundError, RelationError, TimeoutError, ValidationError, wrapPgError, } from './errors.js';
14
+ import { camelToSnake, snakeToCamel } from './schema.js';
14
15
  // ---------------------------------------------------------------------------
15
16
  // Identifier quoting — prevents SQL injection via table/column names
16
17
  // ---------------------------------------------------------------------------
@@ -26,6 +27,13 @@ import { snakeToCamel, camelToSnake } from './schema.js';
26
27
  export function quoteIdent(name) {
27
28
  return `"${name.replace(/"/g, '""')}"`;
28
29
  }
30
+ /**
31
+ * Escape single quotes for use as string keys in json_build_object().
32
+ * Doubles single quotes per SQL quoting rules.
33
+ */
34
+ function escSingleQuote(s) {
35
+ return s.replace(/'/g, "''");
36
+ }
29
37
  /**
30
38
  * Escape LIKE pattern metacharacters: %, _, and \.
31
39
  * Must be used with `ESCAPE '\'` in the LIKE clause.
@@ -35,22 +43,41 @@ function escapeLike(value) {
35
43
  }
36
44
  /** Known operator keys — used to detect operator objects vs plain values */
37
45
  const OPERATOR_KEYS = new Set([
38
- 'gt', 'gte', 'lt', 'lte', 'not', 'in', 'notIn',
39
- 'contains', 'startsWith', 'endsWith',
46
+ 'gt',
47
+ 'gte',
48
+ 'lt',
49
+ 'lte',
50
+ 'not',
51
+ 'in',
52
+ 'notIn',
53
+ 'contains',
54
+ 'startsWith',
55
+ 'endsWith',
56
+ 'mode',
40
57
  ]);
41
58
  /** Check if a value is a where operator object (has at least one known operator key) */
42
59
  function isWhereOperator(value) {
43
- if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
60
+ if (value === null ||
61
+ value === undefined ||
62
+ typeof value !== 'object' ||
63
+ Array.isArray(value) ||
64
+ value instanceof Date) {
44
65
  return false;
45
66
  }
46
67
  const keys = Object.keys(value);
47
68
  return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
48
69
  }
70
+ /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
71
+ const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
49
72
  /** Known JSONB operator keys */
50
73
  const JSONB_OPERATOR_KEYS = new Set(['path', 'equals', 'contains', 'hasKey']);
51
74
  /** Check if a value is a JSONB filter object */
52
75
  function isJsonFilter(value) {
53
- if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
76
+ if (value === null ||
77
+ value === undefined ||
78
+ typeof value !== 'object' ||
79
+ Array.isArray(value) ||
80
+ value instanceof Date) {
54
81
  return false;
55
82
  }
56
83
  const keys = Object.keys(value);
@@ -60,38 +87,124 @@ function isJsonFilter(value) {
60
87
  const ARRAY_OPERATOR_KEYS = new Set(['has', 'hasEvery', 'hasSome', 'isEmpty']);
61
88
  /** Check if a value is an Array filter object */
62
89
  function isArrayFilter(value) {
63
- if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value) || value instanceof Date) {
90
+ if (value === null ||
91
+ value === undefined ||
92
+ typeof value !== 'object' ||
93
+ Array.isArray(value) ||
94
+ value instanceof Date) {
64
95
  return false;
65
96
  }
66
97
  const keys = Object.keys(value);
67
98
  return keys.length > 0 && keys.some((k) => ARRAY_OPERATOR_KEYS.has(k));
68
99
  }
100
+ // ---------------------------------------------------------------------------
101
+ // LRU cache — bounded SQL template cache to prevent memory leaks
102
+ // ---------------------------------------------------------------------------
103
+ /**
104
+ * Simple LRU (Least Recently Used) cache with a fixed maximum size.
105
+ * When the cache exceeds maxSize, the oldest (least recently used) entry is evicted.
106
+ * Uses Map insertion order for O(1) eviction.
107
+ */
108
+ class LRUCache {
109
+ maxSize;
110
+ cache = new Map();
111
+ constructor(maxSize) {
112
+ this.maxSize = maxSize;
113
+ }
114
+ get(key) {
115
+ const value = this.cache.get(key);
116
+ if (value !== undefined) {
117
+ // Move to end (most recently used)
118
+ this.cache.delete(key);
119
+ this.cache.set(key, value);
120
+ }
121
+ return value;
122
+ }
123
+ set(key, value) {
124
+ if (this.cache.has(key)) {
125
+ this.cache.delete(key);
126
+ }
127
+ else if (this.cache.size >= this.maxSize) {
128
+ // Delete oldest (first) entry
129
+ const firstKey = this.cache.keys().next().value;
130
+ if (firstKey !== undefined)
131
+ this.cache.delete(firstKey);
132
+ }
133
+ this.cache.set(key, value);
134
+ }
135
+ get size() {
136
+ return this.cache.size;
137
+ }
138
+ }
69
139
  export class QueryInterface {
70
140
  pool;
71
141
  table;
72
142
  schema;
73
143
  tableMeta;
74
144
  /** SQL template cache: cacheKey → sql string (params are always positional $1,$2,...) */
75
- sqlCache = new Map();
145
+ sqlCache = new LRUCache(1000);
76
146
  middlewares;
77
- // Fast path: pre-computed flag — true when this table has zero date/timestamp columns.
78
- // When true, parseRow can skip the per-field dateCols.has() check entirely.
79
- hasNoDateColumns;
80
- constructor(pool, table, schema, middlewares) {
147
+ defaultLimit;
148
+ warnOnUnlimited;
149
+ /** Pre-computed column type lookups (avoids linear scans per query) */
150
+ columnPgTypeMap;
151
+ columnArrayTypeMap;
152
+ constructor(pool, table, schema, middlewares, options) {
81
153
  this.pool = pool;
82
154
  this.table = table;
83
155
  this.schema = schema;
84
156
  const meta = schema.tables[table];
85
157
  if (!meta) {
86
- throw new Error(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
158
+ throw new ValidationError(`[turbine] Unknown table "${table}". Available: ${Object.keys(schema.tables).join(', ')}`);
87
159
  }
88
160
  this.tableMeta = meta;
89
161
  this.middlewares = middlewares ?? [];
90
- this.hasNoDateColumns = meta.dateColumns.size === 0;
162
+ this.defaultLimit = options?.defaultLimit;
163
+ this.warnOnUnlimited = options?.warnOnUnlimited ?? false;
164
+ // Pre-compute column type lookup maps (TASK-26)
165
+ this.columnPgTypeMap = new Map();
166
+ this.columnArrayTypeMap = new Map();
167
+ for (const col of this.tableMeta.columns) {
168
+ this.columnPgTypeMap.set(col.name, col.pgType);
169
+ this.columnArrayTypeMap.set(col.name, col.pgArrayType);
170
+ }
171
+ }
172
+ /**
173
+ * Execute a pool.query with an optional timeout.
174
+ * If timeout is set, races the query against a timer and rejects on expiry.
175
+ * pg driver errors are translated to typed Turbine errors via wrapPgError.
176
+ */
177
+ async queryWithTimeout(sql, params, timeout) {
178
+ if (!timeout) {
179
+ try {
180
+ return await this.pool.query(sql, params);
181
+ }
182
+ catch (err) {
183
+ throw wrapPgError(err);
184
+ }
185
+ }
186
+ let timer;
187
+ const timeoutPromise = new Promise((_, reject) => {
188
+ timer = setTimeout(() => reject(new TimeoutError(timeout)), timeout);
189
+ });
190
+ try {
191
+ return await Promise.race([this.pool.query(sql, params), timeoutPromise]);
192
+ }
193
+ catch (err) {
194
+ throw wrapPgError(err);
195
+ }
196
+ finally {
197
+ clearTimeout(timer);
198
+ }
91
199
  }
92
200
  /**
93
201
  * Execute a query through the middleware chain.
94
202
  * If no middlewares are registered, executes directly.
203
+ *
204
+ * Middleware can inspect and log query parameters, modify results after execution,
205
+ * and measure timing. Note: query SQL is generated before middleware runs, so
206
+ * modifying params.args in middleware will NOT affect the executed SQL.
207
+ * To intercept queries before SQL generation, use the raw() method instead.
95
208
  */
96
209
  async executeWithMiddleware(action, args, executor) {
97
210
  if (this.middlewares.length === 0) {
@@ -110,25 +223,13 @@ export class QueryInterface {
110
223
  };
111
224
  return next(params);
112
225
  }
113
- /**
114
- * Generate a cache key for a query shape.
115
- * Same where-keys + same with-clause = same SQL template.
116
- */
117
- cacheKey(op, whereKeys, withClause, extra) {
118
- let key = `${op}:${whereKeys.sort().join(',')}`;
119
- if (withClause)
120
- key += `:w=${JSON.stringify(Object.keys(withClause).sort())}`;
121
- if (extra)
122
- key += `:${extra}`;
123
- return key;
124
- }
125
226
  // -------------------------------------------------------------------------
126
227
  // findUnique
127
228
  // -------------------------------------------------------------------------
128
229
  async findUnique(args) {
129
230
  return this.executeWithMiddleware('findUnique', args, async () => {
130
231
  const deferred = this.buildFindUnique(args);
131
- const result = await this.pool.query(deferred.sql, deferred.params);
232
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
132
233
  return deferred.transform(result);
133
234
  });
134
235
  }
@@ -137,15 +238,12 @@ export class QueryInterface {
137
238
  const whereObj = args.where;
138
239
  // Check if all where values are simple (plain equality, no operators/null/OR)
139
240
  const whereKeys = Object.keys(whereObj).filter((k) => whereObj[k] !== undefined);
140
- const isSimpleWhere = !whereObj['OR'] && !whereObj['AND'] && !whereObj['NOT'] && whereKeys.every((k) => {
141
- const v = whereObj[k];
142
- return v !== null && !isWhereOperator(v) && !isJsonFilter(v) && !isArrayFilter(v);
143
- });
144
- // -----------------------------------------------------------------------
145
- // Fast path: no relations, simple equality where cache SQL template.
146
- // Generates: SELECT col1, col2 FROM "table" WHERE "id" = $1 LIMIT 1
147
- // No json_build_object, no subqueries, no COALESCE wrappers.
148
- // -----------------------------------------------------------------------
241
+ const isSimpleWhere = !whereObj.OR &&
242
+ whereKeys.every((k) => {
243
+ const v = whereObj[k];
244
+ return v !== null && !isWhereOperator(v);
245
+ });
246
+ // For simple queries (no nested with, no operators), use cached SQL template
149
247
  if (!args.with && isSimpleWhere) {
150
248
  const colKey = columnsList ? columnsList.join(',') : '*';
151
249
  const ck = `fu:${whereKeys.sort().join(',')}:c=${colKey}`;
@@ -155,34 +253,25 @@ export class QueryInterface {
155
253
  const qt = quoteIdent(this.table);
156
254
  const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
157
255
  const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
158
- const selectExpr = columnsList
159
- ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
160
- : `${qt}.*`;
256
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
161
257
  sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
162
258
  this.sqlCache.set(ck, sql);
163
259
  }
164
- // Fast path: skip date coercion when table has no date columns
165
- const transformRow = this.hasNoDateColumns
166
- ? (row) => this.parseRowFast(row)
167
- : (row) => this.parseRow(row, this.table);
168
260
  return {
169
261
  sql,
170
262
  params,
171
263
  transform: (result) => {
172
264
  const row = result.rows[0];
173
- return row ? transformRow(row) : null;
265
+ return row ? this.parseRow(row, this.table) : null;
174
266
  },
175
267
  tag: `${this.table}.findUnique`,
176
268
  };
177
269
  }
178
270
  // General path: supports operators, null, OR, nested with
179
271
  const { sql: whereSql, params } = this.buildWhere(args.where);
180
- // Fast path: no relations, skip json_agg (but where has operators/null/OR)
181
272
  if (!args.with) {
182
273
  const qt = quoteIdent(this.table);
183
- const selectExpr = columnsList
184
- ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
185
- : `${qt}.*`;
274
+ const selectExpr = columnsList ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ') : `${qt}.*`;
186
275
  const sql = `SELECT ${selectExpr} FROM ${qt}${whereSql} LIMIT 1`;
187
276
  return {
188
277
  sql,
@@ -211,85 +300,21 @@ export class QueryInterface {
211
300
  // findMany
212
301
  // -------------------------------------------------------------------------
213
302
  async findMany(args) {
303
+ // Warn if no limit specified and warnOnUnlimited is enabled
304
+ const hasExplicitLimit = args?.limit !== undefined || args?.take !== undefined;
305
+ if (this.warnOnUnlimited && !hasExplicitLimit) {
306
+ console.warn(`[turbine] findMany() called without limit on table "${this.table}". Set defaultLimit in config to prevent unbounded queries.`);
307
+ }
214
308
  return this.executeWithMiddleware('findMany', (args ?? {}), async () => {
215
309
  const deferred = this.buildFindMany(args);
216
- const result = await this.pool.query(deferred.sql, deferred.params);
310
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
217
311
  return deferred.transform(result);
218
312
  });
219
313
  }
220
314
  buildFindMany(args) {
315
+ const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
221
316
  const columnsList = this.resolveColumns(args?.select, args?.omit);
222
317
  const qt = quoteIdent(this.table);
223
- const hasWith = !!(args?.with);
224
- // -----------------------------------------------------------------------
225
- // Fast path: no relations, no cursor, no distinct — cache SQL template
226
- // Skip json_agg subquery machinery entirely for simple table scans.
227
- // -----------------------------------------------------------------------
228
- if (!hasWith && !args?.cursor && !args?.distinct) {
229
- const whereObj = args?.where;
230
- const whereKeys = whereObj
231
- ? Object.keys(whereObj).filter((k) => whereObj[k] !== undefined)
232
- : [];
233
- // Check if all where values are simple equality (no operators, null, OR/AND/NOT)
234
- const isSimpleWhere = !whereObj || (!whereObj['OR'] && !whereObj['AND'] && !whereObj['NOT'] && whereKeys.every((k) => {
235
- const v = whereObj[k];
236
- return v !== null && !isWhereOperator(v) && !isJsonFilter(v) && !isArrayFilter(v);
237
- }));
238
- if (isSimpleWhere) {
239
- // Build cache key: operation + where keys + columns + orderBy + hasLimit + hasOffset
240
- const colKey = columnsList ? columnsList.join(',') : '*';
241
- const orderKey = args?.orderBy ? Object.entries(args.orderBy).map(([k, d]) => `${k}:${d}`).join(',') : '';
242
- const hasLimit = args?.limit !== undefined || args?.take !== undefined;
243
- const hasOffset = args?.offset !== undefined;
244
- const ck = `fm:${whereKeys.sort().join(',')}:c=${colKey}:o=${orderKey}:l=${hasLimit ? '1' : '0'}:off=${hasOffset ? '1' : '0'}`;
245
- let sql = this.sqlCache.get(ck);
246
- const params = whereKeys.map((k) => whereObj[k]);
247
- if (!sql) {
248
- // Build SQL template once and cache it
249
- const selectExpr = columnsList
250
- ? columnsList.map((c) => `${qt}.${quoteIdent(c)}`).join(', ')
251
- : `${qt}.*`;
252
- const whereClauses = whereKeys.map((k, i) => `${this.toSqlColumn(k)} = $${i + 1}`);
253
- const whereSql = whereClauses.length > 0 ? ` WHERE ${whereClauses.join(' AND ')}` : '';
254
- sql = `SELECT ${selectExpr} FROM ${qt}${whereSql}`;
255
- if (args?.orderBy) {
256
- sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
257
- }
258
- // Placeholders for LIMIT/OFFSET — positions are stable since where params come first
259
- if (hasLimit) {
260
- sql += ` LIMIT $${params.length + 1}`;
261
- }
262
- if (hasOffset) {
263
- sql += ` OFFSET $${params.length + (hasLimit ? 2 : 1)}`;
264
- }
265
- this.sqlCache.set(ck, sql);
266
- }
267
- // Append runtime param values for LIMIT and OFFSET
268
- const effectiveLimit = args?.take ?? args?.limit;
269
- if (effectiveLimit !== undefined) {
270
- params.push(Number(effectiveLimit));
271
- }
272
- if (args?.offset !== undefined) {
273
- params.push(Number(args.offset));
274
- }
275
- // Fast path: no relations, use parseRow (or parseRowFast when no date columns)
276
- const parseRow = this.hasNoDateColumns
277
- ? (row) => this.parseRowFast(row)
278
- : (row) => this.parseRow(row, this.table);
279
- return {
280
- sql,
281
- params,
282
- transform: (result) => result.rows.map(parseRow),
283
- tag: `${this.table}.findMany`,
284
- };
285
- }
286
- }
287
- // -----------------------------------------------------------------------
288
- // General path: supports operators, null, OR, cursor, distinct, nested with
289
- // -----------------------------------------------------------------------
290
- const { sql: whereSql, params } = args?.where
291
- ? this.buildWhere(args.where)
292
- : { sql: '', params: [] };
293
318
  // Distinct support
294
319
  let distinctPrefix = '';
295
320
  if (args?.distinct && args.distinct.length > 0) {
@@ -297,7 +322,7 @@ export class QueryInterface {
297
322
  distinctPrefix = `DISTINCT ON (${distinctCols.join(', ')}) `;
298
323
  }
299
324
  let selectClause;
300
- if (hasWith) {
325
+ if (args?.with) {
301
326
  selectClause = this.buildSelectWithRelations(this.table, args.with, params, columnsList);
302
327
  }
303
328
  else if (columnsList) {
@@ -331,8 +356,8 @@ export class QueryInterface {
331
356
  if (args?.orderBy) {
332
357
  sql += ` ORDER BY ${this.buildOrderBy(args.orderBy)}`;
333
358
  }
334
- // take overrides limit when cursor pagination is used
335
- const effectiveLimit = args?.take ?? args?.limit;
359
+ // take overrides limit when cursor pagination is used; fall back to defaultLimit
360
+ const effectiveLimit = args?.take ?? args?.limit ?? this.defaultLimit;
336
361
  if (effectiveLimit !== undefined) {
337
362
  params.push(Number(effectiveLimit));
338
363
  sql += ` LIMIT $${params.length}`;
@@ -344,19 +369,74 @@ export class QueryInterface {
344
369
  return {
345
370
  sql,
346
371
  params,
347
- transform: (result) => result.rows.map((row) => hasWith
348
- ? this.parseNestedRow(row, this.table)
349
- : this.parseRow(row, this.table)),
372
+ transform: (result) => result.rows.map((row) => args?.with ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table)),
350
373
  tag: `${this.table}.findMany`,
351
374
  };
352
375
  }
353
376
  // -------------------------------------------------------------------------
377
+ // findManyStream — async iterable using PostgreSQL cursors
378
+ // -------------------------------------------------------------------------
379
+ /**
380
+ * Stream rows from a findMany query using PostgreSQL cursors.
381
+ * Returns an AsyncIterable that yields individual rows, fetching in batches internally.
382
+ *
383
+ * Uses DECLARE CURSOR within a dedicated transaction on a single pooled connection.
384
+ * The cursor is automatically closed and the connection released when iteration
385
+ * completes or is terminated early (e.g. `break` from `for await`).
386
+ *
387
+ * @example
388
+ * ```ts
389
+ * for await (const user of db.users.findManyStream({ where: { orgId: 1 }, batchSize: 500 })) {
390
+ * process.stdout.write(`${user.email}\n`);
391
+ * }
392
+ * ```
393
+ */
394
+ async *findManyStream(args) {
395
+ const batchSize = Math.max(1, Math.floor(Number(args?.batchSize ?? 100)));
396
+ const deferred = this.buildFindMany(args);
397
+ const hasRelations = !!args?.with;
398
+ // Acquire a dedicated connection — cursors require a single connection in a transaction
399
+ const client = await this.pool.connect();
400
+ const cursorName = `turbine_cursor_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
401
+ const quotedCursor = quoteIdent(cursorName);
402
+ try {
403
+ await client.query('BEGIN');
404
+ await client.query(`DECLARE ${quotedCursor} NO SCROLL CURSOR FOR ${deferred.sql}`, deferred.params);
405
+ while (true) {
406
+ const batch = await client.query(`FETCH ${batchSize} FROM ${quotedCursor}`);
407
+ if (batch.rows.length === 0)
408
+ break;
409
+ for (const row of batch.rows) {
410
+ yield (hasRelations ? this.parseNestedRow(row, this.table) : this.parseRow(row, this.table));
411
+ }
412
+ if (batch.rows.length < batchSize)
413
+ break;
414
+ }
415
+ await client.query(`CLOSE ${quotedCursor}`);
416
+ await client.query('COMMIT');
417
+ }
418
+ catch (err) {
419
+ // Rollback on error (also closes cursor implicitly)
420
+ try {
421
+ await client.query('ROLLBACK');
422
+ }
423
+ catch {
424
+ // Connection may already be broken — ignore rollback error
425
+ }
426
+ // Wrap pg constraint errors so streaming surfaces typed errors like the rest of the API
427
+ throw wrapPgError(err);
428
+ }
429
+ finally {
430
+ client.release();
431
+ }
432
+ }
433
+ // -------------------------------------------------------------------------
354
434
  // findFirst — like findMany but returns a single row or null
355
435
  // -------------------------------------------------------------------------
356
436
  async findFirst(args) {
357
437
  return this.executeWithMiddleware('findFirst', (args ?? {}), async () => {
358
438
  const deferred = this.buildFindFirst(args);
359
- const result = await this.pool.query(deferred.sql, deferred.params);
439
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
360
440
  return deferred.transform(result);
361
441
  });
362
442
  }
@@ -380,7 +460,7 @@ export class QueryInterface {
380
460
  async findFirstOrThrow(args) {
381
461
  return this.executeWithMiddleware('findFirstOrThrow', (args ?? {}), async () => {
382
462
  const deferred = this.buildFindFirstOrThrow(args);
383
- const result = await this.pool.query(deferred.sql, deferred.params);
463
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
384
464
  return deferred.transform(result);
385
465
  });
386
466
  }
@@ -392,7 +472,11 @@ export class QueryInterface {
392
472
  transform: (result) => {
393
473
  const row = inner.transform(result);
394
474
  if (row === null) {
395
- throw new Error('Record not found');
475
+ throw new NotFoundError({
476
+ table: this.table,
477
+ where: args?.where,
478
+ operation: 'findFirstOrThrow',
479
+ });
396
480
  }
397
481
  return row;
398
482
  },
@@ -405,7 +489,7 @@ export class QueryInterface {
405
489
  async findUniqueOrThrow(args) {
406
490
  return this.executeWithMiddleware('findUniqueOrThrow', args, async () => {
407
491
  const deferred = this.buildFindUniqueOrThrow(args);
408
- const result = await this.pool.query(deferred.sql, deferred.params);
492
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
409
493
  return deferred.transform(result);
410
494
  });
411
495
  }
@@ -417,7 +501,11 @@ export class QueryInterface {
417
501
  transform: (result) => {
418
502
  const row = inner.transform(result);
419
503
  if (row === null) {
420
- throw new Error('Record not found');
504
+ throw new NotFoundError({
505
+ table: this.table,
506
+ where: args.where,
507
+ operation: 'findUniqueOrThrow',
508
+ });
421
509
  }
422
510
  return row;
423
511
  },
@@ -430,7 +518,7 @@ export class QueryInterface {
430
518
  async create(args) {
431
519
  return this.executeWithMiddleware('create', args, async () => {
432
520
  const deferred = this.buildCreate(args);
433
- const result = await this.pool.query(deferred.sql, deferred.params);
521
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
434
522
  return deferred.transform(result);
435
523
  });
436
524
  }
@@ -445,8 +533,13 @@ export class QueryInterface {
445
533
  params,
446
534
  transform: (result) => {
447
535
  const row = result.rows[0];
448
- if (!row)
449
- throw new Error('[turbine] Expected a row but query returned none');
536
+ if (!row) {
537
+ throw new NotFoundError({
538
+ table: this.table,
539
+ operation: 'create',
540
+ message: `[turbine] create on "${this.table}" returned no row from RETURNING * — this should never happen.`,
541
+ });
542
+ }
450
543
  return this.parseRow(row, this.table);
451
544
  },
452
545
  tag: `${this.table}.create`,
@@ -458,7 +551,7 @@ export class QueryInterface {
458
551
  async createMany(args) {
459
552
  return this.executeWithMiddleware('createMany', args, async () => {
460
553
  const deferred = this.buildCreateMany(args);
461
- const result = await this.pool.query(deferred.sql, deferred.params);
554
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
462
555
  return deferred.transform(result);
463
556
  });
464
557
  }
@@ -505,29 +598,32 @@ export class QueryInterface {
505
598
  async update(args) {
506
599
  return this.executeWithMiddleware('update', args, async () => {
507
600
  const deferred = this.buildUpdate(args);
508
- const result = await this.pool.query(deferred.sql, deferred.params);
601
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
509
602
  return deferred.transform(result);
510
603
  });
511
604
  }
512
605
  buildUpdate(args) {
513
606
  const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
514
- // Build SET params first
607
+ // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
515
608
  const params = [];
516
- const setClauses = setEntries.map(([k, v]) => {
517
- params.push(v);
518
- return `${this.toSqlColumn(k)} = $${params.length}`;
519
- });
609
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
520
610
  // Build WHERE using the shared params array (continues numbering after SET params)
521
611
  const whereClause = this.buildWhereClause(args.where, params);
522
612
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
613
+ this.assertMutationHasPredicate('update', whereSql, args.allowFullTableScan);
523
614
  const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql} RETURNING *`;
524
615
  return {
525
616
  sql,
526
617
  params,
527
618
  transform: (result) => {
528
619
  const row = result.rows[0];
529
- if (!row)
530
- throw new Error('[turbine] Expected a row but query returned none');
620
+ if (!row) {
621
+ throw new NotFoundError({
622
+ table: this.table,
623
+ where: args.where,
624
+ operation: 'update',
625
+ });
626
+ }
531
627
  return this.parseRow(row, this.table);
532
628
  },
533
629
  tag: `${this.table}.update`,
@@ -539,20 +635,26 @@ export class QueryInterface {
539
635
  async delete(args) {
540
636
  return this.executeWithMiddleware('delete', args, async () => {
541
637
  const deferred = this.buildDelete(args);
542
- const result = await this.pool.query(deferred.sql, deferred.params);
638
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
543
639
  return deferred.transform(result);
544
640
  });
545
641
  }
546
642
  buildDelete(args) {
547
643
  const { sql: whereSql, params } = this.buildWhere(args.where);
644
+ this.assertMutationHasPredicate('delete', whereSql, args.allowFullTableScan);
548
645
  const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql} RETURNING *`;
549
646
  return {
550
647
  sql,
551
648
  params,
552
649
  transform: (result) => {
553
650
  const row = result.rows[0];
554
- if (!row)
555
- throw new Error('[turbine] Expected a row but query returned none');
651
+ if (!row) {
652
+ throw new NotFoundError({
653
+ table: this.table,
654
+ where: args.where,
655
+ operation: 'delete',
656
+ });
657
+ }
556
658
  return this.parseRow(row, this.table);
557
659
  },
558
660
  tag: `${this.table}.delete`,
@@ -564,7 +666,7 @@ export class QueryInterface {
564
666
  async upsert(args) {
565
667
  return this.executeWithMiddleware('upsert', args, async () => {
566
668
  const deferred = this.buildUpsert(args);
567
- const result = await this.pool.query(deferred.sql, deferred.params);
669
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
568
670
  return deferred.transform(result);
569
671
  });
570
672
  }
@@ -595,8 +697,14 @@ export class QueryInterface {
595
697
  params,
596
698
  transform: (result) => {
597
699
  const row = result.rows[0];
598
- if (!row)
599
- throw new Error('[turbine] Expected a row but query returned none');
700
+ if (!row) {
701
+ throw new NotFoundError({
702
+ table: this.table,
703
+ where: args.where,
704
+ operation: 'upsert',
705
+ message: `[turbine] upsert on "${this.table}" returned no row from RETURNING * — this should never happen.`,
706
+ });
707
+ }
600
708
  return this.parseRow(row, this.table);
601
709
  },
602
710
  tag: `${this.table}.upsert`,
@@ -608,21 +716,19 @@ export class QueryInterface {
608
716
  async updateMany(args) {
609
717
  return this.executeWithMiddleware('updateMany', args, async () => {
610
718
  const deferred = this.buildUpdateMany(args);
611
- const result = await this.pool.query(deferred.sql, deferred.params);
719
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
612
720
  return deferred.transform(result);
613
721
  });
614
722
  }
615
723
  buildUpdateMany(args) {
616
724
  const setEntries = Object.entries(args.data).filter(([, v]) => v !== undefined);
617
- // Build SET params first
725
+ // Build SET params first (supports atomic operators: set/increment/decrement/multiply/divide)
618
726
  const params = [];
619
- const setClauses = setEntries.map(([k, v]) => {
620
- params.push(v);
621
- return `${this.toSqlColumn(k)} = $${params.length}`;
622
- });
727
+ const setClauses = setEntries.map(([k, v]) => this.buildSetClause(k, v, params));
623
728
  // Build WHERE using the shared params array (continues numbering after SET params)
624
729
  const whereClause = this.buildWhereClause(args.where, params);
625
730
  const whereSql = whereClause ? ` WHERE ${whereClause}` : '';
731
+ this.assertMutationHasPredicate('updateMany', whereSql, args.allowFullTableScan);
626
732
  const sql = `UPDATE ${quoteIdent(this.table)} SET ${setClauses.join(', ')}${whereSql}`;
627
733
  return {
628
734
  sql,
@@ -637,12 +743,13 @@ export class QueryInterface {
637
743
  async deleteMany(args) {
638
744
  return this.executeWithMiddleware('deleteMany', args, async () => {
639
745
  const deferred = this.buildDeleteMany(args);
640
- const result = await this.pool.query(deferred.sql, deferred.params);
746
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
641
747
  return deferred.transform(result);
642
748
  });
643
749
  }
644
750
  buildDeleteMany(args) {
645
751
  const { sql: whereSql, params } = this.buildWhere(args.where);
752
+ this.assertMutationHasPredicate('deleteMany', whereSql, args.allowFullTableScan);
646
753
  const sql = `DELETE FROM ${quoteIdent(this.table)}${whereSql}`;
647
754
  return {
648
755
  sql,
@@ -657,14 +764,12 @@ export class QueryInterface {
657
764
  async count(args) {
658
765
  return this.executeWithMiddleware('count', (args ?? {}), async () => {
659
766
  const deferred = this.buildCount(args);
660
- const result = await this.pool.query(deferred.sql, deferred.params);
767
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args?.timeout);
661
768
  return deferred.transform(result);
662
769
  });
663
770
  }
664
771
  buildCount(args) {
665
- const { sql: whereSql, params } = args?.where
666
- ? this.buildWhere(args.where)
667
- : { sql: '', params: [] };
772
+ const { sql: whereSql, params } = args?.where ? this.buildWhere(args.where) : { sql: '', params: [] };
668
773
  const sql = `SELECT COUNT(*)::int AS count FROM ${quoteIdent(this.table)}${whereSql}`;
669
774
  return {
670
775
  sql,
@@ -679,16 +784,22 @@ export class QueryInterface {
679
784
  async groupBy(args) {
680
785
  return this.executeWithMiddleware('groupBy', args, async () => {
681
786
  const deferred = this.buildGroupBy(args);
682
- const result = await this.pool.query(deferred.sql, deferred.params);
787
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
683
788
  return deferred.transform(result);
684
789
  });
685
790
  }
686
791
  buildGroupBy(args) {
792
+ const meta = this.schema.tables[this.table];
793
+ if (meta) {
794
+ for (const key of args.by) {
795
+ if (!(key in meta.columnMap)) {
796
+ throw new ValidationError(`Unknown column "${key}" in groupBy for table "${this.table}"`);
797
+ }
798
+ }
799
+ }
687
800
  const groupColsRaw = args.by.map((k) => this.toColumn(k));
688
801
  const groupCols = groupColsRaw.map((c) => quoteIdent(c));
689
- const { sql: whereSql, params } = args.where
690
- ? this.buildWhere(args.where)
691
- : { sql: '', params: [] };
802
+ const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
692
803
  // Build SELECT expressions: group-by columns + aggregate functions
693
804
  const selectExprs = [...groupCols];
694
805
  // _count
@@ -701,7 +812,7 @@ export class QueryInterface {
701
812
  for (const [field, enabled] of Object.entries(args._sum)) {
702
813
  if (enabled) {
703
814
  const col = this.toColumn(field);
704
- selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
815
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
705
816
  }
706
817
  }
707
818
  }
@@ -710,7 +821,7 @@ export class QueryInterface {
710
821
  for (const [field, enabled] of Object.entries(args._avg)) {
711
822
  if (enabled) {
712
823
  const col = this.toColumn(field);
713
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
824
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
714
825
  }
715
826
  }
716
827
  }
@@ -719,7 +830,7 @@ export class QueryInterface {
719
830
  for (const [field, enabled] of Object.entries(args._min)) {
720
831
  if (enabled) {
721
832
  const col = this.toColumn(field);
722
- selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
833
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
723
834
  }
724
835
  }
725
836
  }
@@ -728,7 +839,7 @@ export class QueryInterface {
728
839
  for (const [field, enabled] of Object.entries(args._max)) {
729
840
  if (enabled) {
730
841
  const col = this.toColumn(field);
731
- selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
842
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
732
843
  }
733
844
  }
734
845
  }
@@ -806,14 +917,31 @@ export class QueryInterface {
806
917
  async aggregate(args) {
807
918
  return this.executeWithMiddleware('aggregate', args, async () => {
808
919
  const deferred = this.buildAggregate(args);
809
- const result = await this.pool.query(deferred.sql, deferred.params);
920
+ const result = await this.queryWithTimeout(deferred.sql, deferred.params, args.timeout);
810
921
  return deferred.transform(result);
811
922
  });
812
923
  }
813
924
  buildAggregate(args) {
814
- const { sql: whereSql, params } = args.where
815
- ? this.buildWhere(args.where)
816
- : { sql: '', params: [] };
925
+ const { sql: whereSql, params } = args.where ? this.buildWhere(args.where) : { sql: '', params: [] };
926
+ const meta = this.schema.tables[this.table];
927
+ if (meta) {
928
+ for (const group of [args._sum, args._avg, args._min, args._max]) {
929
+ if (group && typeof group === 'object') {
930
+ for (const key of Object.keys(group)) {
931
+ if (!(key in meta.columnMap)) {
932
+ throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
933
+ }
934
+ }
935
+ }
936
+ }
937
+ if (args._count && typeof args._count === 'object') {
938
+ for (const key of Object.keys(args._count)) {
939
+ if (!(key in meta.columnMap)) {
940
+ throw new ValidationError(`Unknown column "${key}" in aggregate for table "${this.table}"`);
941
+ }
942
+ }
943
+ }
944
+ }
817
945
  const selectExprs = [];
818
946
  // _count
819
947
  if (args._count === true) {
@@ -823,7 +951,7 @@ export class QueryInterface {
823
951
  for (const [field, enabled] of Object.entries(args._count)) {
824
952
  if (enabled) {
825
953
  const col = this.toColumn(field);
826
- selectExprs.push(`COUNT(${quoteIdent(col)})::int AS _count_${col}`);
954
+ selectExprs.push(`COUNT(${quoteIdent(col)})::int AS ${quoteIdent('_count_' + col)}`);
827
955
  }
828
956
  }
829
957
  }
@@ -832,7 +960,7 @@ export class QueryInterface {
832
960
  for (const [field, enabled] of Object.entries(args._sum)) {
833
961
  if (enabled) {
834
962
  const col = this.toColumn(field);
835
- selectExprs.push(`SUM(${quoteIdent(col)}) AS _sum_${col}`);
963
+ selectExprs.push(`SUM(${quoteIdent(col)}) AS ${quoteIdent('_sum_' + col)}`);
836
964
  }
837
965
  }
838
966
  }
@@ -841,7 +969,7 @@ export class QueryInterface {
841
969
  for (const [field, enabled] of Object.entries(args._avg)) {
842
970
  if (enabled) {
843
971
  const col = this.toColumn(field);
844
- selectExprs.push(`AVG(${quoteIdent(col)})::float AS _avg_${col}`);
972
+ selectExprs.push(`AVG(${quoteIdent(col)})::float AS ${quoteIdent('_avg_' + col)}`);
845
973
  }
846
974
  }
847
975
  }
@@ -850,7 +978,7 @@ export class QueryInterface {
850
978
  for (const [field, enabled] of Object.entries(args._min)) {
851
979
  if (enabled) {
852
980
  const col = this.toColumn(field);
853
- selectExprs.push(`MIN(${quoteIdent(col)}) AS _min_${col}`);
981
+ selectExprs.push(`MIN(${quoteIdent(col)}) AS ${quoteIdent('_min_' + col)}`);
854
982
  }
855
983
  }
856
984
  }
@@ -859,7 +987,7 @@ export class QueryInterface {
859
987
  for (const [field, enabled] of Object.entries(args._max)) {
860
988
  if (enabled) {
861
989
  const col = this.toColumn(field);
862
- selectExprs.push(`MAX(${quoteIdent(col)}) AS _max_${col}`);
990
+ selectExprs.push(`MAX(${quoteIdent(col)}) AS ${quoteIdent('_max_' + col)}`);
863
991
  }
864
992
  }
865
993
  }
@@ -971,6 +1099,67 @@ export class QueryInterface {
971
1099
  toSqlColumn(field) {
972
1100
  return quoteIdent(this.toColumn(field));
973
1101
  }
1102
+ /**
1103
+ * Build a single SET clause entry for update/updateMany.
1104
+ *
1105
+ * Supports plain values and atomic operator objects ({ set, increment,
1106
+ * decrement, multiply, divide }). An operator object is detected ONLY when
1107
+ * it has EXACTLY one key that is one of the 5 operator keys — this avoids
1108
+ * misinterpreting JSON column values like `{ set: 'x' }` as operators
1109
+ * (real operator objects always have exactly one key, and a plain JSON
1110
+ * payload that happens to have a single `set` key is extremely unusual).
1111
+ * Multi-key objects are always treated as plain (JSON) values.
1112
+ *
1113
+ * Returns the SQL fragment (e.g., `"view_count" = "view_count" + $3`) and
1114
+ * pushes any required params onto the shared params array so that WHERE
1115
+ * clause numbering continues correctly afterward.
1116
+ */
1117
+ buildSetClause(key, value, params) {
1118
+ const col = this.toSqlColumn(key);
1119
+ // Detect atomic-operator object: plain object (not null, not array, not
1120
+ // Date, not Buffer) with EXACTLY one key matching an operator name.
1121
+ if (value !== null &&
1122
+ typeof value === 'object' &&
1123
+ !Array.isArray(value) &&
1124
+ !(value instanceof Date) &&
1125
+ !Buffer.isBuffer(value)) {
1126
+ const v = value;
1127
+ const keys = Object.keys(v);
1128
+ if (keys.length === 1 && UPDATE_OPERATOR_KEYS.has(keys[0])) {
1129
+ const op = keys[0];
1130
+ const opValue = v[op];
1131
+ if (op === 'set') {
1132
+ params.push(opValue);
1133
+ return `${col} = $${params.length}`;
1134
+ }
1135
+ // Arithmetic operators: must be finite numbers
1136
+ if (typeof opValue !== 'number' || !Number.isFinite(opValue)) {
1137
+ throw new ValidationError(`[turbine] update operator "${op}" on "${this.table}.${key}" requires a finite number, got ${typeof opValue}`);
1138
+ }
1139
+ if (op === 'increment') {
1140
+ params.push(opValue);
1141
+ return `${col} = ${col} + $${params.length}`;
1142
+ }
1143
+ if (op === 'decrement') {
1144
+ params.push(opValue);
1145
+ return `${col} = ${col} - $${params.length}`;
1146
+ }
1147
+ if (op === 'multiply') {
1148
+ params.push(opValue);
1149
+ return `${col} = ${col} * $${params.length}`;
1150
+ }
1151
+ if (op === 'divide') {
1152
+ params.push(opValue);
1153
+ return `${col} = ${col} / $${params.length}`;
1154
+ }
1155
+ }
1156
+ // Fall through: multi-key objects or non-operator single-key objects
1157
+ // are treated as plain values (e.g., JSONB column payloads).
1158
+ }
1159
+ // Plain value (including null, Date, Buffer, arrays, JSON objects)
1160
+ params.push(value);
1161
+ return `${col} = $${params.length}`;
1162
+ }
974
1163
  /** Build WHERE clause from a where object (supports operators, NULL, OR) */
975
1164
  buildWhere(where) {
976
1165
  const params = [];
@@ -979,6 +1168,22 @@ export class QueryInterface {
979
1168
  return { sql: '', params: [] };
980
1169
  return { sql: ` WHERE ${clause}`, params };
981
1170
  }
1171
+ /**
1172
+ * Refuse mutations with an empty predicate unless explicitly opted in.
1173
+ *
1174
+ * An empty `where` (e.g. `{}` or `{ id: undefined }`) resolves to a
1175
+ * mutation with no filter — a common footgun when a caller's filter
1176
+ * value accidentally resolves to `undefined`. This guard throws
1177
+ * `ValidationError` in that case unless `allowFullTableScan: true`.
1178
+ */
1179
+ assertMutationHasPredicate(operation, whereSql, allowFullTableScan) {
1180
+ if (whereSql.length > 0)
1181
+ return;
1182
+ if (allowFullTableScan === true)
1183
+ return;
1184
+ throw new ValidationError(`[turbine] ${operation} on "${this.table}" refused: the \`where\` clause is empty. ` +
1185
+ `Pass \`allowFullTableScan: true\` to opt in, or check that your filter values are defined.`);
1186
+ }
982
1187
  /**
983
1188
  * Build the inner WHERE expression (without the WHERE keyword).
984
1189
  * Returns null if no conditions exist.
@@ -1197,24 +1402,30 @@ export class QueryInterface {
1197
1402
  params.push(op.notIn);
1198
1403
  clauses.push(`${column} != ALL($${params.length})`);
1199
1404
  }
1405
+ // Use ILIKE for case-insensitive mode, LIKE otherwise
1406
+ const likeOp = op.mode === 'insensitive' ? 'ILIKE' : 'LIKE';
1200
1407
  if (op.contains !== undefined) {
1201
1408
  params.push(`%${escapeLike(op.contains)}%`);
1202
- clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
1409
+ clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
1203
1410
  }
1204
1411
  if (op.startsWith !== undefined) {
1205
1412
  params.push(`${escapeLike(op.startsWith)}%`);
1206
- clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
1413
+ clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
1207
1414
  }
1208
1415
  if (op.endsWith !== undefined) {
1209
1416
  params.push(`%${escapeLike(op.endsWith)}`);
1210
- clauses.push(`${column} LIKE $${params.length} ESCAPE '\\'`);
1417
+ clauses.push(`${column} ${likeOp} $${params.length} ESCAPE '\\'`);
1211
1418
  }
1212
1419
  return clauses;
1213
1420
  }
1214
1421
  /** Build ORDER BY clause from an object */
1215
1422
  buildOrderBy(orderBy) {
1423
+ const meta = this.schema.tables[this.table];
1216
1424
  return Object.entries(orderBy)
1217
1425
  .map(([key, dir]) => {
1426
+ if (meta && !(key in meta.columnMap)) {
1427
+ throw new ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
1428
+ }
1218
1429
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1219
1430
  return `${this.toSqlColumn(key)} ${safeDir}`;
1220
1431
  })
@@ -1249,22 +1460,6 @@ export class QueryInterface {
1249
1460
  }
1250
1461
  return parsed;
1251
1462
  }
1252
- /**
1253
- * Fast path: parse a flat row when the table has NO date columns.
1254
- * Only renames snake_case -> camelCase via the pre-computed reverseMap.
1255
- * Skips all date coercion checks — avoids Set.has() per field per row.
1256
- * Used by findUnique/findMany fast paths when hasNoDateColumns is true.
1257
- */
1258
- parseRowFast(row) {
1259
- const parsed = {};
1260
- const reverseMap = this.tableMeta.reverseColumnMap;
1261
- const keys = Object.keys(row);
1262
- for (let i = 0; i < keys.length; i++) {
1263
- const col = keys[i];
1264
- parsed[reverseMap[col] ?? col] = row[col];
1265
- }
1266
- return parsed;
1267
- }
1268
1463
  /** Parse a row that may contain JSON nested relation columns */
1269
1464
  parseNestedRow(row, table) {
1270
1465
  const parsed = this.parseRow(row, table);
@@ -1279,6 +1474,7 @@ export class QueryInterface {
1279
1474
  parsed[relName] = JSON.parse(rawValue);
1280
1475
  }
1281
1476
  catch {
1477
+ console.warn(`[turbine] Warning: Failed to parse JSON for relation "${relName}" on table "${this.table}". Using raw value.`);
1282
1478
  parsed[relName] = rawValue;
1283
1479
  }
1284
1480
  }
@@ -1298,52 +1494,173 @@ export class QueryInterface {
1298
1494
  return parsed;
1299
1495
  }
1300
1496
  /**
1301
- * Build a SELECT clause with nested relation subqueries.
1497
+ * Build a SELECT clause that includes both base columns and nested relation subqueries.
1498
+ *
1499
+ * For each relation specified in the `with` clause, this method generates a correlated
1500
+ * subquery using PostgreSQL's `json_agg(json_build_object(...))` pattern. The result
1501
+ * is a single SQL SELECT clause that resolves the full object tree in one query --
1502
+ * no N+1 problem.
1503
+ *
1504
+ * **How it works:**
1505
+ * 1. Resolves the base columns for the root table (all columns, or a subset via `columnsList`).
1506
+ * 2. Iterates over each key in the `with` clause, looking up the relation definition.
1507
+ * 3. For each relation, delegates to {@link buildRelationSubquery} to generate a
1508
+ * correlated subquery that returns JSON (array for hasMany, object for belongsTo/hasOne).
1509
+ * 4. Each subquery is aliased as the relation name in the final SELECT.
1510
+ *
1511
+ * **aliasCounter:** A shared `{ n: number }` object is passed through all nesting levels.
1512
+ * Each call to `buildRelationSubquery` increments it to produce unique table aliases
1513
+ * (`t0`, `t1`, `t2`, ...) across arbitrarily deep relation trees, preventing alias
1514
+ * collisions in the generated SQL.
1302
1515
  *
1303
- * Uses json_build_object + json_agg — the same approach as the raw-pg benchmark
1304
- * queries. Generates a single SQL statement that resolves the full object tree.
1516
+ * **Example output:**
1517
+ * ```sql
1518
+ * "users"."id", "users"."name", "users"."email",
1519
+ * (SELECT COALESCE(json_agg(json_build_object('id', t0."id", 'title', t0."title")), '[]'::json)
1520
+ * FROM "posts" t0 WHERE t0."user_id" = "users"."id") AS "posts"
1521
+ * ```
1305
1522
  *
1306
- * Nested where values are parameterized via the shared params array to prevent
1307
- * SQL injection.
1523
+ * @param table - The root table name (e.g. `"users"`).
1524
+ * @param withClause - An object mapping relation names to their include specs
1525
+ * (`true` for default inclusion, or `WithOptions` for select/omit/where/orderBy/limit).
1526
+ * @param params - Shared parameter array for parameterized values (`$1`, `$2`, ...).
1527
+ * Nested where/limit values are pushed here to prevent SQL injection.
1528
+ * @param columnsList - Optional subset of columns to include in the SELECT. When `null`
1529
+ * or omitted, all columns from the table's schema metadata are used.
1530
+ * @param depth - Current nesting depth, passed through to {@link buildRelationSubquery}
1531
+ * for circular-relation detection. Defaults to `0` at the top level.
1532
+ * @param path - Breadcrumb trail of relation names traversed so far, used in error
1533
+ * messages when circular or too-deep nesting is detected.
1534
+ * @returns A complete SELECT clause string (without the `SELECT` keyword) containing
1535
+ * base columns and relation subqueries.
1308
1536
  */
1309
- buildSelectWithRelations(table, withClause, params, columnsList) {
1537
+ buildSelectWithRelations(table, withClause, params, columnsList, depth, path) {
1310
1538
  const meta = this.schema.tables[table];
1311
1539
  if (!meta)
1312
- throw new Error(`[turbine] Unknown table "${table}"`);
1540
+ throw new ValidationError(`[turbine] Unknown table "${table}"`);
1313
1541
  const cols = columnsList ?? meta.allColumns;
1314
1542
  const qtbl = quoteIdent(table);
1315
- const baseCols = cols
1316
- .map((col) => `${qtbl}.${quoteIdent(col)}`)
1317
- .join(', ');
1543
+ const baseCols = cols.map((col) => `${qtbl}.${quoteIdent(col)}`).join(', ');
1318
1544
  const relationSelects = [];
1319
1545
  const aliasCounter = { n: 0 };
1320
1546
  for (const [relName, relSpec] of Object.entries(withClause)) {
1321
1547
  const relDef = meta.relations[relName];
1322
1548
  if (!relDef) {
1323
- throw new Error(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1549
+ throw new RelationError(`[turbine] Unknown relation "${relName}" on table "${table}". ` +
1324
1550
  `Available: ${Object.keys(meta.relations).join(', ')}`);
1325
1551
  }
1326
1552
  // The main table is not aliased, so pass table name as parentRef
1327
- const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter);
1553
+ const subquery = this.buildRelationSubquery(relDef, relSpec, params, table, aliasCounter, depth, path);
1328
1554
  relationSelects.push(`(${subquery}) AS ${quoteIdent(relName)}`);
1329
1555
  }
1330
1556
  return [baseCols, ...relationSelects].join(', ');
1331
1557
  }
1332
1558
  /**
1333
- * Build a json_agg subquery for a relation.
1559
+ * Generate a correlated subquery that returns JSON for a single relation.
1560
+ *
1561
+ * This is the core of Turbine's single-query nested relation strategy. For a given
1562
+ * relation (e.g. `posts` on a `users` query), it produces a self-contained SQL subquery
1563
+ * that PostgreSQL evaluates per parent row, returning either a JSON array (hasMany) or
1564
+ * a single JSON object (belongsTo / hasOne).
1565
+ *
1566
+ * ### Algorithm overview
1567
+ *
1568
+ * 1. **Alias generation:** Allocates a unique alias (`t0`, `t1`, ...) from the shared
1569
+ * `aliasCounter` so that deeply nested subqueries never collide.
1570
+ *
1571
+ * 2. **Column resolution:** Honors `select` / `omit` options to control which columns
1572
+ * appear in the output JSON.
1573
+ *
1574
+ * 3. **`json_build_object`:** Builds a JSON object for each row by mapping camelCase
1575
+ * field names to their column values:
1576
+ * ```sql
1577
+ * json_build_object('id', t0."id", 'title', t0."title", 'createdAt', t0."created_at")
1578
+ * ```
1579
+ *
1580
+ * 4. **`json_agg` wrapping (hasMany):** For one-to-many relations, wraps the
1581
+ * `json_build_object` call in `json_agg(...)` to aggregate all matching child rows
1582
+ * into a JSON array. Uses `COALESCE(..., '[]'::json)` so the result is never NULL.
1583
+ * For belongsTo / hasOne, no aggregation is used -- just the single JSON object
1584
+ * with `LIMIT 1`.
1334
1585
  *
1335
- * All user-supplied values in nested where clauses are parameterized
1336
- * through the shared params array — no string interpolation.
1586
+ * 5. **Correlation (WHERE clause):** Links the subquery to the parent row:
1587
+ * - **hasMany:** `alias.foreignKey = parentRef.referenceKey`
1588
+ * (e.g. `t0."user_id" = "users"."id"` -- child FK points to parent PK)
1589
+ * - **belongsTo / hasOne:** `alias.referenceKey = parentRef.foreignKey`
1590
+ * (e.g. `t0."id" = "posts"."author_id"` -- parent FK points to child PK)
1337
1591
  *
1338
- * @param parentRef - The alias (or table name) of the parent in the outer query.
1339
- * Used for the correlated WHERE clause: `child.fk = parentRef.pk`.
1340
- * @param aliasCounter - Shared counter for generating unique aliases across nested levels.
1592
+ * 6. **Recursion:** If the spec includes a nested `with` clause, this method calls
1593
+ * itself recursively for each nested relation, passing the current alias as
1594
+ * `parentRef`. The nested subquery appears as an additional key in the
1595
+ * `json_build_object` call, wrapped in `COALESCE(..., '[]'::json)`.
1596
+ * Depth is incremented and capped at 10 to guard against circular relations.
1597
+ *
1598
+ * 7. **LIMIT / ORDER BY wrapping:** For hasMany relations with `limit` or `orderBy`,
1599
+ * the query is restructured into a two-level form:
1600
+ * ```sql
1601
+ * SELECT COALESCE(json_agg(json_build_object(...)), '[]'::json)
1602
+ * FROM (
1603
+ * SELECT t0.* FROM "posts" t0
1604
+ * WHERE t0."user_id" = "users"."id"
1605
+ * ORDER BY t0."created_at" DESC
1606
+ * LIMIT $1
1607
+ * ) t0i
1608
+ * ```
1609
+ * This ensures LIMIT and ORDER BY apply to the raw rows *before* `json_agg`
1610
+ * aggregation. Without the inner subquery, LIMIT would be meaningless because
1611
+ * `json_agg` produces a single aggregated row.
1612
+ *
1613
+ * 8. **Parameter threading:** All user-supplied values (where filters, limit) are
1614
+ * pushed to the shared `params` array with `$N` placeholders. No string
1615
+ * interpolation of user data ever occurs -- all identifiers go through
1616
+ * `quoteIdent()` and all values are parameterized.
1617
+ *
1618
+ * ### Example output (hasMany with nested relation)
1619
+ * ```sql
1620
+ * SELECT COALESCE(json_agg(json_build_object(
1621
+ * 'id', t0."id",
1622
+ * 'title', t0."title",
1623
+ * 'comments', COALESCE((
1624
+ * SELECT COALESCE(json_agg(json_build_object('id', t1."id", 'body', t1."body")), '[]'::json)
1625
+ * FROM "comments" t1 WHERE t1."post_id" = t0."id"
1626
+ * ), '[]'::json)
1627
+ * )), '[]'::json) FROM "posts" t0 WHERE t0."user_id" = "users"."id"
1628
+ * ```
1629
+ *
1630
+ * @param relDef - The relation definition from schema metadata (contains `to`, `type`,
1631
+ * `foreignKey`, `referenceKey`).
1632
+ * @param spec - Either `true` (include with defaults) or a `WithOptions` object that
1633
+ * can specify `select`, `omit`, `where`, `orderBy`, `limit`, and nested `with`.
1634
+ * @param params - Shared parameter array. User-supplied values are pushed here and
1635
+ * referenced as `$1`, `$2`, etc. in the generated SQL.
1636
+ * @param parentRef - The alias (e.g. `"t0"`) or table name (e.g. `"users"`) of the
1637
+ * parent query. Used to build the correlated WHERE clause that ties
1638
+ * child rows to their parent row.
1639
+ * @param aliasCounter - Shared mutable counter (`{ n: number }`) for generating unique
1640
+ * table aliases (`t0`, `t1`, `t2`, ...) across all nesting levels.
1641
+ * Each call increments `n` by 1.
1642
+ * @param depth - Current nesting depth (starts at `0`). Incremented on each recursive
1643
+ * call. If it reaches 10, a {@link CircularRelationError} is thrown.
1644
+ * @param path - Breadcrumb trail of relation/table names traversed so far
1645
+ * (e.g. `["users", "posts", "comments"]`). Used in the error message
1646
+ * when circular or too-deep nesting is detected.
1647
+ * @returns A complete SQL subquery string (without surrounding parentheses) that
1648
+ * evaluates to a JSON array (hasMany) or a JSON object (belongsTo/hasOne).
1341
1649
  */
1342
- buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter) {
1650
+ buildRelationSubquery(relDef, spec, params, parentRef, aliasCounter, depth, path) {
1651
+ const currentDepth = depth ?? 0;
1652
+ const currentPath = path ?? [this.table];
1343
1653
  const targetTable = relDef.to;
1654
+ // Hard depth cap — the `with` clause is a finite JSON structure so users can't
1655
+ // create true infinite recursion, but extremely deep nesting (10+ levels) produces
1656
+ // unmanageably large SQL. Back-references (e.g. posts → user → posts) are allowed
1657
+ // since they are legitimate queries (Prisma supports the same pattern).
1658
+ if (currentDepth >= 10) {
1659
+ throw new CircularRelationError([...currentPath, targetTable]);
1660
+ }
1344
1661
  const targetMeta = this.schema.tables[targetTable];
1345
1662
  if (!targetMeta)
1346
- throw new Error(`[turbine] Unknown relation target "${targetTable}"`);
1663
+ throw new RelationError(`[turbine] Unknown relation target "${targetTable}"`);
1347
1664
  // Generate a unique alias: t0, t1, t2, ...
1348
1665
  const alias = `t${aliasCounter.n++}`;
1349
1666
  // Resolve which columns to include based on select/omit
@@ -1361,18 +1678,24 @@ export class QueryInterface {
1361
1678
  targetColumns = targetMeta.allColumns.filter((col) => !omittedFields.has(col));
1362
1679
  }
1363
1680
  // Build json_build_object pairs for resolved columns
1364
- const jsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${alias}.${quoteIdent(col)}`);
1365
- // Nested relations?
1366
- if (spec !== true && spec.with) {
1681
+ const jsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${alias}.${quoteIdent(col)}`);
1682
+ // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
1683
+ // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
1684
+ // so we must NOT build them here (they would push orphaned params).
1685
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
1686
+ // Nested relations — only in the non-wrapped path (wrapped path builds them separately)
1687
+ if (!willWrap && spec !== true && spec.with) {
1367
1688
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1368
1689
  const nestedRelDef = targetMeta.relations[nestedRelName];
1369
1690
  if (!nestedRelDef) {
1370
- throw new Error(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1691
+ throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1371
1692
  `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1372
1693
  }
1373
1694
  // Recursively build nested subquery, passing THIS alias as the parent reference
1374
- const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter);
1375
- jsonPairs.push(`'${nestedRelName}', COALESCE((${nestedSubquery}), '[]'::json)`);
1695
+ const nestedSubquery = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, alias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
1696
+ // Use '[]'::json for hasMany (empty array), NULL for belongsTo/hasOne (no object)
1697
+ const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
1698
+ jsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSubquery}), ${fallback})`);
1376
1699
  }
1377
1700
  }
1378
1701
  const jsonObj = `json_build_object(${jsonPairs.join(', ')})`;
@@ -1386,7 +1709,7 @@ export class QueryInterface {
1386
1709
  .map(([k, dir]) => {
1387
1710
  const col = camelToSnake(k);
1388
1711
  if (!targetMeta.allColumns.includes(col)) {
1389
- throw new Error(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
1712
+ throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
1390
1713
  }
1391
1714
  const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
1392
1715
  return `${alias}.${quoteIdent(col)} ${safeDir}`;
@@ -1409,7 +1732,7 @@ export class QueryInterface {
1409
1732
  for (const [k, v] of Object.entries(spec.where)) {
1410
1733
  const col = camelToSnake(k);
1411
1734
  if (!targetMeta.allColumns.includes(col)) {
1412
- throw new Error(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
1735
+ throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
1413
1736
  }
1414
1737
  params.push(v);
1415
1738
  whereClause += ` AND ${alias}.${quoteIdent(col)} = $${params.length}`;
@@ -1430,15 +1753,18 @@ export class QueryInterface {
1430
1753
  // Inner SELECT always needs all columns for WHERE/ORDER to work; json_build_object filters later
1431
1754
  const innerSql = `SELECT ${targetMeta.allColumns.map((c) => `${alias}.${quoteIdent(c)}`).join(', ')} FROM ${qTarget} ${alias} WHERE ${whereClause}${orderClause}${limitClause}`;
1432
1755
  // For the json_build_object, reference the inner alias — only include resolved columns
1433
- const innerJsonPairs = targetColumns.map((col) => `'${targetMeta.reverseColumnMap[col] ?? snakeToCamel(col)}', ${innerAlias}.${quoteIdent(col)}`);
1434
- // Re-add nested relation subqueries referencing innerAlias
1756
+ const innerJsonPairs = targetColumns.map((col) => `'${escSingleQuote(targetMeta.reverseColumnMap[col] ?? snakeToCamel(col))}', ${innerAlias}.${quoteIdent(col)}`);
1757
+ // Build nested relation subqueries referencing innerAlias
1435
1758
  if (spec !== true && spec.with) {
1436
- for (const [nestedRelName] of Object.entries(spec.with)) {
1759
+ for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
1437
1760
  const nestedRelDef = targetMeta.relations[nestedRelName];
1438
- if (nestedRelDef) {
1439
- const nestedSub = this.buildRelationSubquery(nestedRelDef, spec.with[nestedRelName], params, innerAlias, aliasCounter);
1440
- innerJsonPairs.push(`'${nestedRelName}', COALESCE((${nestedSub}), '[]'::json)`);
1761
+ if (!nestedRelDef) {
1762
+ throw new RelationError(`[turbine] Unknown relation "${nestedRelName}" on table "${targetTable}". ` +
1763
+ `Available: ${Object.keys(targetMeta.relations).join(', ')}`);
1441
1764
  }
1765
+ const nestedSub = this.buildRelationSubquery(nestedRelDef, nestedSpec, params, innerAlias, aliasCounter, currentDepth + 1, [...currentPath, relDef.name]);
1766
+ const fallback = nestedRelDef.type === 'hasMany' ? "'[]'::json" : 'NULL';
1767
+ innerJsonPairs.push(`'${escSingleQuote(nestedRelName)}', COALESCE((${nestedSub}), ${fallback})`);
1442
1768
  }
1443
1769
  }
1444
1770
  const innerJsonObj = `json_build_object(${innerJsonPairs.join(', ')})`;
@@ -1452,10 +1778,10 @@ export class QueryInterface {
1452
1778
  /**
1453
1779
  * Get the Postgres type for a column (e.g. 'jsonb', 'text', '_int4').
1454
1780
  * Used to detect JSONB/array columns for specialized operators.
1781
+ * Uses pre-computed Map for O(1) lookup instead of linear scan.
1455
1782
  */
1456
1783
  getColumnPgType(column) {
1457
- const col = this.tableMeta.columns.find((c) => c.name === column);
1458
- return col?.pgType ?? 'text';
1784
+ return this.columnPgTypeMap.get(column) ?? 'text';
1459
1785
  }
1460
1786
  /**
1461
1787
  * Get the Postgres base element type for an array column.
@@ -1542,13 +1868,15 @@ export class QueryInterface {
1542
1868
  }
1543
1869
  return clauses;
1544
1870
  }
1545
- /** Get the Postgres array type for a column (used by UNNEST in createMany) */
1871
+ /**
1872
+ * Get the Postgres array type for a column (used by UNNEST in createMany).
1873
+ * Uses pre-computed Map for O(1) lookup instead of linear scan.
1874
+ */
1546
1875
  getColumnArrayType(column) {
1547
- // Find the column metadata
1548
- const col = this.tableMeta.columns.find((c) => c.name === column);
1549
- if (col)
1550
- return col.pgArrayType;
1551
- // Fallback heuristic
1876
+ const arrayType = this.columnArrayTypeMap.get(column);
1877
+ if (arrayType)
1878
+ return arrayType;
1879
+ // Fallback heuristic for unknown columns
1552
1880
  if (column === 'id' || column.endsWith('_id'))
1553
1881
  return 'bigint[]';
1554
1882
  if (column.endsWith('_at'))