turbine-orm 0.18.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.
@@ -66,6 +66,20 @@ function isWhereOperator(value) {
66
66
  const keys = Object.keys(value);
67
67
  return keys.length > 0 && keys.every((k) => utils_js_1.OPERATOR_KEYS.has(k));
68
68
  }
69
+ /**
70
+ * True for a *plain object literal* that reached an equality fallthrough
71
+ * without matching any known filter shape — the misspelled-operator case.
72
+ * Class instances (Buffer for bytea, Decimal wrappers, ...) are legitimate
73
+ * bind values and return false, as do arrays and Dates.
74
+ */
75
+ function isUnmatchedPlainObject(value) {
76
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || value instanceof Date)
77
+ return false;
78
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(value))
79
+ return false;
80
+ const proto = Object.getPrototypeOf(value);
81
+ return proto === Object.prototype || proto === null;
82
+ }
69
83
  /** Known atomic-update operator keys — used to detect operator objects vs plain JSON values */
70
84
  const UPDATE_OPERATOR_KEYS = new Set(['set', 'increment', 'decrement', 'multiply', 'divide']);
71
85
  /** Known JSONB operator keys */
@@ -1723,12 +1737,24 @@ class QueryInterface {
1723
1737
  */
1724
1738
  resolveColumns(select, omit) {
1725
1739
  if (select) {
1740
+ // An array here means a caller wrote `select: ['id', 'name']` (Drizzle/SQL
1741
+ // style) instead of the object shape. Object.entries() would iterate the
1742
+ // numeric indices and throw a cryptic `Unknown field "0"` — catch it early
1743
+ // with an actionable message.
1744
+ if (Array.isArray(select)) {
1745
+ throw new errors_js_1.ValidationError(`[turbine] "select" must be an object mapping field names to true ` +
1746
+ `(e.g. { id: true, name: true }), not an array.`);
1747
+ }
1726
1748
  // Only include columns where value is true
1727
1749
  return Object.entries(select)
1728
1750
  .filter(([, v]) => v)
1729
1751
  .map(([k]) => this.toColumn(k));
1730
1752
  }
1731
1753
  if (omit) {
1754
+ if (Array.isArray(omit)) {
1755
+ throw new errors_js_1.ValidationError(`[turbine] "omit" must be an object mapping field names to true ` +
1756
+ `(e.g. { createdAt: true }), not an array.`);
1757
+ }
1732
1758
  // Include all columns except those where value is true
1733
1759
  const omitCols = new Set(Object.entries(omit)
1734
1760
  .filter(([, v]) => v)
@@ -1932,6 +1958,16 @@ class QueryInterface {
1932
1958
  parts.push(`${key}:fts(${cfg})`);
1933
1959
  continue;
1934
1960
  }
1961
+ // Plain object literal that matched no filter shape — give it a
1962
+ // fingerprint distinct from real equality. The build path throws for
1963
+ // these on non-JSON columns; sharing `key:eq` would let a cache entry
1964
+ // warmed by genuine equality serve the bad filter silently.
1965
+ if (isUnmatchedPlainObject(value)) {
1966
+ parts.push(`${key}:obj(${Object.keys(value)
1967
+ .sort()
1968
+ .join(',')})`);
1969
+ continue;
1970
+ }
1935
1971
  // Plain equality
1936
1972
  parts.push(`${key}:eq`);
1937
1973
  }
@@ -1971,6 +2007,11 @@ class QueryInterface {
1971
2007
  const modeStr = mode === 'insensitive' ? ':i' : '';
1972
2008
  parts.push(`${key}:op(${opKeys.join(',')}${modeStr})`);
1973
2009
  }
2010
+ else if (isUnmatchedPlainObject(value)) {
2011
+ parts.push(`${key}:obj(${Object.keys(value)
2012
+ .sort()
2013
+ .join(',')})`);
2014
+ }
1974
2015
  else {
1975
2016
  parts.push(`${key}:eq`);
1976
2017
  }
@@ -2073,7 +2114,9 @@ class QueryInterface {
2073
2114
  this.collectOperatorParams(value, params);
2074
2115
  continue;
2075
2116
  }
2076
- // Plain equality
2117
+ // Plain equality — same strict validation as the build path, so a
2118
+ // cache hit can never silently bind a misspelled-operator object.
2119
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2077
2120
  params.push(value);
2078
2121
  }
2079
2122
  }
@@ -2082,7 +2125,7 @@ class QueryInterface {
2082
2125
  const meta = this.schema.tables[targetTable];
2083
2126
  if (!meta)
2084
2127
  return;
2085
- for (const [_field, value] of Object.entries(subWhere)) {
2128
+ for (const [field, value] of Object.entries(subWhere)) {
2086
2129
  if (value === undefined)
2087
2130
  continue;
2088
2131
  if (value === null)
@@ -2091,6 +2134,8 @@ class QueryInterface {
2091
2134
  this.collectOperatorParams(value, params);
2092
2135
  continue;
2093
2136
  }
2137
+ const col = meta.columnMap[field] ?? (0, schema_js_1.camelToSnake)(field);
2138
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2094
2139
  params.push(value);
2095
2140
  }
2096
2141
  }
@@ -2218,13 +2263,11 @@ class QueryInterface {
2218
2263
  .sort();
2219
2264
  subParts.push(`om=${omKeys.join(',')}`);
2220
2265
  }
2221
- // where shape (value-invariant)
2266
+ // where shape (value-invariant, operator-shape-aware: `{title: 'x'}` and
2267
+ // `{title: {contains: 'x'}}` emit different SQL so they must not share
2268
+ // a fingerprint)
2222
2269
  if (opts.where) {
2223
- // Use a target-table QI if possible, or a simplified fingerprint
2224
- const wKeys = Object.keys(opts.where)
2225
- .filter((k) => opts.where[k] !== undefined)
2226
- .sort();
2227
- subParts.push(`w=${wKeys.join(',')}`);
2270
+ subParts.push(`w=${this.fingerprintAliasWhere(opts.where)}`);
2228
2271
  }
2229
2272
  // orderBy shape
2230
2273
  if (opts.orderBy) {
@@ -2274,11 +2317,9 @@ class QueryInterface {
2274
2317
  // where params → limit param → nested-with params (always, both paths).
2275
2318
  if (relDef.type === 'manyToMany') {
2276
2319
  if (spec.where) {
2277
- for (const [, v] of Object.entries(spec.where)) {
2278
- params.push(v);
2279
- }
2320
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2280
2321
  }
2281
- if (spec.limit) {
2322
+ if (spec.limit !== undefined) {
2282
2323
  params.push(Number(spec.limit));
2283
2324
  }
2284
2325
  if (spec.with) {
@@ -2291,7 +2332,9 @@ class QueryInterface {
2291
2332
  }
2292
2333
  return;
2293
2334
  }
2294
- const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || spec.orderBy !== undefined);
2335
+ // Mirrors buildRelationSubquery's willWrap: `orderBy: {}` is treated as absent.
2336
+ const hasOrder = spec.orderBy ? Object.values(spec.orderBy).some((dir) => dir !== undefined) : false;
2337
+ const willWrap = relDef.type === 'hasMany' && (spec.limit !== undefined || hasOrder);
2295
2338
  // Non-wrapped path: nested relations BEFORE where/limit
2296
2339
  if (!willWrap && spec.with) {
2297
2340
  for (const [nestedRelName, nestedSpec] of Object.entries(spec.with)) {
@@ -2301,14 +2344,15 @@ class QueryInterface {
2301
2344
  this.collectRelationSubqueryParams(nestedRelDef, nestedSpec, params, 'alias', depth + 1);
2302
2345
  }
2303
2346
  }
2304
- // where params
2347
+ // where params — mirrors buildAliasWhere push order
2305
2348
  if (spec.where) {
2306
- for (const [, v] of Object.entries(spec.where)) {
2307
- params.push(v);
2308
- }
2349
+ this.collectAliasWhereParams(targetTable, targetMeta, spec.where, params);
2309
2350
  }
2310
- // limit param
2311
- if (spec.limit) {
2351
+ // limit param — only hasMany parameterizes its limit (mirrors
2352
+ // buildRelationSubquery). belongsTo/hasOne ignore limit (always LIMIT 1), so
2353
+ // pushing one here would orphan a param and desync the collect path.
2354
+ // `limit: 0` pushes (LIMIT 0 is honored), so check !== undefined.
2355
+ if (relDef.type === 'hasMany' && spec.limit !== undefined) {
2312
2356
  params.push(Number(spec.limit));
2313
2357
  }
2314
2358
  // Wrapped path: nested relations AFTER where/limit (inside inner subquery)
@@ -2516,6 +2560,11 @@ class QueryInterface {
2516
2560
  andClauses.push(...opClauses);
2517
2561
  continue;
2518
2562
  }
2563
+ // Strict validation: a plain object literal that matched no known filter
2564
+ // shape is almost always a misspelled operator (`startWith` for
2565
+ // `startsWith`). The guard also runs on the cache-hit param-collect path
2566
+ // (collectWhereParams) so a warmed SQL cache can never skip it.
2567
+ this.assertBindableEqualityValue(rawColumn, value, this.getColumnPgType(rawColumn), this.table);
2519
2568
  // Plain equality
2520
2569
  params.push(value);
2521
2570
  andClauses.push(`${column} = ${this.p(params.length)}`);
@@ -2615,11 +2664,168 @@ class QueryInterface {
2615
2664
  conditions.push(...opClauses);
2616
2665
  continue;
2617
2666
  }
2667
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(meta, col), targetTable);
2618
2668
  params.push(value);
2619
2669
  conditions.push(`${qCol} = ${this.p(params.length)}`);
2620
2670
  }
2621
2671
  return conditions.length > 0 ? conditions.join(' AND ') : null;
2622
2672
  }
2673
+ /**
2674
+ * Resolve a column's Postgres type from an arbitrary table's metadata
2675
+ * (relation targets, not just `this.table`).
2676
+ */
2677
+ pgTypeForColumn(meta, column) {
2678
+ return meta.dialectTypes?.[column] ?? meta.pgTypes?.[column] ?? 'text';
2679
+ }
2680
+ /**
2681
+ * Equality-fallthrough guard shared by every SQL-build path AND every
2682
+ * cache-hit param-collect path. A plain object literal that matched no known
2683
+ * filter shape on a non-JSON column is almost always a misspelled operator
2684
+ * (`startWith` for `startsWith`); binding it as `col = $1` silently returns
2685
+ * wrong rows. Class instances (Buffer for bytea, Decimal wrappers, ...) are
2686
+ * legitimate bind values and pass through, as do objects on json/jsonb
2687
+ * columns (object equality).
2688
+ */
2689
+ assertBindableEqualityValue(rawColumn, value, columnPgType, table) {
2690
+ if (!isUnmatchedPlainObject(value))
2691
+ return;
2692
+ if (columnPgType === 'json' || columnPgType === 'jsonb')
2693
+ return;
2694
+ const badKeys = Object.keys(value);
2695
+ throw new errors_js_1.ValidationError(badKeys.length === 0
2696
+ ? `[turbine] Empty filter object on "${rawColumn}" for table "${table}". ` +
2697
+ `Provide a value or an operator like { gt: 1 }.`
2698
+ : `[turbine] Unknown operator${badKeys.length > 1 ? 's' : ''} ` +
2699
+ `${badKeys.map((k) => `"${k}"`).join(', ')} on "${rawColumn}" for table "${table}". ` +
2700
+ `Supported operators: ${[...utils_js_1.OPERATOR_KEYS].join(', ')}.`);
2701
+ }
2702
+ /**
2703
+ * Build the user-supplied `where` filter of a relation `with` clause against
2704
+ * the relation's table alias. Supports the same scalar surface as the
2705
+ * top-level WHERE builder — equality, IS NULL, operator objects (incl.
2706
+ * `mode: 'insensitive'`), and OR/AND/NOT combinators. Unknown operator
2707
+ * objects throw via {@link assertBindableEqualityValue}.
2708
+ *
2709
+ * Param push order MUST mirror {@link collectAliasWhereParams} exactly, or
2710
+ * cache hits and pipeline batching will desync.
2711
+ */
2712
+ buildAliasWhere(targetTable, targetMeta, alias, where, params) {
2713
+ const clauses = [];
2714
+ for (const [key, value] of Object.entries(where)) {
2715
+ if (value === undefined)
2716
+ continue;
2717
+ if (key === 'OR' || key === 'AND') {
2718
+ const arr = value;
2719
+ if (!Array.isArray(arr) || arr.length === 0)
2720
+ continue;
2721
+ const subs = arr
2722
+ .map((cond) => this.buildAliasWhere(targetTable, targetMeta, alias, cond, params))
2723
+ .filter((s) => s !== null)
2724
+ .map((s) => `(${s})`);
2725
+ if (subs.length > 0)
2726
+ clauses.push(`(${subs.join(key === 'OR' ? ' OR ' : ' AND ')})`);
2727
+ continue;
2728
+ }
2729
+ if (key === 'NOT') {
2730
+ const sub = this.buildAliasWhere(targetTable, targetMeta, alias, value, params);
2731
+ if (sub)
2732
+ clauses.push(`NOT (${sub})`);
2733
+ continue;
2734
+ }
2735
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2736
+ if (!targetMeta.allColumns.includes(col)) {
2737
+ throw new errors_js_1.ValidationError(`[turbine] Unknown column "${key}" in where for table "${targetTable}"`);
2738
+ }
2739
+ const qCol = `${alias}.${this.q(col)}`;
2740
+ if (value === null) {
2741
+ clauses.push(`${qCol} IS NULL`);
2742
+ continue;
2743
+ }
2744
+ if (isWhereOperator(value)) {
2745
+ clauses.push(...this.buildOperatorClauses(qCol, value, params));
2746
+ continue;
2747
+ }
2748
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2749
+ params.push(value);
2750
+ clauses.push(`${qCol} = ${this.p(params.length)}`);
2751
+ }
2752
+ return clauses.length > 0 ? clauses.join(' AND ') : null;
2753
+ }
2754
+ /** Mirrors {@link buildAliasWhere} param-push order for the cache-hit collect path. */
2755
+ collectAliasWhereParams(targetTable, targetMeta, where, params) {
2756
+ for (const [key, value] of Object.entries(where)) {
2757
+ if (value === undefined)
2758
+ continue;
2759
+ if (key === 'OR' || key === 'AND') {
2760
+ const arr = value;
2761
+ if (!Array.isArray(arr) || arr.length === 0)
2762
+ continue;
2763
+ for (const cond of arr) {
2764
+ this.collectAliasWhereParams(targetTable, targetMeta, cond, params);
2765
+ }
2766
+ continue;
2767
+ }
2768
+ if (key === 'NOT') {
2769
+ this.collectAliasWhereParams(targetTable, targetMeta, value, params);
2770
+ continue;
2771
+ }
2772
+ if (value === null)
2773
+ continue;
2774
+ if (isWhereOperator(value)) {
2775
+ this.collectOperatorParams(value, params);
2776
+ continue;
2777
+ }
2778
+ const col = targetMeta.columnMap[key] ?? (0, schema_js_1.camelToSnake)(key);
2779
+ this.assertBindableEqualityValue(col, value, this.pgTypeForColumn(targetMeta, col), targetTable);
2780
+ params.push(value);
2781
+ }
2782
+ }
2783
+ /**
2784
+ * Value-invariant, shape-aware fingerprint for a relation `with` clause's
2785
+ * `where` filter. Must distinguish every SQL shape {@link buildAliasWhere}
2786
+ * can emit — equality vs null vs operator sets vs combinators — or two
2787
+ * differently-shaped wheres would share one cached SQL string.
2788
+ */
2789
+ fingerprintAliasWhere(where) {
2790
+ const keys = Object.keys(where)
2791
+ .filter((k) => where[k] !== undefined)
2792
+ .sort();
2793
+ const parts = [];
2794
+ for (const key of keys) {
2795
+ const value = where[key];
2796
+ if (key === 'OR' || key === 'AND') {
2797
+ const arr = value;
2798
+ if (!Array.isArray(arr) || arr.length === 0)
2799
+ continue;
2800
+ parts.push(`${key}[${arr.map((c) => this.fingerprintAliasWhere(c)).join(',')}]`);
2801
+ continue;
2802
+ }
2803
+ if (key === 'NOT') {
2804
+ parts.push(`NOT(${this.fingerprintAliasWhere(value)})`);
2805
+ continue;
2806
+ }
2807
+ if (value === null) {
2808
+ parts.push(`${key}:null`);
2809
+ continue;
2810
+ }
2811
+ if (isWhereOperator(value)) {
2812
+ const opKeys = Object.keys(value)
2813
+ .filter((k) => k !== 'mode')
2814
+ .sort();
2815
+ const mode = value.mode;
2816
+ parts.push(`${key}:op(${opKeys.join(',')}${mode === 'insensitive' ? ':i' : ''})`);
2817
+ continue;
2818
+ }
2819
+ if (isUnmatchedPlainObject(value)) {
2820
+ parts.push(`${key}:obj(${Object.keys(value)
2821
+ .sort()
2822
+ .join(',')})`);
2823
+ continue;
2824
+ }
2825
+ parts.push(`${key}:eq`);
2826
+ }
2827
+ return parts.join('&');
2828
+ }
2623
2829
  /**
2624
2830
  * Build SQL clauses for a single operator object on a column.
2625
2831
  * Each operator key becomes its own clause, all ANDed together.
@@ -2699,7 +2905,8 @@ class QueryInterface {
2699
2905
  return Object.entries(orderBy)
2700
2906
  .map(([key, dir]) => {
2701
2907
  if (meta && !(key in meta.columnMap)) {
2702
- throw new errors_js_1.ValidationError(`Unknown column "${key}" in orderBy for table "${this.table}"`);
2908
+ throw new errors_js_1.ValidationError(`[turbine] Unknown field "${key}" in orderBy on table "${this.table}". ` +
2909
+ `Known fields: ${Object.keys(meta.columnMap).join(', ') || '(none)'}.`);
2703
2910
  }
2704
2911
  // Vector KNN ordering: { distance: { to, metric, direction? } }
2705
2912
  if (isVectorOrderBy(dir)) {
@@ -3065,7 +3272,11 @@ class QueryInterface {
3065
3272
  // Determine if this hasMany will take the wrapped subquery path (LIMIT or ORDER BY).
3066
3273
  // When wrapping, nested relations are built in the wrapped path referencing innerAlias,
3067
3274
  // so we must NOT build them here (they would push orphaned params).
3068
- const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || spec.orderBy !== undefined);
3275
+ // An orderBy with no defined entries (`orderBy: {}`) is treated as absent
3276
+ // it must neither trigger the wrap (dropping nested relations) nor render a
3277
+ // dangling `ORDER BY `. `limit: 0` is meaningful (LIMIT 0) and DOES wrap.
3278
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3279
+ const willWrap = relDef.type === 'hasMany' && spec !== true && (spec.limit !== undefined || relOrderEntries.length > 0);
3069
3280
  // manyToMany takes a dedicated JOIN-through-junction path. Nested relations,
3070
3281
  // where, orderBy, and select/omit are handled there (the target alias is the
3071
3282
  // row source, exactly like hasMany), so short-circuit before the hasMany logic.
@@ -3093,14 +3304,14 @@ class QueryInterface {
3093
3304
  const qTarget = this.q(targetTable);
3094
3305
  // Build ORDER BY for json_agg
3095
3306
  let orderClause = '';
3096
- if (spec !== true && spec.orderBy) {
3097
- const orders = Object.entries(spec.orderBy)
3307
+ if (relOrderEntries.length > 0) {
3308
+ const orders = relOrderEntries
3098
3309
  .map(([k, dir]) => {
3099
3310
  const col = (0, schema_js_1.camelToSnake)(k);
3100
3311
  if (!targetMeta.allColumns.includes(col)) {
3101
3312
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3102
3313
  }
3103
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3314
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3104
3315
  return `${alias}.${this.q(col)} ${safeDir}`;
3105
3316
  })
3106
3317
  .join(', ');
@@ -3117,20 +3328,21 @@ class QueryInterface {
3117
3328
  else {
3118
3329
  whereClause = this.dialect.buildCorrelation(alias, relDef.foreignKey, qParent, relDef.referenceKey);
3119
3330
  }
3120
- // Additional filters — properly parameterized
3331
+ // Additional filters — full scalar where surface (equality, null, operator
3332
+ // objects, OR/AND/NOT), properly parameterized against this alias.
3121
3333
  if (spec !== true && spec.where) {
3122
- for (const [k, v] of Object.entries(spec.where)) {
3123
- const col = (0, schema_js_1.camelToSnake)(k);
3124
- if (!targetMeta.allColumns.includes(col)) {
3125
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3126
- }
3127
- params.push(v);
3128
- whereClause += ` AND ${alias}.${this.q(col)} = ${this.p(params.length)}`;
3129
- }
3130
- }
3131
- // LIMIT
3334
+ const extra = this.buildAliasWhere(targetTable, targetMeta, alias, spec.where, params);
3335
+ if (extra)
3336
+ whereClause += ` AND ${extra}`;
3337
+ }
3338
+ // LIMIT — only meaningful for hasMany. A belongsTo / hasOne subquery returns
3339
+ // a single row (literal `LIMIT 1` below), so a `spec.limit` here must NOT push
3340
+ // a parameter: doing so orphans an untyped `$N` that the SQL never references,
3341
+ // which Postgres rejects with "could not determine data type of parameter $N"
3342
+ // (and shifts every later placeholder by one). To-one relations ignore limit.
3343
+ // `limit: 0` is honored (LIMIT 0 → empty array), so check !== undefined.
3132
3344
  let limitClause = '';
3133
- if (spec !== true && spec.limit) {
3345
+ if (relDef.type === 'hasMany' && spec !== true && spec.limit !== undefined) {
3134
3346
  params.push(Number(spec.limit));
3135
3347
  limitClause = ` LIMIT ${this.p(params.length)}`;
3136
3348
  }
@@ -3224,35 +3436,33 @@ class QueryInterface {
3224
3436
  let whereClause = sourceKeys
3225
3437
  .map((jcol, i) => `${jalias}.${this.q(jcol)} = ${qParent}.${this.q(refKeys[i])}`)
3226
3438
  .join(' AND ');
3227
- // ORDER BY on the target rows
3439
+ // ORDER BY on the target rows. `orderBy: {}` (no defined entries) is
3440
+ // treated as absent — it must not render a dangling `ORDER BY `.
3441
+ const relOrderEntries = spec !== true && spec.orderBy ? Object.entries(spec.orderBy).filter(([, dir]) => dir !== undefined) : [];
3228
3442
  let orderClause = '';
3229
- if (spec !== true && spec.orderBy) {
3230
- const orders = Object.entries(spec.orderBy)
3443
+ if (relOrderEntries.length > 0) {
3444
+ const orders = relOrderEntries
3231
3445
  .map(([k, dir]) => {
3232
3446
  const col = (0, schema_js_1.camelToSnake)(k);
3233
3447
  if (!targetMeta.allColumns.includes(col)) {
3234
3448
  throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in orderBy for table "${targetTable}"`);
3235
3449
  }
3236
- const safeDir = dir.toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3450
+ const safeDir = String(dir).toLowerCase() === 'desc' ? 'DESC' : 'ASC';
3237
3451
  return `${talias}.${this.q(col)} ${safeDir}`;
3238
3452
  })
3239
3453
  .join(', ');
3240
3454
  orderClause = ` ORDER BY ${orders}`;
3241
3455
  }
3242
- // Additional WHERE filters on the target — properly parameterized.
3456
+ // Additional WHERE filters on the target — full scalar where surface,
3457
+ // properly parameterized against the target alias.
3243
3458
  if (spec !== true && spec.where) {
3244
- for (const [k, v] of Object.entries(spec.where)) {
3245
- const col = (0, schema_js_1.camelToSnake)(k);
3246
- if (!targetMeta.allColumns.includes(col)) {
3247
- throw new errors_js_1.ValidationError(`[turbine] Unknown column "${k}" in where for table "${targetTable}"`);
3248
- }
3249
- params.push(v);
3250
- whereClause += ` AND ${talias}.${this.q(col)} = ${this.p(params.length)}`;
3251
- }
3459
+ const extra = this.buildAliasWhere(targetTable, targetMeta, talias, spec.where, params);
3460
+ if (extra)
3461
+ whereClause += ` AND ${extra}`;
3252
3462
  }
3253
- // LIMIT
3463
+ // LIMIT — `limit: 0` is honored (LIMIT 0 → empty array)
3254
3464
  let limitClause = '';
3255
- if (spec !== true && spec.limit) {
3465
+ if (spec !== true && spec.limit !== undefined) {
3256
3466
  params.push(Number(spec.limit));
3257
3467
  limitClause = ` LIMIT ${this.p(params.length)}`;
3258
3468
  }
package/dist/cli/index.js CHANGED
@@ -20,14 +20,14 @@
20
20
  * npx turbine init --url postgres://...
21
21
  * npx turbine migrate create add_users_table
22
22
  */
23
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
23
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
24
24
  import { dirname, relative, resolve } from 'node:path';
25
25
  import { pathToFileURL } from 'node:url';
26
26
  import { generate } from '../generate.js';
27
27
  import { introspect } from '../introspect.js';
28
28
  import { schemaDiff, schemaPush } from '../schema-sql.js';
29
29
  import { configTemplate, findConfigFile, loadConfig, resolveConfig } from './config.js';
30
- import { needsTsLoader, registerTsLoader } from './loader.js';
30
+ import { canResolveTsx, getTsLoaderError, needsTsLoader, registerTsLoader } from './loader.js';
31
31
  import { createMigration, listMigrationFiles, migrateDown, migrateStatus, migrateUp } from './migrate.js';
32
32
  import { startObserve } from './observe.js';
33
33
  import { startStudio } from './studio.js';
@@ -132,6 +132,15 @@ function failMissingTsLoader(filePath, reason) {
132
132
  console.log(` ${dim('Your Node.js version does not support')} ${cyan('module.register()')}.`);
133
133
  console.log(` ${dim('Upgrade to Node.js')} ${cyan('20.6+')} ${dim('or use a')} ${cyan('.js')} ${dim('/')} ${cyan('.mjs')} ${dim('config file.')}`);
134
134
  }
135
+ else if (reason === 'failed') {
136
+ // tsx IS installed but registering its loader threw. Report the real
137
+ // cause — telling the user to install tsx here would be a misdiagnosis.
138
+ console.log(` ${dim('tsx is installed, but registering its TypeScript loader failed:')}`);
139
+ newline();
140
+ console.log(` ${getTsLoaderError() ?? '(unknown error)'}`);
141
+ newline();
142
+ console.log(` ${dim('Try upgrading tsx:')} ${cyan('npm install --save-dev tsx@latest')}${dim(', or rename your file to')} ${cyan('.mjs')}.`);
143
+ }
135
144
  else {
136
145
  console.log(` ${dim('Loading .ts config / schema files requires')} ${cyan('tsx')} ${dim('to be installed.')}`);
137
146
  newline();
@@ -175,7 +184,7 @@ async function loadSchemaFile(schemaFile) {
175
184
  // ERR_UNKNOWN_FILE_EXTENSION for `.ts`.
176
185
  if (needsTsLoader(absPath)) {
177
186
  const status = await registerTsLoader();
178
- if (status === 'missing' || status === 'unsupported') {
187
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
179
188
  failMissingTsLoader(schemaFile, status);
180
189
  }
181
190
  }
@@ -311,7 +320,7 @@ export default defineSchema({
311
320
  // id: { type: 'serial', primaryKey: true },
312
321
  // email: { type: 'text', notNull: true, unique: true },
313
322
  // name: { type: 'text', notNull: true },
314
- // created_at: { type: 'timestamptz', default: 'NOW()' },
323
+ // created_at: { type: 'timestamp', default: 'NOW()' },
315
324
  // },
316
325
  });
317
326
  `, 'utf-8');
@@ -374,6 +383,9 @@ export default defineSchema({
374
383
  console.log(` ${dim('or create a')} ${cyan('.env')} ${dim('file with')} ${cyan('DATABASE_URL=postgres://...')}`);
375
384
  }
376
385
  console.log(` ${dim('2.')} Run ${cyan('npx turbine generate')} to introspect your DB`);
386
+ if (!canResolveTsx()) {
387
+ console.log(` ${dim('Note: the TypeScript config requires')} ${cyan('tsx')} ${dim('—')} ${cyan('npm install --save-dev tsx')}`);
388
+ }
377
389
  }
378
390
  else {
379
391
  console.log(` ${dim('1.')} Import the generated client:`);
@@ -1214,7 +1226,17 @@ function showVersion() {
1214
1226
  // Using process.argv[1] instead of import.meta.url so the same code compiles
1215
1227
  // cleanly for both the ESM and CJS builds.
1216
1228
  try {
1217
- let dir = dirname(process.argv[1] ?? '');
1229
+ // Resolve symlinks first: `npx turbine` runs via node_modules/.bin/turbine,
1230
+ // a symlink whose dirname would walk the CONSUMER's tree and never find
1231
+ // turbine-orm's package.json (printing no version number at all).
1232
+ let entry = process.argv[1] ?? '';
1233
+ try {
1234
+ entry = realpathSync(entry);
1235
+ }
1236
+ catch {
1237
+ // keep the raw path if realpath fails (e.g. deleted cwd)
1238
+ }
1239
+ let dir = dirname(entry);
1218
1240
  for (let i = 0; i < 6; i++) {
1219
1241
  const candidate = resolve(dir, 'package.json');
1220
1242
  if (existsSync(candidate)) {
@@ -1262,7 +1284,7 @@ async function main() {
1262
1284
  const configPath = findConfigFile();
1263
1285
  if (needsTsLoader(configPath)) {
1264
1286
  const status = await registerTsLoader();
1265
- if (status === 'missing' || status === 'unsupported') {
1287
+ if (status === 'missing' || status === 'unsupported' || status === 'failed') {
1266
1288
  failMissingTsLoader(configPath ?? 'turbine.config.ts', status);
1267
1289
  }
1268
1290
  }
@@ -8,9 +8,18 @@
8
8
  *
9
9
  * Strategy:
10
10
  * 1. If the file we're about to import ends in `.ts` / `.mts` / `.cts`,
11
- * probe whether `tsx/esm` is resolvable from the user's CWD.
12
- * 2. If yes, call `module.register('tsx/esm', ...)` ONCE per process.
13
- * 3. If no, surface an actionable error telling the user to install `tsx`.
11
+ * probe whether `tsx` is resolvable from the user's CWD.
12
+ * 2. Prefer tsx's supported programmatic API, `tsx/esm/api`'s `register()`.
13
+ * Calling Node's `module.register('tsx/esm', ...)` directly throws
14
+ * "tsx must be loaded with --import instead of --loader" on every Node
15
+ * version that has `module.register()` (>= 20.6) — tsx's hook file
16
+ * guards against being loaded that way. The `tsx/esm/api` entry point
17
+ * is the documented path and works everywhere `module.register()` does.
18
+ * 3. Fall back to `module.register('tsx/esm', ...)` only for very old tsx
19
+ * versions (< 4.0) that predate `tsx/esm/api`.
20
+ * 4. If tsx isn't installed, or registration genuinely fails, surface an
21
+ * actionable error — including the REAL underlying error message, never
22
+ * a misdiagnosed "tsx is not installed".
14
23
  *
15
24
  * `tsx` is intentionally NOT a runtime dependency — many projects already
16
25
  * have it, and adding a heavy dev tool to a 1-dependency ORM would be silly.
@@ -28,7 +37,12 @@ export declare function needsTsLoader(filePath: string | null | undefined): bool
28
37
  * Accepts an injected `resolver` so unit tests don't need a real filesystem.
29
38
  */
30
39
  export declare function canResolveTsx(resolver?: (id: string) => string): boolean;
31
- export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing';
40
+ export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing' | 'failed';
41
+ /**
42
+ * The underlying error message from the last failed registration attempt,
43
+ * or null. Lets the CLI report the REAL cause instead of guessing.
44
+ */
45
+ export declare function getTsLoaderError(): string | null;
32
46
  /**
33
47
  * Register the tsx ESM loader so subsequent dynamic imports of `.ts` files
34
48
  * work. Safe to call multiple times — internal flag prevents double registration.
@@ -36,8 +50,11 @@ export type TsLoaderStatus = 'registered' | 'already' | 'unsupported' | 'missing
36
50
  * Returns:
37
51
  * - 'registered' loader was successfully registered this call
38
52
  * - 'already' a loader was previously registered (idempotent)
39
- * - 'unsupported' Node lacks `module.register()` (Node < 20.6)
53
+ * - 'unsupported' Node lacks `module.register()` (Node < 20.6) and tsx has
54
+ * no programmatic API to fall back to
40
55
  * - 'missing' `tsx` is not installed in the user's project
56
+ * - 'failed' tsx IS installed but registration threw — see
57
+ * {@link getTsLoaderError} for the underlying message
41
58
  */
42
59
  export declare function registerTsLoader(): Promise<TsLoaderStatus>;
43
60
  /** Reset the loader state — used by unit tests only. */