Repository: lightdash/lightdash
Pull request #14843: feat: download csv from async query results
Author: login: almeidabbm
Labels: name: released
Created: 2025-05-15T17:43:10Z | Merged: 2025-05-23T16:07:51Z

<!-- Thanks so much for your PR, your contribution is appreciated! ❤️ -->

Closes: [Endpoint to download CSV from query uuid](https://github.com/lightdash/lightdash/issues/14702)

### Description:

- Adds new endpoint to download async query results as csv `GET /api/v2/project/{projectUuid}/query/{queryUuid}/csv`
test-backend

### Reviewer actions

- [ ] I have manually tested the changes in the preview environment
- [ ] I have reviewed the code
- [ ] I understand that "request changes" will block this PR from merging



Diff:
diff --git a/packages/backend/src/clients/ResultsFileStorageClients/S3ResultsFileStorageClient.ts b/packages/backend/src/clients/ResultsFileStorageClients/S3ResultsFileStorageClient.ts
index e0c1310e9a4f2..605a0a22af8d4 100644
--- a/packages/backend/src/clients/ResultsFileStorageClients/S3ResultsFileStorageClient.ts
+++ b/packages/backend/src/clients/ResultsFileStorageClients/S3ResultsFileStorageClient.ts
@@ -2,7 +2,7 @@ import { GetObjectCommand, NotFound } from '@aws-sdk/client-s3';
 import { Upload } from '@aws-sdk/lib-storage';
 import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
 import { getErrorMessage, WarehouseResults } from '@lightdash/common';
-import { PassThrough, Readable } from 'stream';
+import { once, PassThrough, Readable, Writable } from 'stream';
 import Logger from '../../logging/logger';
 import {
     S3CacheClient,
@@ -19,17 +19,22 @@ export class S3ResultsFileStorageClient extends S3CacheClient {
         this.s3ExpiresIn = lightdashConfig.s3.expirationTime;
     }
 
-    createUploadStream(cacheKey: string) {
+    createUploadStream(
+        fileName: string,
+        opts: {
+            contentType: string;
+        },
+    ) {
         const passThrough = new PassThrough();
 
         const upload = new Upload({
             client: this.s3,
             params: {
                 Bucket: this.configuration.bucket,
-                Key: `${cacheKey}.jsonl`,
+                Key: fileName,
                 Body: passThrough,
-                ContentType: 'application/jsonl',
-                ContentDisposition: `attachment; filename="${cacheKey}.jsonl"`,
+                ContentType: opts.contentType,
+                ContentDisposition: `attachment; filename="${fileName}"`,
             },
         });
 
@@ -41,13 +46,13 @@ export class S3ResultsFileStorageClient extends S3CacheClient {
                 passThrough.end(); // signal EOF
                 await upload.done(); // wait for upload to finish
                 Logger.debug(
-                    `Successfully closed upload stream to s3://${this.configuration.bucket}/${cacheKey}.jsonl`,
+                    `Successfully closed upload stream to s3://${this.configuration.bucket}/${fileName}`,
                 );
             } catch (error) {
                 Logger.error(
                     `Error closing upload stream to s3://${
                         this.configuration.bucket
-                    }/${cacheKey}.jsonl: ${getErrorMessage(error)}`,
+                    }/${fileName}: ${getErrorMessage(error)}`,
                 );
                 throw error;
             }
@@ -61,7 +66,7 @@ export class S3ResultsFileStorageClient extends S3CacheClient {
                 );
             } catch (error) {
                 Logger.error(
-                    `Failed to write rows to cache key ${cacheKey}: ${getErrorMessage(
+                    `Failed to write rows to fileName ${fileName}: ${getErrorMessage(
                         error,
                     )}`,
                 );
@@ -69,10 +74,7 @@ export class S3ResultsFileStorageClient extends S3CacheClient {
             }
         };
 
-        return {
-            write,
-            close,
-        };
+        return { write, close, writeStream: passThrough };
     }
 
     async getDowloadStream(
@@ -98,11 +100,18 @@ export class S3ResultsFileStorageClient extends S3CacheClient {
     }
 
     async getFileUrl(cacheKey: string, fileExtension = 'jsonl') {
+        // Check if the cacheKey already has the specified file extension
+        const hasExtension = cacheKey
+            .toLowerCase()
+            .endsWith(`.${fileExtension.toLowerCase()}`);
+        const key = hasExtension ? cacheKey : `${cacheKey}.${fileExtension}`;
+
+        // Get the S3 URL
         const url = await getSignedUrl(
             this.s3,
             new GetObjectCommand({
                 Bucket: this.configuration.bucket,
-                Key: `${cacheKey}.${fileExtension}`,
+                Key: key,
             }),
             {
                 expiresIn: this.s3ExpiresIn,
@@ -111,4 +120,41 @@ export class S3ResultsFileStorageClient extends S3CacheClient {
 
         return url;
     }
+
+    async transformResultsIntoNewFile(
+        sourceResultsFileWithoutExtension: string,
+        sinkFileName: string,
+        streamProcessor: (
+            readStream: Readable,
+            writeStream: Writable,
+        ) => Promise<{ truncated: boolean }>,
+    ): Promise<{ fileUrl: string; truncated: boolean }> {
+        // Determine content type based on file extension
+        const isCsv = sinkFileName.toLowerCase().endsWith('.csv');
+        const contentType = isCsv ? 'text/csv' : 'application/jsonl';
+
+        // Create upload stream
+        const { writeStream, close } = this.createUploadStream(sinkFileName, {
+            contentType,
+        });
+
+        // Get the results stream
+        const resultsStream = await this.getDowloadStream(
+            sourceResultsFileWithoutExtension,
+        );
+
+        // Process the stream
+        const { truncated } = await streamProcessor(resultsStream, writeStream);
+
+        await close();
+
+        // Extract extension from filename to ensure correct URL generation
+        const extension = isCsv ? 'csv' : 'jsonl';
+        const url = await this.getFileUrl(sinkFileName, extension);
+
+        return {
+            fileUrl: url,
+            truncated,
+        };
+    }
 }
diff --git a/packages/backend/src/controllers/v2/QueryController.ts b/packages/backend/src/controllers/v2/QueryController.ts
index cef8510abf28d..b9c84e8abadb2 100644
--- a/packages/backend/src/controllers/v2/QueryController.ts
+++ b/packages/backend/src/controllers/v2/QueryController.ts
@@ -7,11 +7,14 @@ import {
     ApiGetAsyncQueryResults,
     ApiSuccess,
     ApiSuccessEmpty,
+    assertUnreachable,
+    DownloadFileType,
     ExecuteAsyncSqlQueryRequestParams,
     isExecuteAsyncDashboardSqlChartByUuidParams,
     isExecuteAsyncSqlChartByUuidParams,
     QueryExecutionContext,
     type ApiDownloadAsyncQueryResults,
+    type ApiDownloadAsyncQueryResultsAsCsv,
     type ApiExecuteAsyncMetricQueryResults,
     type ExecuteAsyncDashboardChartRequestParams,
     type ExecuteAsyncDashboardSqlChartRequestParams,
@@ -402,7 +405,12 @@ export class QueryController extends BaseController {
         @Path() projectUuid: string,
         @Path() queryUuid: string,
         @Request() req: express.Request,
-    ): Promise<ApiSuccess<ApiDownloadAsyncQueryResults>> {
+        @Query() type: DownloadFileType = DownloadFileType.CSV,
+    ): Promise<
+        ApiSuccess<
+            ApiDownloadAsyncQueryResults | ApiDownloadAsyncQueryResultsAsCsv
+        >
+    > {
         this.setStatus(200);
 
         const results = await this.services
@@ -411,6 +419,7 @@ export class QueryController extends BaseController {
                 user: req.user!,
                 projectUuid,
                 queryUuid,
+                type,
             });
 
         return {
diff --git a/packages/backend/src/generated/routes.ts b/packages/backend/src/generated/routes.ts
index a71a50b101a6f..1e8b595a98b64 100644
--- a/packages/backend/src/generated/routes.ts
+++ b/packages/backend/src/generated/routes.ts
@@ -11152,7 +11152,7 @@ const models: TsoaRoute.Models = {
         },
     },
     // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
-    'PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__':
+    'PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__':
         {
             dataType: 'refAlias',
             type: {
@@ -11162,7 +11162,7 @@ const models: TsoaRoute.Models = {
             },
         },
     // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
-    'PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__':
+    'PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__':
         {
             dataType: 'refAlias',
             type: {
@@ -11172,7 +11172,7 @@ const models: TsoaRoute.Models = {
                         dataType: 'union',
                         subSchemas: [
                             {
-                                ref: 'PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__',
+                                ref: 'PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__',
                             },
                             { dataType: 'undefined' },
                         ],
@@ -11185,7 +11185,7 @@ const models: TsoaRoute.Models = {
     'PartialDeep_DashboardAsCodeLanguageMap._recurseIntoArrays-true__': {
         dataType: 'refAlias',
         type: {
-            ref: 'PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__',
+            ref: 'PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__',
             validators: {},
         },
     },
@@ -15790,21 +15790,43 @@ const models: TsoaRoute.Models = {
         },
     },
     // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
-    ApiSuccess_ApiDownloadAsyncQueryResults_: {
+    ApiDownloadAsyncQueryResultsAsCsv: {
         dataType: 'refAlias',
         type: {
             dataType: 'nestedObjectLiteral',
             nestedProperties: {
-                results: {
-                    ref: 'ApiDownloadAsyncQueryResults',
-                    required: true,
-                },
-                status: { dataType: 'enum', enums: ['ok'], required: true },
+                truncated: { dataType: 'boolean', required: true },
+                fileUrl: { dataType: 'string', required: true },
             },
             validators: {},
         },
     },
     // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
+    'ApiSuccess_ApiDownloadAsyncQueryResults-or-ApiDownloadAsyncQueryResultsAsCsv_':
+        {
+            dataType: 'refAlias',
+            type: {
+                dataType: 'nestedObjectLiteral',
+                nestedProperties: {
+                    results: {
+                        dataType: 'union',
+                        subSchemas: [
+                            { ref: 'ApiDownloadAsyncQueryResults' },
+                            { ref: 'ApiDownloadAsyncQueryResultsAsCsv' },
+                        ],
+                        required: true,
+                    },
+                    status: { dataType: 'enum', enums: ['ok'], required: true },
+                },
+                validators: {},
+            },
+        },
+    // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
+    DownloadFileType: {
+        dataType: 'refEnum',
+        enums: ['csv', 'image', 'jsonl', 's3_jsonl'],
+    },
+    // WARNING: This file was auto-generated with tsoa. Please do not modify it. Re-run tsoa to re-generate this file: https://github.com/lukeautry/tsoa
     FeatureFlag: {
         dataType: 'refAlias',
         type: {
@@ -32574,6 +32596,12 @@ export function RegisterRoutes(app: Router) {
             dataType: 'string',
         },
         req: { in: 'request', name: 'req', required: true, dataType: 'object' },
+        type: {
+            default: 'csv',
+            in: 'query',
+            name: 'type',
+            ref: 'DownloadFileType',
+        },
     };
     app.get(
         '/api/v2/projects/:projectUuid/query/:queryUuid/download',
diff --git a/packages/backend/src/generated/swagger.json b/packages/backend/src/generated/swagger.json
index 24f1f07d0626b..f246f392265aa 100644
--- a/packages/backend/src/generated/swagger.json
+++ b/packages/backend/src/generated/swagger.json
@@ -12065,22 +12065,22 @@
                     }
                 ]
             },
-            "PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__": {
+            "PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__": {
                 "properties": {},
                 "type": "object",
                 "description": "Same as `PartialDeep`, but accepts only `object`s as inputs. Internal helper for `PartialDeep`."
             },
-            "PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__": {
+            "PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__": {
                 "properties": {
                     "dashboard": {
-                        "$ref": "#/components/schemas/PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__"
+                        "$ref": "#/components/schemas/PartialObjectDeep___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined__._recurseIntoArrays-true__"
                     }
                 },
                 "type": "object",
                 "description": "Same as `PartialDeep`, but accepts only `object`s as inputs. Internal helper for `PartialDeep`."
             },
             "PartialDeep_DashboardAsCodeLanguageMap._recurseIntoArrays-true__": {
-                "$ref": "#/components/schemas/PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__chartName-string--title-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__content-string--title-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__",
+                "$ref": "#/components/schemas/PartialObjectDeep__dashboard_58___91_x-string_93__58__name_63_-string-or-undefined--description_63_-string-or-undefined--tiles_63__58__40__type-DashboardTileTypes.SAVED_CHART-or-DashboardTileTypes.SQL_CHART-or-DashboardTileTypes.SEMANTIC_VIEWER_CHART--properties_58__title-string--chartName-string__-or-_type-DashboardTileTypes.MARKDOWN--properties_58__title-string--content-string__-or-_type-DashboardTileTypes.LOOM--properties_58__title-string___41_-Array-or-undefined___._recurseIntoArrays-true__",
                 "description": "Create a type from another type with all keys and nested keys set to optional.\n\nUse-cases:\n- Merging a default settings/config object with another object, the second object would be a deep partial of the default object.\n- Mocking and testing complex entities, where populating an entire object with its keys would be redundant in terms of the mock or test."
             },
             "ApiDashboardAsCodeListResponse": {
@@ -16668,10 +16668,29 @@
                 "required": ["fileUrl"],
                 "type": "object"
             },
-            "ApiSuccess_ApiDownloadAsyncQueryResults_": {
+            "ApiDownloadAsyncQueryResultsAsCsv": {
+                "properties": {
+                    "truncated": {
+                        "type": "boolean"
+                    },
+                    "fileUrl": {
+                        "type": "string"
+                    }
+                },
+                "required": ["truncated", "fileUrl"],
+                "type": "object"
+            },
+            "ApiSuccess_ApiDownloadAsyncQueryResults-or-ApiDownloadAsyncQueryResultsAsCsv_": {
                 "properties": {
                     "results": {
-                        "$ref": "#/components/schemas/ApiDownloadAsyncQueryResults"
+                        "anyOf": [
+                            {
+                                "$ref": "#/components/schemas/ApiDownloadAsyncQueryResults"
+                            },
+                            {
+                                "$ref": "#/components/schemas/ApiDownloadAsyncQueryResultsAsCsv"
+                            }
+                        ]
                     },
                     "status": {
                         "type": "string",
@@ -16682,6 +16701,10 @@
                 "required": ["results", "status"],
                 "type": "object"
             },
+            "DownloadFileType": {
+                "enum": ["csv", "image", "jsonl", "s3_jsonl"],
+                "type": "string"
+            },
             "FeatureFlag": {
                 "properties": {
                     "enabled": {
diff --git a/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts b/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts
index f81d6edf8a68e..a6674be951541 100644
--- a/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts
+++ b/packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts
@@ -2,6 +2,7 @@ import { subject } from '@casl/ability';
 import {
     addDashboardFiltersToMetricQuery,
     type ApiDownloadAsyncQueryResults,
+    type ApiDownloadAsyncQueryResultsAsCsv,
     ApiExecuteAsyncDashboardChartQueryResults,
     ApiExecuteAsyncDashboardSqlChartQueryResults,
     type ApiExecuteAsyncMetricQueryResults,
@@ -11,12 +12,14 @@ import {
     CompiledDimension,
     convertCustomFormatToFormatExpression,
     convertFieldRefToFieldId,
+    convertItemTypeToDimensionType,
     createVirtualView as createVirtualViewObject,
     CreateWarehouseCredentials,
     type CustomDimension,
     DashboardFilters,
     DEFAULT_RESULTS_PAGE_SIZE,
     DimensionType,
+    DownloadFileType,
     type ExecuteAsyncDashboardChartRequestParams,
     type ExecuteAsyncMetricQueryRequestParams,
     type ExecuteAsyncQueryRequestParams,
@@ -30,9 +33,11 @@ import {
     getDashboardFilterRulesForTileAndReferences,
     getDimensions,
     getErrorMessage,
+    getFieldLabel,
     getFieldQuoteChar,
     getIntrinsicUserAttributes,
     getItemId,
+    getItemLabel,
     getItemMap,
     GroupByColumn,
     isCartesianChartConfig,
@@ -90,6 +95,7 @@ import {
     MissCacheResult,
     ResultsCacheStatus,
 } from '../CacheService/types';
+import { CsvService } from '../CsvService/CsvService';
 import {
     ProjectService,
     type ProjectServiceArguments,
@@ -101,6 +107,7 @@ import {
 import { getPivotedColumns } from './getPivotedColumns';
 import { getUnpivotedColumns } from './getUnpivotedColumns';
 import {
+    type DownloadAsyncQueryResultsArgs,
     type ExecuteAsyncDashboardChartQueryArgs,
     type ExecuteAsyncDashboardSqlChartArgs,
     type ExecuteAsyncMetricQueryArgs,
@@ -231,8 +238,12 @@ export class AsyncQueryService extends ProjectService {
         }
 
         // Create upload stream for storing results
-        const { write, close } =
-            this.storageClient.createUploadStream(cacheKey);
+        const { write, close } = this.storageClient.createUploadStream(
+            `${cacheKey}.jsonl`,
+            {
+                contentType: 'application/jsonl',
+            },
+        );
 
         const now = new Date();
         const newExpiresAt = this.getCacheExpiresAt(now);
@@ -694,11 +705,10 @@ export class AsyncQueryService extends ProjectService {
         user,
         projectUuid,
         queryUuid,
-    }: {
-        user: SessionUser;
-        projectUuid: string;
-        queryUuid: string;
-    }): Promise<ApiDownloadAsyncQueryResults> {
+        type,
+    }: DownloadAsyncQueryResultsArgs): Promise<
+        ApiDownloadAsyncQueryResults | ApiDownloadAsyncQueryResultsAsCsv
+    > {
         if (!isUserWithOrg(user)) {
             throw new ForbiddenError('User is not part of an organization');
         }
@@ -727,28 +737,102 @@ export class AsyncQueryService extends ProjectService {
             );
         }
 
-        const { status, cacheKey: resultsFileName } = queryHistory;
+        const { status, cacheKey: resultsFileName, fields } = queryHistory;
 
-        if (status === QueryHistoryStatus.ERROR) {
-            throw new Error(queryHistory.error ?? 'Warehouse query failed');
+        // First check the query status
+        switch (status) {
+            case QueryHistoryStatus.CANCELLED:
+                throw new Error('Query was cancelled');
+            case QueryHistoryStatus.ERROR:
+                throw new Error(queryHistory.error ?? 'Warehouse query failed');
+            case QueryHistoryStatus.PENDING:
+                throw new Error('Query is in pending state');
+            case QueryHistoryStatus.READY:
+                // Continue with execution
+                break;
+            default:
+                return assertUnreachable(status, 'Unknown query status');
         }
 
-        if (status === QueryHistoryStatus.PENDING) {
-            throw new Error('Query is in pending state');
+        // At this point, we know status is READY
+        if (!resultsFileName) {
+            throw new Error('Results file name not found for query');
         }
 
-        if (status === QueryHistoryStatus.READY) {
-            if (!resultsFileName) {
-                throw new Error('Results file name not found for query');
-            }
+        // Handle file type in a separate switch
+        switch (type) {
+            case DownloadFileType.CSV:
+                return this.downloadAsyncQueryResultsAsCsv(
+                    resultsFileName,
+                    projectUuid,
+                    fields,
+                );
+            case undefined:
+            case DownloadFileType.JSONL:
+                return this.downloadAsyncQueryResultsAsJson(resultsFileName);
+            case DownloadFileType.S3_JSONL:
+                throw new Error('S3_JSONL download not supported yet');
+            case DownloadFileType.IMAGE:
+                throw new Error(
+                    'IMAGE download not supported for query results',
+                );
+            default:
+                return assertUnreachable(
+                    type,
+                    `Unsupported file type: ${type}`,
+                );
+        }
+    }
 
-            return {
-                fileUrl: await this.storageClient.getFileUrl(resultsFileName),
-                // TODO: add columns here once they're saved to query_history
-            };
+    private async downloadAsyncQueryResultsAsCsv(
+        resultsFileName: string,
+        projectUuid: string,
+        fields: ItemsMap,
+    ): Promise<ApiDownloadAsyncQueryResultsAsCsv> {
+        // Get the results
+        const resultsFile = await this.findCache(resultsFileName, projectUuid);
+
+        if (!resultsFile) {
+            throw new Error('Results file not found for query');
         }
 
-        throw new Error('Invalid query status');
+        // Generate a unique filename
+        const csvFileName = CsvService.generateFileId(resultsFileName);
+
+        const csvHeaders = Object.keys(fields).map((field) =>
+            getItemLabel(fields[field]),
+        );
+
+        // Transform and upload the results
+        return this.storageClient.transformResultsIntoNewFile(
+            resultsFileName,
+            csvFileName,
+            async (readStream, writeStream) => {
+                // Use streamJsonlRowsToFile which handles JSONL data from S3
+                const { truncated } = await CsvService.streamJsonlRowsToFile(
+                    false,
+                    fields,
+                    Object.keys(fields), // TODO: sorted field ids
+                    csvHeaders,
+                    {
+                        readStream,
+                        writeStream,
+                    },
+                );
+
+                return {
+                    truncated,
+                };
+            },
+        );
+    }
+
+    private async downloadAsyncQueryResultsAsJson(
+        resultsFileName: string,
+    ): Promise<ApiDownloadAsyncQueryResults> {
+        return {
+            fileUrl: await this.storageClient.getFileUrl(resultsFileName),
+        };
     }
 
     /**
diff --git a/packages/backend/src/services/AsyncQueryService/types.ts b/packages/backend/src/services/AsyncQueryService/types.ts
index 28e8c405c917a..fbb69fbbe4542 100644
--- a/packages/backend/src/services/AsyncQueryService/types.ts
+++ b/packages/backend/src/services/AsyncQueryService/types.ts
@@ -1,4 +1,5 @@
 import {
+    DownloadFileType,
     GroupByColumn,
     ItemsMap,
     MetricQuery,
@@ -36,6 +37,7 @@ export type DownloadAsyncQueryResultsArgs = Omit<
     'invalidateCache' | 'context'
 > & {
     queryUuid: string;
+    type?: DownloadFileType;
 };
 
 export type ExecuteAsyncMetricQueryArgs = CommonAsyncQueryArgs & {
diff --git a/packages/backend/src/services/CsvService/CsvService.ts b/packages/backend/src/services/CsvService/CsvService.ts
index 4cfea3e24e37c..e3bdee93b89bb 100644
--- a/packages/backend/src/services/CsvService/CsvService.ts
+++ b/packages/backend/src/services/CsvService/CsvService.ts
@@ -52,6 +52,7 @@ import * as fsPromise from 'fs/promises';
 import isNil from 'lodash/isNil';
 import moment, { MomentInput } from 'moment';
 import { nanoid } from 'nanoid';
+import { createInterface } from 'readline';
 import {
     pipeline,
     Readable,
@@ -264,7 +265,7 @@ export class CsvService extends BaseService {
         );
     }
 
-    static async streamRowsToFile(
+    static async streamObjectRowsToFile(
         onlyRaw: boolean,
         itemMap: ItemsMap,
         sortedFieldIds: string[],
@@ -319,6 +320,84 @@ export class CsvService extends BaseService {
         });
     }
 
+    /**
+     * Stream S3 JSONL data to CSV file
+     * This method is specifically designed to handle JSONL data from S3 storage
+     * and convert it to CSV format.
+     * Unlike streamRowsToFile, this method expects the readStream to be in string format,
+     * not in object mode.
+     */
+    static async streamJsonlRowsToFile(
+        onlyRaw: boolean,
+        itemMap: ItemsMap,
+        sortedFieldIds: string[],
+        csvHeader: string[],
+        {
+            readStream,
+            writeStream,
+        }: {
+            readStream: Readable;
+            writeStream: Writable;
+        },
+    ): Promise<{ truncated: boolean }> {
+        const stringifier = stringify({
+            delimiter: ',',
+            header: true,
+            columns: csvHeader,
+        });
+
+        return new Promise((resolve, reject) => {
+            // Process the readStream line by line
+            const lineReader = createInterface({
+                input: readStream,
+                crlfDelay: Infinity,
+            });
+
+            let lineCount = 0;
+            const MAX_LINES = 100000; // Configurable limit
+            let truncated = false;
+
+            lineReader.on('line', (line: string) => {
+                if (!line.trim()) return;
+
+                // eslint-disable-next-line no-plusplus
+                lineCount++;
+                if (lineCount > MAX_LINES) {
+                    truncated = true;
+                    lineReader.close();
+                    return;
+                }
+
+                try {
+                    const parsedRow = JSON.parse(line);
+                    const csvRow = CsvService.convertRowToCsv(
+                        parsedRow,
+                        itemMap,
+                        onlyRaw,
+                        sortedFieldIds,
+                    );
+                    stringifier.write(csvRow);
+                } catch (error) {
+                    Logger.error(
+                        `Error processing line ${lineCount}: ${error}`,
+                    );
+                }
+            });
+
+            lineReader.on('close', () => {
+                stringifier.end();
+            });
+
+            pipeline(stringifier, writeStream, (err) => {
+                if (err) {
+                    reject(err);
+                    return;
+                }
+                resolve({ truncated });
+            });
+        });
+    }
+
     static async writeRowsToFile(
         rows: Record<string, AnyType>[],
         onlyRaw: boolean,
@@ -372,7 +451,7 @@ export class CsvService extends BaseService {
             highWaterMark: CHUNK_SIZE,
         });
 
-        await CsvService.streamRowsToFile(
+        await CsvService.streamObjectRowsToFile(
             onlyRaw,
             itemMap,
             sortedFieldIds,
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index fe45918494fd7..7f6841e6c0682 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -592,6 +592,11 @@ export type ApiDownloadAsyncQueryResults = {
     fileUrl: string;
 };
 
+export type ApiDownloadAsyncQueryResultsAsCsv = {
+    fileUrl: string;
+    truncated: boolean;
+};
+
 export type ApiChartAndResults = {
     chart: SavedChart;
     explore: Explore;
diff --git a/packages/common/src/types/api/paginatedQuery.ts b/packages/common/src/types/api/paginatedQuery.ts
index 32948c796bd8a..9e36e06d8c6d7 100644
--- a/packages/common/src/types/api/paginatedQuery.ts
+++ b/packages/common/src/types/api/paginatedQuery.ts
@@ -109,6 +109,10 @@ export const isExecuteAsyncDashboardSqlChartByUuidParams = (
 ): params is ExecuteAsyncDashboardSqlChartByUuidRequestParams =>
     'savedSqlUuid' in params;
 
+export type DownloadAsyncQueryResultsRequestParams = {
+    queryUuid: string;
+};
+
 export type ExecuteAsyncQueryRequestParams =
     | ExecuteAsyncMetricQueryRequestParams
     | ExecuteAsyncSqlQueryRequestParams


Reviews:
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T21:58:46Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-15T22:11:28Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T12:20:09Z
user: login: ZeRego
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-23T12:30:32Z
user: login: ZeRego
body: lgtm 👍 needs 1 small fix on cache key extension check.
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-23T12:31:38Z

Review comments:
diff_hunk: @@ -380,4 +381,30 @@ export class QueryController extends BaseController {
             results,
         };
     }
+
+    @Hidden()
+    @Middlewares([allowApiKeyAuthentication, isAuthenticated])
+    @SuccessResponse('200', 'Success')
+    @Get('/{queryUuid}/csv')
path: packages/backend/src/controllers/v2/QueryController.ts
user: login: ZeRego
body: I was expecting this to be under /download so we could support more download types(eg. excel, json) without new endpoints. 
created_at: 2025-05-15T21:58:46Z
author_association: COLLABORATOR
diff_hunk: @@ -625,6 +630,92 @@ export class AsyncQueryService extends ProjectService {
         };
     }
 
+    async downloadAsyncQueryResultsAsCsv({
+        user,
+        projectUuid,
+        queryUuid,
+    }: DownloadAsyncQueryResultsArgs): Promise<ApiDownloadAsyncQueryResultsAsCsv> {
+        if (!isUserWithOrg(user)) {
+            throw new ForbiddenError('User is not part of an organization');
+        }
+        const { organizationUuid } =
+            await this.projectModel.getWithSensitiveFields(projectUuid);
+
+        const queryHistory = await this.queryHistoryModel.get(
+            queryUuid,
+            projectUuid,
+            user.userUuid,
+        );
+
+        if (
+            user.ability.cannot(
+                'view',
+                subject('Project', { organizationUuid, projectUuid }),
+            )
+        ) {
+            throw new ForbiddenError();
+        }
+
+        if (user.userUuid !== queryHistory.createdByUserUuid) {
+            throw new ForbiddenError(
+                'User is not allowed to download results for this query',
+            );
+        }
+
+        const { status, cacheKey: resultsFileName, fields } = queryHistory;
+
+        switch (status) {
+            case QueryHistoryStatus.CANCELLED:
+                throw new Error('Query was cancelled');
+            case QueryHistoryStatus.ERROR:
+                throw new Error(queryHistory.error ?? 'Warehouse query failed');
+            case QueryHistoryStatus.PENDING:
+                throw new Error('Query is in pending state');
+            case QueryHistoryStatus.READY:
+                if (!resultsFileName) {
+                    throw new Error('Results file name not found for query');
+                }
+
+                // Get the results
+                const resultsFile = await this.findCache(
+                    resultsFileName,
+                    projectUuid,
+                );
+
+                if (!resultsFile) {
+                    throw new Error('Results file not found for query');
+                }
+
+                // Generate a unique filename
+                const csvFileName = CsvService.generateFileId(resultsFileName);
+
+                // Transform and upload the results
+                return this.storageClient.transformResultsIntoNewFile(
+                    resultsFileName,
+                    csvFileName,
+                    async (readStream, writeStream) => {
+                        // Need to find a way to pass the csv header
+                        await CsvService.streamRowsToFile(
+                            false,
+                            fields,
+                            [], // TODO: sorted field ids
+                            [], // TODO: csv header
+                            {
+                                readStream,
+                                writeStream,
+                            },
+                        );
+
+                        return {
+                            truncated: false, // TODO: when is file truncated?
path: packages/backend/src/services/AsyncQueryService/AsyncQueryService.ts
user: login: ZeRego
body: I believe it was used when they reached the cell limit for CSVS. 
created_at: 2025-05-15T22:11:27Z
author_association: COLLABORATOR
diff_hunk: @@ -98,11 +100,16 @@ export class S3ResultsFileStorageClient extends S3CacheClient {
     }
 
     async getFileUrl(cacheKey: string, fileExtension = 'jsonl') {
+        // Check if the cacheKey already has a file extension
+        const hasExtension = cacheKey.includes('.');
path: packages/backend/src/clients/ResultsFileStorageClients/S3ResultsFileStorageClient.ts
user: login: ZeRego
body: Cache keys always have a `.` 
```
const queryHashKey = resultsIdentifiers.timezone
            ? `${projectUuid}.${resultsIdentifiers.sql}.${resultsIdentifiers.timezone}`
            : `${projectUuid}.${resultsIdentifiers.sql}`;
```

Should we check if the cacheKey ends with `.${fileExtension}`?
created_at: 2025-05-23T12:20:08Z
author_association: COLLABORATOR
diff_hunk: @@ -319,6 +320,84 @@ export class CsvService extends BaseService {
         });
     }
 
+    /**
+     * Stream S3 JSONL data to CSV file
+     * This method is specifically designed to handle JSONL data from S3 storage
+     * and convert it to CSV format.
+     * Unlike streamRowsToFile, this method expects the readStream to be in string format,
+     * not in object mode.
+     */
+    static async streamS3DataRowsToFile(
path: packages/backend/src/services/CsvService/CsvService.ts
user: login: ZeRego
body: [non blocker] I would have named this `streamJsonlRowsToFile`.

Main reason being the method doesn't need to know/care about 'S3' only that the rows are 'JSONL' format.
created_at: 2025-05-23T12:30:31Z
author_association: COLLABORATOR

PR comments:
user: login: almeidabbm
created_at: 2025-05-15T17:43:27Z
body: * **#14843** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14843?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14843?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#14842** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14842?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#14841** <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14841?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>: 1 other dependent PR ([#14844](https://github.com/lightdash/lightdash/pull/14844) <a href="https://app.graphite.dev/github/pr/lightdash/lightdash/14844?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>)
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: lightdash-bot
created_at: 2025-05-23T16:10:43Z
body: :tada: This PR is included in version 0.1648.0 :tada:

The release is available on:
- `0.1648.0`
- [GitHub release](https://github.com/lightdash/lightdash/releases/tag/0.1648.0)

Your **[semantic-release](https://github.com/semantic-release/semantic-release)** bot :package::rocket:
author_association: COLLABORATOR