turbine-orm 0.19.0 → 0.19.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.
@@ -30,6 +30,20 @@ function isWhereOperator(value) {
30
30
  const keys = Object.keys(value);
31
31
  return keys.length > 0 && keys.every((k) => OPERATOR_KEYS.has(k));
32
32
  }
33
+ /**
34
+ * True for a *plain object literal* that reached an equality fallthrough
35
+ * without matching any known filter shape — the misspelled-operator case.
36
+ * Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
37
+ * bind values and return false, as do arrays and Dates.
38
+ */
39
+ function isUnmatchedPlainObject(value) {
40
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
41
+ return false;
42
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
43
+ return false;
44
+ const proto = Object.getPrototypeOf(value);
45
+ return proto === Object.prototype || proto === null;
46
+ }
33
47
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
34
48
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
35
49
  /** Known JSONB operator keys */
@@ -1908,6 +1922,16 @@ export class QueryInterface {
1908
1922
  parts.push(`${key}:fts(${cfg})`);
1909
1923
  continue;
1910
1924
  }
1925
+ // Plain object literal that matched no filter shape — give it a
1926
+ // fingerprint distinct from real equality. The build path throws for
1927
+ // these on non-JSON columns; sharing `key:eq` would let a cache entry
1928
+ // warmed by genuine equality serve the bad filter silently.
1929
+ if (isUnmatchedPlainObject(value)) {
1930
+ parts.push(`${key}:obj(${Object.keys(value)
1931
+ .sort()
1932
+ .join(',')})`);
1933
+ continue;
1934
+ }
1911
1935
  // Plain equality
1912
1936
  parts.push(`${key}:eq`);
1913
1937
  }
@@ -1947,6 +1971,11 @@ export class QueryInterface {
1947
1971
  const modeStr = mode === 'insensitive' ? ':i' : '';
1948
1972
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1949
1973
  }
1974
+ else if (isUnmatchedPlainObject(value)) {
1975
+ parts.push(`${key}:obj(${Object.keys(value)
1976
+ .sort()
1977
+ .join(',')})`);
1978
+ }
1950
1979
  else {
1951
1980
  parts.push(`${key}:eq`);
1952
1981
  }
@@ -2049,7 +2078,9 @@ export class QueryInterface {
2049
2078
  this.collectOperatorParams(value, params);
2050
2079
  continue;
2051
2080
  }
2052
- // Plain equality
2081
+ // Plain equality — same strict validation as the build path, so a
2082
+ // cache hit can never silently bind a misspelled-operator object.
2083
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2053
2084
  params.push(value);
2054
2085
  }
2055
2086
  }
