@@ -235,6 +235,39 @@ function parseCsvToRows(csv: string, mapping: Record<string, string> = {}): Arra
235235 return out ;
236236}
237237
238+ /**
239+ * Escape a single value into an RFC-4180 CSV cell. Values containing
240+ * commas, quotes, CR, or LF are wrapped in double-quotes with embedded
241+ * quotes doubled. `null` / `undefined` become an empty cell. Objects and
242+ * arrays are serialised as compact JSON so nested data round-trips
243+ * without flattening surprises.
244+ */
245+ function formatCsvCell ( value : any ) : string {
246+ if ( value === null || value === undefined ) return '' ;
247+ let s : string ;
248+ if ( typeof value === 'string' ) s = value ;
249+ else if ( typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint' ) s = String ( value ) ;
250+ else if ( value instanceof Date ) s = value . toISOString ( ) ;
251+ else { try { s = JSON . stringify ( value ) ; } catch { s = String ( value ) ; } }
252+ if ( / [ " , \r \n ] / . test ( s ) ) {
253+ return `"${ s . replace ( / " / g, '""' ) } "` ;
254+ }
255+ return s ;
256+ }
257+
258+ /**
259+ * Serialise a list of rows to RFC-4180 CSV text. Caller supplies the
260+ * ordered list of field names; unknown fields produce empty cells.
261+ */
262+ function rowsToCsv ( fields : string [ ] , rows : Array < Record < string , any > > , includeHeader : boolean ) : string {
263+ const lines : string [ ] = [ ] ;
264+ if ( includeHeader ) lines . push ( fields . map ( formatCsvCell ) . join ( ',' ) ) ;
265+ for ( const row of rows ) {
266+ lines . push ( fields . map ( f => formatCsvCell ( row ?. [ f ] ) ) . join ( ',' ) ) ;
267+ }
268+ return lines . join ( '\r\n' ) + ( lines . length > 0 ? '\r\n' : '' ) ;
269+ }
270+
238271/**
239272 * Structural subset of `KernelManager` that RestServer needs in order to
240273 * resolve a per-project protocol at request time. Typed locally to avoid
@@ -1628,6 +1661,156 @@ export class RestServer {
16281661 tags : [ 'data' , 'import' ] ,
16291662 } ,
16301663 } ) ;
1664+
1665+ // GET /data/:object/export — streaming export (M10.21 / C.21)
1666+ //
1667+ // Query params:
1668+ // format=csv|json (default: csv. json emits a JSON array.)
1669+ // fields=a,b,c (default: derive from object schema; falls back to keys of the first row)
1670+ // filter=<json> ($filter as URL-encoded JSON, same shape as list endpoint)
1671+ // orderby=field:desc (optional ordering, mirrors $orderby semantics)
1672+ // limit=<n> (default 10000, hard cap 50000)
1673+ // page=<n> (driver chunk size, default 500, max 5000)
1674+ //
1675+ // Streams the response so 50k-row exports do not buffer in memory.
1676+ // Filename suggests `${object}-${YYYY-MM-DD}.${ext}` for browsers.
1677+ this . routeManager . register ( {
1678+ method : 'GET' ,
1679+ path : `${ dataPath } /:object/export` ,
1680+ handler : async ( req : any , res : any ) => {
1681+ try {
1682+ const projectId = isScoped ? req . params ?. projectId : undefined ;
1683+ const p = await this . resolveProtocol ( projectId , req ) ;
1684+ const context = await this . resolveExecCtx ( projectId , req ) ;
1685+ if ( this . enforceAuth ( req , res , context ) ) return ;
1686+ const objectName = String ( req . params . object || '' ) ;
1687+ if ( ! objectName ) {
1688+ res . status ( 400 ) . json ( { code : 'INVALID_REQUEST' , error : 'object is required' } ) ;
1689+ return ;
1690+ }
1691+ const q = req . query ?? { } ;
1692+ const format = ( String ( q . format ?? 'csv' ) ) . toLowerCase ( ) === 'json' ? 'json' : 'csv' ;
1693+ const HARD_CAP = 50_000 ;
1694+ const MAX_CHUNK = 5_000 ;
1695+ const requestedLimit = q . limit != null ? Math . max ( 1 , Number ( q . limit ) || 0 ) : 10_000 ;
1696+ const limit = Math . min ( requestedLimit , HARD_CAP ) ;
1697+ const chunkSize = Math . min ( MAX_CHUNK , Math . max ( 50 , q . page != null ? Number ( q . page ) || 500 : 500 ) ) ;
1698+
1699+ let filter : any = undefined ;
1700+ if ( typeof q . filter === 'string' && q . filter . length > 0 ) {
1701+ try { filter = JSON . parse ( q . filter ) ; }
1702+ catch {
1703+ res . status ( 400 ) . json ( { code : 'INVALID_REQUEST' , error : 'filter must be JSON' } ) ;
1704+ return ;
1705+ }
1706+ } else if ( q . filter && typeof q . filter === 'object' ) {
1707+ filter = q . filter ;
1708+ }
1709+
1710+ let orderby : any = undefined ;
1711+ if ( typeof q . orderby === 'string' && q . orderby . length > 0 ) {
1712+ // Accept "field:dir,field2:dir" shorthand or a JSON object.
1713+ if ( q . orderby . startsWith ( '{' ) || q . orderby . startsWith ( '[' ) ) {
1714+ try { orderby = JSON . parse ( q . orderby ) ; } catch { /* leave undefined */ }
1715+ } else {
1716+ const obj : Record < string , 'asc' | 'desc' > = { } ;
1717+ for ( const part of q . orderby . split ( ',' ) ) {
1718+ const [ field , dir ] = part . split ( ':' ) . map ( ( s : string ) => s . trim ( ) ) ;
1719+ if ( field ) obj [ field ] = dir ?. toLowerCase ( ) === 'desc' ? 'desc' : 'asc' ;
1720+ }
1721+ if ( Object . keys ( obj ) . length > 0 ) orderby = obj ;
1722+ }
1723+ }
1724+
1725+ // Resolve fields: explicit param > schema fields > derived from first row.
1726+ let fields : string [ ] | undefined ;
1727+ if ( typeof q . fields === 'string' && q . fields . length > 0 ) {
1728+ fields = q . fields . split ( ',' ) . map ( ( s : string ) => s . trim ( ) ) . filter ( Boolean ) ;
1729+ } else if ( Array . isArray ( q . fields ) ) {
1730+ fields = q . fields . filter ( ( s : any ) => typeof s === 'string' && s . length > 0 ) ;
1731+ }
1732+ if ( ! fields || fields . length === 0 ) {
1733+ try {
1734+ const schema = await ( p as any ) . getObjectSchema ?.( objectName , projectId ) ;
1735+ const schemaFields = schema ?. fields ;
1736+ if ( Array . isArray ( schemaFields ) ) {
1737+ fields = schemaFields . map ( ( f : any ) => f . name ) . filter ( ( n : any ) => typeof n === 'string' ) ;
1738+ }
1739+ } catch { /* fall back to first-row derivation */ }
1740+ }
1741+
1742+ // Prepare streaming response. Set headers BEFORE first write.
1743+ const stamp = new Date ( ) . toISOString ( ) . slice ( 0 , 10 ) ;
1744+ const safeObj = objectName . replace ( / [ ^ A - Z a - z 0 - 9 _ . - ] / g, '_' ) ;
1745+ if ( format === 'csv' ) {
1746+ res . header ( 'Content-Type' , 'text/csv; charset=utf-8' ) ;
1747+ res . header ( 'Content-Disposition' , `attachment; filename="${ safeObj } -${ stamp } .csv"` ) ;
1748+ } else {
1749+ res . header ( 'Content-Type' , 'application/json; charset=utf-8' ) ;
1750+ res . header ( 'Content-Disposition' , `attachment; filename="${ safeObj } -${ stamp } .json"` ) ;
1751+ }
1752+ res . header ( 'X-Export-Format' , format ) ;
1753+ res . header ( 'X-Export-Limit' , String ( limit ) ) ;
1754+ res . header ( 'Cache-Control' , 'no-store' ) ;
1755+
1756+ let exported = 0 ;
1757+ let firstChunk = true ;
1758+ let skip = 0 ;
1759+ if ( format === 'json' ) res . write ( '[' ) ;
1760+
1761+ while ( exported < limit ) {
1762+ const take = Math . min ( chunkSize , limit - exported ) ;
1763+ const findArgs : any = {
1764+ object : objectName ,
1765+ query : {
1766+ ...( filter ? { $filter : filter } : { } ) ,
1767+ ...( orderby ? { $orderby : orderby } : { } ) ,
1768+ $top : take ,
1769+ $skip : skip ,
1770+ } ,
1771+ ...( projectId ? { projectId } : { } ) ,
1772+ ...( context ? { context } : { } ) ,
1773+ } ;
1774+ const result : any = await ( p as any ) . findData ( findArgs ) ;
1775+ const rows : any [ ] = Array . isArray ( result ?. data ) ? result . data
1776+ : Array . isArray ( result ?. rows ) ? result . rows
1777+ : Array . isArray ( result ) ? result : [ ] ;
1778+
1779+ if ( rows . length === 0 ) break ;
1780+
1781+ if ( format === 'csv' ) {
1782+ // Derive fields from the first row if schema lookup failed.
1783+ if ( ( ! fields || fields . length === 0 ) && firstChunk ) {
1784+ fields = Object . keys ( rows [ 0 ] ?? { } ) ;
1785+ }
1786+ const text = rowsToCsv ( fields ?? [ ] , rows , firstChunk ) ;
1787+ res . write ( text ) ;
1788+ } else {
1789+ for ( let i = 0 ; i < rows . length ; i ++ ) {
1790+ const prefix = ( firstChunk && i === 0 ) ? '' : ',' ;
1791+ res . write ( prefix + JSON . stringify ( rows [ i ] ) ) ;
1792+ }
1793+ }
1794+ firstChunk = false ;
1795+ exported += rows . length ;
1796+ skip += rows . length ;
1797+ if ( rows . length < take ) break ;
1798+ }
1799+ if ( format === 'json' ) res . write ( ']' ) ;
1800+ res . end ( ) ;
1801+ } catch ( error : any ) {
1802+ logError ( '[REST] Unhandled error:' , error ) ;
1803+ // Best-effort error envelope; if headers already sent the
1804+ // client receives a truncated stream which signals failure.
1805+ try { sendError ( res , error , String ( req . params ?. object || '' ) ) ; }
1806+ catch { try { res . end ( ) ; } catch { /* swallow */ } }
1807+ }
1808+ } ,
1809+ metadata : {
1810+ summary : 'Streaming export of object rows (CSV or JSON)' ,
1811+ tags : [ 'data' , 'export' ] ,
1812+ } ,
1813+ } ) ;
16311814 }
16321815
16331816 /**
0 commit comments