11import { AuditAction , AuditResourceType , recordAudit } from '@sim/audit'
2- import { createLogger } from '@sim/logger'
32import { type NextRequest , NextResponse } from 'next/server'
43import { tableExportFormatSchema , tableIdParamsSchema } from '@/lib/api/contracts/tables'
54import { getValidationErrorMessage } from '@/lib/api/server'
65import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
7- import { neutralizeCsvFormula } from '@/lib/core/utils/csv'
86import { generateRequestId } from '@/lib/core/utils/request'
97import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
108import { captureServerEvent } from '@/lib/posthog/server'
11- import { namedRowMapper } from '@/lib/table/cell-format'
12- import { getColumnId } from '@/lib/table/column-keys'
13- import { formatCsvCell } from '@/lib/table/export-format'
14- import { queryRows } from '@/lib/table/rows/service'
9+ import {
10+ createTableExportStream ,
11+ exportContentType ,
12+ sanitizeExportFilename ,
13+ } from '@/lib/table/export-stream'
1514import { accessError , checkAccess } from '@/app/api/table/utils'
1615
17- const logger = createLogger ( 'TableExport' )
18-
19- const EXPORT_BATCH_SIZE = 1000
20-
21- type ExportFormat = 'csv' | 'json'
22-
2316interface RouteParams {
2417 params : Promise < { tableId : string } >
2518}
@@ -45,19 +38,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
4538 { status : 400 }
4639 )
4740 }
48- const format : ExportFormat = formatValidation . data
41+ const format = formatValidation . data
4942
5043 const access = await checkAccess ( tableId , auth . userId , 'read' )
5144 if ( ! access . ok ) return accessError ( access , requestId , tableId )
5245 const { table } = access
5346
54- const columns = table . schema . columns
55- // Stored row data is id-keyed; CSV headers and JSON keys are display names, so
56- // translate id → name on the way out (export is a name-friendly boundary).
57- const toNamedRow = namedRowMapper ( columns )
58- const safeName = sanitizeFilename ( table . name )
59- const filename = `${ safeName } .${ format } `
60-
6147 // Audit before streaming: rows leave incrementally, so a mid-stream failure still exfiltrates partial data.
6248 recordAudit ( {
6349 workspaceId : table . workspaceId ?? null ,
@@ -79,80 +65,12 @@ export const GET = withRouteHandler(async (request: NextRequest, { params }: Rou
7965 )
8066 }
8167
82- const stream = new ReadableStream < Uint8Array > ( {
83- async start ( controller ) {
84- const encoder = new TextEncoder ( )
85- try {
86- if ( format === 'csv' ) {
87- controller . enqueue (
88- encoder . encode ( `${ toCsvRow ( columns . map ( ( c ) => neutralizeCsvFormula ( c . name ) ) ) } \n` )
89- )
90- } else {
91- controller . enqueue ( encoder . encode ( '[' ) )
92- }
93-
94- let offset = 0
95- let firstJsonRow = true
96- while ( true ) {
97- const result = await queryRows (
98- table ,
99- { limit : EXPORT_BATCH_SIZE , offset, includeTotal : false } ,
100- requestId
101- )
102-
103- for ( const row of result . rows ) {
104- if ( format === 'csv' ) {
105- const values = columns . map ( ( c ) => formatCsvCell ( c , row . data [ getColumnId ( c ) ] ) )
106- controller . enqueue ( encoder . encode ( `${ toCsvRow ( values ) } \n` ) )
107- } else {
108- const prefix = firstJsonRow ? '' : ','
109- firstJsonRow = false
110- controller . enqueue ( encoder . encode ( prefix + JSON . stringify ( toNamedRow ( row . data ) ) ) )
111- }
112- }
113-
114- // A page can be cut by the byte budget before reaching EXPORT_BATCH_SIZE,
115- // so a short page does NOT mean the export is done — only a null cursor does.
116- if ( ! result . nextCursor ) break
117- offset += result . rows . length
118- }
119-
120- if ( format === 'json' ) controller . enqueue ( encoder . encode ( ']' ) )
121- controller . close ( )
122-
123- logger . info ( `[${ requestId } ] Exported table ${ tableId } ` , {
124- format,
125- rowCount : table . rowCount ,
126- } )
127- } catch ( err ) {
128- logger . error ( `[${ requestId } ] Export failed for table ${ tableId } ` , err )
129- controller . error ( err )
130- }
131- } ,
132- } )
133-
134- return new NextResponse ( stream , {
68+ return new NextResponse ( createTableExportStream ( table , format , requestId ) , {
13569 status : 200 ,
13670 headers : {
137- 'Content-Type' : format === 'csv' ? 'text/csv; charset=utf-8' : 'application/json' ,
138- 'Content-Disposition' : `attachment; filename="${ filename } "` ,
71+ 'Content-Type' : exportContentType ( format ) ,
72+ 'Content-Disposition' : `attachment; filename="${ sanitizeExportFilename ( table . name ) } . ${ format } "` ,
13973 'Cache-Control' : 'no-store' ,
14074 } ,
14175 } )
14276} )
143-
144- function sanitizeFilename ( name : string ) : string {
145- const cleaned = name . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] + / g, '_' ) . replace ( / ^ _ + | _ + $ / g, '' )
146- return cleaned || 'table'
147- }
148-
149- function toCsvRow ( values : string [ ] ) : string {
150- return values . map ( escapeCsvField ) . join ( ',' )
151- }
152-
153- function escapeCsvField ( field : string ) : string {
154- if ( / [ " , \n \r ] / . test ( field ) ) {
155- return `"${ field . replace ( / " / g, '""' ) } "`
156- }
157- return field
158- }
0 commit comments