@@ -2058,7 +2089,7 @@ export class QueryInterface {
2058
2089
  const meta = this.schema.tables[targetTable];
2059
2090
  if (!meta)
2060
2091
  return;
2061
- for (const [_field, value] of Object.entries(subWhere)) {
2092
+ for (const [field, value] of Object.entries(subWhere)) {
2062
2093
  if (value === undefined)
2063
2094
  continue;
2064
2095
  if (value === null)
@@ -2067,6 +2098,8 @@ export class QueryInterface {
2067
2098
  this.collectOperatorParams(value, params);
2068
2099
  continue;
2069
2100
  }
2101
+ const col = meta.columnMap[field] ?? camelToSnake(field);
2102
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2070
2103
  params.push(value);
2071
2104
  }
2072
2105
  }
@@ -2194,13 +2227,11 @@ export class QueryInterface {
2194
2227
  .sort();
2195
2228
  subParts.push(`om=${omKeys.join(',')}`);
2196
2229
  }
2197
- // where shape (value-invariant)
2230
+ // where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
2231
+ // `{title: {contains: 'x'}}` emit different SQL so they must not share
2232
+ // a fingerprint)
2198
2233
  if (opts.where) {
2199
- // Use a target-table QI if possible, or a simplified fingerprint
2200
- const wKeys = Object.keys(opts.where)
2201
- .filter((k) => opts.where[k] !== undefined)
2202
- .sort();
2203
- subParts.push(`w=${wKeys.join(',')}`);
2234
+ subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
2204
2235
  }
2205
2236
  // orderBy shape
2206
2237
  if (opts.orderBy) {
@@ -2250,11 +2281,9 @@ export class QueryInterface {
2250
2281
  // where params → limit param → nested-with params (always, both paths).
2251
2282
  if (relDef.type === 'manyToMany') {
2252
2283
  if (spec.where) {
2253
- for (const [, v] of Object.entries(spec.where)) {
2254
- params.push(v);
2255
- }
2284
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2256
2285
  }
2257
- if (spec.limit) {
2286
+ if (spec.limit !== undefined) {
2258
2287
  params.push(Number(spec.limit));
2259
2288
  }
2260
2289
  if (spec.with) {
@@ -2267,7 +2296,9 @@ export class QueryInterface {
2267
2296
  }
2268
2297
  return;
2269
2298
  }
2270
- const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2299
+ // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2300
+ const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2301
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2271
2302
  // Non-wrapped path: nested relations BEFORE where/limit
2272
2303
  if (!willWrap && spec.with) {
2273
2304
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2277,16 +2308,15 @@ export class QueryInterface {
2277
2308
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2278
2309
  }
2279
2310
  }
2280
- // where params
2311
+ // where params — mirrors buildAliasWhere push order
2281
2312
  if (spec.where) {
2282
- for (const [, v] of Object.entries(spec.where)) {
2283
- params.push(v);
2284
- }
2313
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2285
2314
  }
2286
2315
  // limit param — only hasMany parameterizes its limit (mirrors
2287
2316
  // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2288
2317
  // pushing one here would orphan a param and desync the collect path.
2289
- if (relDef.type === 'hasMany' && spec.limit) {
2318
+ // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2319
+ if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2290
2320
  params.push(Number(spec.limit));
2291
2321
  }
2292
2322
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2494,25 +2524,11 @@ export class QueryInterface {
2494
2524
  andClauses.push(...opClauses);
2495
2525
  continue;
2496
2526
  }
2497
- // Strict validation: a plain (non-array, non-Date) object on a non-JSON
2498
- // column matched no known filter shape almost always a misspelled
2499
- // operator (`startWith` for `startsWith`) or a stray nested object.
2500
- // Silently treating it as `col = $1` returns wrong rows with no error, so
2501
- // throw with the offending keys and the supported operator list. JSON/JSONB
2502
- // columns legitimately accept object values for equality, so they fall
2503
- // through unchanged.
2504
- if (typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date)) {
2505
- const colType = this.getColumnPgType(rawColumn);
2506
- if (colType !== 'json' && colType !== 'jsonb') {
2507
- const badKeys = Object.keys(value);
2508
- throw new ValidationError(badKeys.length === 0
2509
- ? `[turbine] Empty filter object on "${rawColumn}" for table "${this.table}". ` +
2510
- `Provide a value or an operator like { gt: 1 }.`
2511
- : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2512
- `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${this.table}". ` +
2513
- `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
2514
- }
2515
- }
2527
+ // Strict validation: a plain object literal that matched no known filter
2528
+ // shape is almost always a misspelled operator (`startWith` for
2529
+ // `startsWith`). The guard also runs on the cache-hit param-collect path
2530
+ // (collectWhereParams) so a warmed SQL cache can never skip it.
2531
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2516
2532
  // Plain equality
2517
2533
  params.push(value);
2518
2534
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2612,11 +2628,168 @@ export class QueryInterface {
2612
2628
  conditions.push(...opClauses);
2613
2629
  continue;
2614
2630
  }
2631
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2615
2632
  params.push(value);
2616
2633
  conditions.push(`${qCol} = ${this.p(params.length)}`);
2617
2634
  }
2618
2635
  return conditions.length > 0 ? conditions.join(' AND ') : null;
2619
2636
  }
2637
+ /**
2638
+ * Resolve a column's Postgres type from an arbitrary table's metadata
2639
+ * (relation targets, not just `this.table`).
2640
+ */
2641
+ pgTypeForColumn(meta, column) {
2642
+ return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
2643
+ }
2644
+ /**
2645
+ * Equality-fallthrough guard shared by every SQL-build path AND every
2646
+ * cache-hit param-collect path. A plain object literal that matched no known
2647
+ * filter shape on a non-JSON column is almost always a misspelled operator
2648
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
2649
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
2650
+ * legitimate bind values and pass through, as do objects on json/jsonb
2651
+ * columns (object equality).
2652
+ */
2653
+ assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
2654
+ if (!isUnmatchedPlainObject(value))
2655
+ return;
2656
+ if (columnPgType === 'json' || columnPgType === 'jsonb')
2657
+ return;
2658
+ const badKeys = Object.keys(value);
2659
+ throw new ValidationError(badKeys.length === 0
2660
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
2661
+ `Provide a value or an operator like { gt: 1 }.`
2662
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2663
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
2664
+ `Supported operators: ${[...OPERATOR_KEYS].join(', ')}.`);
2665
+ }
2666
+ /**
2667
+ * Build the user-supplied `where` filter of a relation `with` clause against
2668
+ * the relation's table alias. Supports the same scalar surface as the
2669
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
2670
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
2671
+ * objects throw via {@link assertBindableEqualityValue}.
2672
+ *
2673
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
2674
+ * cache hits and pipeline batching will desync.
2675
+ */
2676
+ buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2677
+ const clauses = [];
2678
+ for (const [key, value] of Object.entries(where)) {
2679
+ if (value === undefined)
2680
+ continue;
2681
+ if (key === 'OR' || key === 'AND') {
2682
+ const arr = value;
2683
+ if (!Array.isArray(arr) || arr.length === 0)
2684
+ continue;
2685
+ const subs = arr
2686
+ .map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
2687
+ .filter((s) => s !== null)
2688
+ .map((s) => `(${s})`);
2689
+ if (subs.length > 0)
2690
+ clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
2691
+ continue;
2692
+ }
2693
+ if (key === 'NOT') {
2694
+ const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
2695
+ if (sub)
2696
+ clauses.push(`NOT (${sub})`);
2697
+ continue;
2698
+ }
2699
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2700
+ if (!targetMeta.allColumns.includes(col)) {
2701
+ throw new ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
2702
+ }
2703
+ const qCol = `${alias}.${this.q(col)}`;
2704
+ if (value === null) {
2705
+ clauses.push(`${qCol} IS NULL`);
2706
+ continue;
2707
+ }
2708
+ if (isWhereOperator(value)) {
2709
+ clauses.push(...this.buildOperatorClauses(qCol, value, params));
2710
+ continue;
2711
+ }
2712
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2713
+ params.push(value);
2714
+ clauses.push(`${qCol} = ${this.p(params.length)}`);
2715
+ }
2716
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
2717
+ }
2718
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2719
+ collectAliasWhereParams(targetTable, targetMeta, where, params) {
2720
+ for (const [key, value] of Object.entries(where)) {
2721
+ if (value === undefined)
2722
+ continue;
2723
+ if (key === 'OR' || key === 'AND') {
2724
+ const arr = value;
2725
+ if (!Array.isArray(arr) || arr.length === 0)
2726
+ continue;
2727
+ for (const cond of arr) {
2728
+ this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
2729
+ }
2730
+ continue;
2731
+ }
2732
+ if (key === 'NOT') {
2733
+ this.collectAliasWhereParams(targetTable, targetMeta, value, params);
2734
+ continue;
2735
+ }
2736
+ if (value === null)
2737
+ continue;
2738
+ if (isWhereOperator(value)) {
2739
+ this.collectOperatorParams(value, params);
2740
+ continue;
2741
+ }
2742
+ const col = targetMeta.columnMap[key] ?? camelToSnake(key);
2743
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2744
+ params.push(value);
2745
+ }
2746
+ }
2747
+ /**
2748
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
2749
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
2750
+ * can emit — equality vs null vs operator sets vs combinators — or two
2751
+ * differently-shaped wheres would share one cached SQL string.
2752
+ */
2753
+ fingerprintAliasWhere(where) {
2754
+ const keys = Object.keys(where)
2755
+ .filter((k) => where[k] !== undefined)
2756
+ .sort();
2757
+ const parts = [];
2758
+ for (const key of keys) {
2759
+ const value = where[key];
2760
+ if (key === 'OR' || key === 'AND') {
2761
+ const arr = value;
2762
+ if (!Array.isArray(arr) || arr.length === 0)
2763
+ continue;
2764
+ parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
2765
+ continue;
2766
+ }
2767
+ if (key === 'NOT') {
2768
+ parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
2769
+ continue;
2770
+ }
2771
+ if (value === null) {
2772
+ parts.push(`${key}:null`);
2773
+ continue;
2774
+ }
2775
+ if (isWhereOperator(value)) {
2776
+ const opKeys = Object.keys(value)
2777
+ .filter((k) => k !== 'mode')
2778
+ .sort();
2779
+ const mode = value.mode;
2780
+ parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
2781
+ continue;
2782
+ }
2783
+ if (isUnmatchedPlainObject(value)) {
2784
+ parts.push(`${key}:obj(${Object.keys(value)
2785
+ .sort()
2786
+ .join(',')})`);
2787
+ continue;
2788
+ }
2789
+ parts.push(`${key}:eq`);
2790
+ }
2791
+ return parts.join('&');
2792
+ }
2620
2793
  /**
2621
2794
  * Build SQL clauses for a single operator object on a column.
2622
2795
  * Each operator key becomes its own clause, all ANDed together.
@@ -3063,7 +3236,11 @@ export class QueryInterface {
3063
3236
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
3064
3237
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
3065
3238
  // so we must NOT build them here (they would push orphaned params).
3066
- const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3239
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent
3240
+ // it must neither trigger the wrap (dropping nested relations) nor render a
3241
+ // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
3242
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3243
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
3067
3244
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3068
3245
  // where, orderBy, and select/omit are handled there (the target alias is the
3069
3246
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -3091,14 +3268,14 @@ export class QueryInterface {
3091
3268
  const qTarget = this.q(targetTable);
3092
3269
  // Build ORDER BY for json_agg
3093
3270
  let orderClause = '';
3094
- if (spec !== true && spec.orderBy) {
3095
- const orders = Object.entries(spec.orderBy)
3271
+ if (relOrderEntries.length > 0) {
3272
+ const orders = relOrderEntries
3096
3273
  .map(([k, dir]) => {
3097
3274
  const col = camelToSnake(k);
3098
3275
  if (!targetMeta.allColumns.includes(col)) {
3099
3276
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3100
3277
  }
3101
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3278
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3102
3279
  return `${alias}.${this.q(col)} ${safeDir}`;
3103
3280
  })
3104
3281
  .join(', ');
@@ -3115,24 +3292,21 @@ export class QueryInterface {
3115
3292
  else {
3116
3293
  whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3117
3294
  }
3118
- // Additional filters — properly parameterized
3295
+ // Additional filters — full scalar where surface (equality, null, operator
3296
+ // objects, OR/AND/NOT), properly parameterized against this alias.
3119
3297
  if (spec !== true && spec.where) {
3120
- for (const [k, v] of Object.entries(spec.where)) {
3121
- const col = camelToSnake(k);
3122
- if (!targetMeta.allColumns.includes(col)) {
3123
- throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3124
- }
3125
- params.push(v);
3126
- whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3127
- }
3298
+ const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
3299
+ if (extra)
3300
+ whereClause += ` AND ${extra}`;
3128
3301
  }
3129
3302
  // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3130
3303
  // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3131
3304
  // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3132
3305
  // which Postgres rejects with "could not determine data type of parameter $N"
3133
3306
  // (and shifts every later placeholder by one). To-one relations ignore limit.
3307
+ // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3134
3308
  let limitClause = '';
3135
- if (relDef.type === 'hasMany' && spec !== true && spec.limit) {
3309
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3136
3310
  params.push(Number(spec.limit));
3137
3311
  limitClause = ` LIMIT ${this.p(params.length)}`;
3138
3312
  }
@@ -3226,35 +3400,33 @@ export class QueryInterface {
3226
3400
  let whereClause = sourceKeys
3227
3401
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3228
3402
  .join(' AND ');
3229
- // ORDER BY on the target rows
3403
+ // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
3404
+ // treated as absent — it must not render a dangling `ORDER BY `.
3405
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3230
3406
  let orderClause = '';
3231
- if (spec !== true && spec.orderBy) {
3232
- const orders = Object.entries(spec.orderBy)
3407
+ if (relOrderEntries.length > 0) {
3408
+ const orders = relOrderEntries
3233
3409
  .map(([k, dir]) => {
3234
3410
  const col = camelToSnake(k);
3235
3411
  if (!targetMeta.allColumns.includes(col)) {
3236
3412
  throw new ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3237
3413
  }
3238
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3414
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3239
3415
  return `${talias}.${this.q(col)} ${safeDir}`;
3240
3416
  })
3241
3417
  .join(', ');
3242
3418
  orderClause = ` ORDER BY ${orders}`;
3243
3419
  }
3244
- // Additional WHERE filters on the target — properly parameterized.
3420
+ // Additional WHERE filters on the target — full scalar where surface,
3421
+ // properly parameterized against the target alias.
3245
3422
  if (spec !== true && spec.where) {
3246
- for (const [k, v] of Object.entries(spec.where)) {
3247
- const col = camelToSnake(k);
3248
- if (!targetMeta.allColumns.includes(col)) {
3249
- throw new ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3250
- }
3251
- params.push(v);
3252
- whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3253
- }
3423
+ const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
3424
+ if (extra)
3425
+ whereClause += ` AND ${extra}`;
3254
3426
  }
3255
- // LIMIT
3427
+ // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3256
3428
  let limitClause = '';
3257
- if (spec !== true && spec.limit) {
3429
+ if (spec !== true && spec.limit !== undefined) {
3258
3430
  params.push(Number(spec.limit));
3259
3431
  limitClause = ` LIMIT ${this.p(params.length)}`;
3260
3432
  }
@@ -5,7 +5,7 @@
5
5
  * `import { … } from './query/index.js'` is a drop-in replacement for the
6
6
  * former monolithic `import { … } from './query.js'`.
7
7
  */
8
- export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
8
+ export type { AggregateArgs, AggregateResult, ArrayFilter, ConnectOrCreateOp, CountArgs, CreateArgs, CreateManyArgs, DeleteArgs, DeleteManyArgs, FieldResult, FindManyArgs, FindManyStreamArgs, FindUniqueArgs, GroupByArgs, HavingClause, JsonFilter, NestedCreateOp, NestedUpdateOp, OmitResult, OrderByClause, OrderDirection, QueryResult, RelationDescriptor, RelationFilter, SelectResult, TextSearchFilter, TypedWithClause, UpdateArgs, UpdateInput, UpdateManyArgs, UpdateOperatorInput, UpsertArgs, VectorDistanceFilter, VectorFilter, VectorMetric, VectorOrderBy, VectorOrderByDistance, WhereClause, WhereOperator, WhereValue, WithClause, WithOptions, WithResult, } from './types.js';
9
9
  export type { BuiltStatement, BulkInsertStatementInput, ColumnDefinitionInput, ColumnTypeInput, CreateIndexStatementInput, CreateTableStatementInput, Dialect, InsertStatementInput, UpsertStatementInput, } from '../dialect.js';
10
10
  export { postgresDialect } from '../dialect.js';
11
11
  export type { SqlCacheEntry } from './utils.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "turbine-orm",
3
- "version": "0.19.0",
3
+ "version": "0.19.1",
4
4
  "description": "Postgres-native TypeScript ORM — runs on Neon, Vercel Postgres, Cloudflare, Supabase. Streaming cursors, typed errors, single-query nested relations. One dependency, no WASM engine",
5
5
  "type": "module",
6
6
  "exports": {
@@ -42,7 +42,7 @@
42
42
  "sideEffects": false,
43
43
  "scripts": {
44
44
  "prebuild": "npm run gen:studio",
45
- "build": "tsc && tsc --project tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
45
+ "build": "rm -rf dist && tsc && tsc --project tsconfig.cjs.json && echo '{\"type\":\"commonjs\"}' > dist/cjs/package.json",
46
46
  "dev": "tsc --watch",
47
47
  "typecheck": "tsc --noEmit --project tsconfig.test.json",
48
48
  "generate": "tsx src/cli/index.ts generate",
@@ -50,7 +50,7 @@
50
50
  "examples": "tsx examples/examples.ts",
51
51
  "dogfood": "tsx examples/dogfood.ts",
52
52
  "test": "tsx --test --test-concurrency=1 src/test/*.test.ts",
53
- "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts src/test/nested-write.test.ts src/test/nested-write-update-upsert.test.ts src/test/cursor-pagination.test.ts src/test/client-branches.test.ts src/test/is-isNot-filter.test.ts src/test/event-emitter.test.ts src/test/observe.test.ts",
53
+ "test:unit": "tsx --test src/test/schema-builder.test.ts src/test/errors.test.ts src/test/stress.test.ts src/test/migrate.test.ts src/test/update-operators.test.ts src/test/empty-where-guard.test.ts src/test/cli.test.ts src/test/serverless.test.ts src/test/pipeline.test.ts src/test/pipeline-submittable.test.ts src/test/with-inference.test.ts src/test/operator-validation.test.ts src/test/unlimited-warning.test.ts src/test/generate-relations.test.ts src/test/stream-and-parse.test.ts src/test/sql-cache.test.ts src/test/dialect.test.ts src/test/studio.test.ts src/test/sql-injection.test.ts src/test/cli-flags.test.ts src/test/cockroachdb-adapter.test.ts src/test/yugabytedb-adapter.test.ts src/test/pg-compat.test.ts src/test/relation-filter-validation.test.ts src/test/client-coverage.test.ts src/test/schema-diff.test.ts src/test/composite-fk.test.ts src/test/retry.test.ts src/test/text-search.test.ts src/test/optimistic-lock.test.ts src/test/sql-safety-property.test.ts src/test/nested-write.test.ts src/test/nested-write-update-upsert.test.ts src/test/cursor-pagination.test.ts src/test/client-branches.test.ts src/test/is-isNot-filter.test.ts src/test/event-emitter.test.ts src/test/observe.test.ts src/test/relation-limit-param.test.ts src/test/where-guard-cache-and-relation-where.test.ts",
54
54
  "test:coverage": "c8 tsx --test --test-concurrency=1 src/test/*.test.ts",
55
55
  "lint": "biome check src/",
56
56
  "lint:fix": "biome check --write src/",