@@ -7,15 +7,27 @@ import { configHandler, log, FsUtility } from '@contentstack/cli-utilities';
77import type { CSAssetsAPIConfig , LinkedWorkspace } from '../types/cs-assets-api' ;
88import type { ExportContext } from '../types/export-types' ;
99import { CSAssetsExportAdapter } from './base' ;
10- import { writeStreamToFile } from '../utils/export-helpers' ;
10+ import { writeStreamToFile , getArrayFromResponse } from '../utils/export-helpers' ;
1111import { forEachChunkedJsonStore } from '../utils/chunked-json-reader' ;
1212import { withRetry , RetryableHttpError , isRetryableStatus , parseRetryAfterMs } from '../utils/retry' ;
1313import type { CustomPromiseHandler } from '../utils/cs-assets-api-adapter' ;
1414import { PROCESS_NAMES , PROCESS_STATUS } from '../constants/index' ;
1515
16- const ASSET_META_KEYS = [ 'uid' , 'url' , 'filename' , 'file_name' , 'parent_uid' ] ;
16+ // `locale` is part of the storage key so multi-locale variants of the same uid are kept
17+ // as distinct records (each locale has its own binary) instead of collapsing to one.
18+ const ASSET_META_KEYS = [ 'uid' , 'url' , 'filename' , 'file_name' , 'parent_uid' , 'locale' ] ;
1719
18- type AssetRecord = { uid ?: string ; _uid ?: string ; url ?: string ; filename ?: string ; file_name ?: string } ;
20+ type AssetRecord = {
21+ uid ?: string ;
22+ _uid ?: string ;
23+ url ?: string ;
24+ filename ?: string ;
25+ file_name ?: string ;
26+ locale ?: string ;
27+ } ;
28+
29+ /** Per-space export counts surfaced to the summary (assets = downloaded binaries; folders = entities). */
30+ export type SpaceExportCounts = { assets : number ; folders : number } ;
1931
2032export default class ExportAssets extends CSAssetsExportAdapter {
2133 constructor ( apiConfig : CSAssetsAPIConfig , exportContext : ExportContext ) {
@@ -26,7 +38,7 @@ export default class ExportAssets extends CSAssetsExportAdapter {
2638 return Boolean ( asset ?. url && ( asset ?. uid ?? asset ?. _uid ) ) ;
2739 }
2840
29- async start ( workspace : LinkedWorkspace , spaceDir : string ) : Promise < void > {
41+ async start ( workspace : LinkedWorkspace , spaceDir : string ) : Promise < SpaceExportCounts > {
3042 await this . init ( ) ;
3143
3244 log . debug ( `Starting assets export for space ${ workspace . space_uid } ` , this . exportContext . context ) ;
@@ -44,7 +56,9 @@ export default class ExportAssets extends CSAssetsExportAdapter {
4456 const onPage = ( items : unknown [ ] ) => {
4557 if ( items . length === 0 ) return ;
4658 if ( ! fsWriter ) fsWriter = this . createChunkedJsonWriter ( assetsDir , 'assets.json' , 'assets' , ASSET_META_KEYS ) ;
47- fsWriter . writeIntoFile ( items as Record < string , string > [ ] , { mapKeyVal : true } ) ;
59+ // Composite key (uid + locale) keeps each localized variant — a plain uid key would let the
60+ // last locale overwrite the rest, silently dropping their binaries.
61+ fsWriter . writeIntoFile ( items as Record < string , string > [ ] , { mapKeyVal : true , keyName : [ 'uid' , 'locale' ] } ) ;
4862 totalStreamed += items . length ;
4963 for ( const asset of items as AssetRecord [ ] ) if ( this . isDownloadable ( asset ) ) downloadableCount += 1 ;
5064 } ;
@@ -75,14 +89,17 @@ export default class ExportAssets extends CSAssetsExportAdapter {
7589 this . tick ( true , `metadata: ${ workspace . space_uid } (${ totalStreamed } )` , null ) ;
7690
7791 log . debug ( `Starting binary downloads for space ${ workspace . space_uid } ` , this . exportContext . context ) ;
78- await this . downloadWorkspaceAssets ( assetsDir , workspace . space_uid , downloadableCount ) ;
92+ const assetsDownloaded = await this . downloadWorkspaceAssets ( assetsDir , workspace . space_uid , downloadableCount ) ;
93+
94+ const folderCount = getArrayFromResponse ( folders , 'folders' ) . length ;
95+ return { assets : assetsDownloaded , folders : folderCount } ;
7996 }
8097
8198 /**
8299 * Download asset binaries by reading the just-written chunked `assets.json` back from disk
83100 * (one chunk at a time), so we never re-materialize the whole asset list in memory.
84101 */
85- private async downloadWorkspaceAssets ( assetsDir : string , spaceUid : string , expectedDownloads : number ) : Promise < void > {
102+ private async downloadWorkspaceAssets ( assetsDir : string , spaceUid : string , expectedDownloads : number ) : Promise < number > {
86103 const filesDir = pResolve ( assetsDir , 'files' ) ;
87104 await mkdir ( filesDir , { recursive : true } ) ;
88105
@@ -105,6 +122,27 @@ export default class ExportAssets extends CSAssetsExportAdapter {
105122 chunkReadLogLabel : 'assets' ,
106123 onOpenError : ( err ) => log . debug ( `Could not open assets.json for download: ${ err } ` , this . exportContext . context ) ,
107124 onEmptyIndexer : ( ) => log . info ( `No asset files to download for space ${ spaceUid } ` , this . exportContext . context ) ,
125+ // A chunk that fails to read back would otherwise drop its downloads silently. `records` are
126+ // recovered from metadata.json, so we count + surface each lost asset by identity here — no
127+ // separate full-metadata reconcile (which would re-materialize the whole set every run).
128+ onChunkError : ( records , err ) => {
129+ log . error (
130+ `Failed to read an asset chunk back from disk during download for space ${ spaceUid } : ${
131+ ( err as Error ) ?. message ?? String ( err )
132+ } `,
133+ this . exportContext . context ,
134+ ) ;
135+ for ( const rec of records ) {
136+ if ( ! this . isDownloadable ( rec ) ) continue ;
137+ downloadFail += 1 ;
138+ const label = rec . file_name ?? rec . filename ?? rec . uid ?? 'asset' ;
139+ this . tick ( false , `asset: ${ label } ` , 'Asset chunk unreadable' ) ;
140+ log . error (
141+ `Asset ${ rec . uid ?? '<unknown>' } (locale ${ rec . locale ?? 'n/a' } ) not downloaded — chunk unreadable for space ${ spaceUid } ` ,
142+ this . exportContext . context ,
143+ ) ;
144+ }
145+ } ,
108146 } ,
109147 async ( records ) => {
110148 const valid = records . filter ( ( asset ) => this . isDownloadable ( asset ) ) ;
@@ -141,7 +179,8 @@ export default class ExportAssets extends CSAssetsExportAdapter {
141179 const body = response . body ;
142180 if ( ! body ) throw new Error ( 'No response body' ) ;
143181 const nodeStream = Readable . fromWeb ( body as Parameters < typeof Readable . fromWeb > [ 0 ] ) ;
144- const assetFolderPath = pResolve ( filesDir , uid ) ;
182+ // Locale-scoped path keeps each localized variant's binary distinct under the same uid.
183+ const assetFolderPath = asset . locale ? pResolve ( filesDir , uid , asset . locale ) : pResolve ( filesDir , uid ) ;
145184 await mkdir ( assetFolderPath , { recursive : true } ) ;
146185 const filePath = pResolve ( assetFolderPath , filename ) ;
147186 await writeStreamToFile ( nodeStream , filePath ) ;
@@ -153,35 +192,29 @@ export default class ExportAssets extends CSAssetsExportAdapter {
153192 downloadFail += 1 ;
154193 const err = ( e as Error ) ?. message ?? PROCESS_STATUS [ PROCESS_NAMES . AM_DOWNLOADS ] . FAILED ;
155194 this . tick ( false , `asset: ${ filename } ` , err ) ;
156- log . debug ( `Failed to download asset ${ uid } : ${ e } ` , this . exportContext . context ) ;
195+ log . error (
196+ `Failed to download asset ${ uid } (${ filename } ): ${ ( e as Error ) ?. message ?? String ( e ) } ` ,
197+ this . exportContext . context ,
198+ ) ;
157199 }
158200 } ;
159201
160202 await this . makeConcurrentCall ( { apiBatches, module : 'asset downloads' } , promisifyHandler ) ;
161203 } ,
162204 ) ;
163205
164- // Completeness check: a chunk that fails to read back is skipped (logged at debug) by
165- // forEachChunkedJsonStore, which would silently drop those downloads. Reconcile attempts
166- // (ok + failed) against what streaming counted as downloadable.
167- const attempted = downloadOk + downloadFail ;
168- if ( attempted < expectedDownloads ) {
169- log . warn (
170- `Asset downloads for space ${ spaceUid } incomplete: expected ${ expectedDownloads } , attempted ${ attempted } ` +
171- ` — ${ expectedDownloads - attempted } asset(s) were never read back for download.` ,
172- this . exportContext . context ,
173- ) ;
174- }
175-
176206 log . info (
177207 downloadFail === 0
178208 ? `Finished downloading ${ downloadOk } asset file(s) for space ${ spaceUid } `
179209 : `Asset downloads for space ${ spaceUid } completed with errors: ${ downloadOk } succeeded, ${ downloadFail } failed` ,
180210 this . exportContext . context ,
181211 ) ;
182212 log . debug (
183- `Asset downloads finished for space ${ spaceUid } : ok=${ downloadOk } , failed=${ downloadFail } ` ,
213+ `Asset downloads finished for space ${ spaceUid } : ok=${ downloadOk } , failed=${ downloadFail } , expected= ${ expectedDownloads } ` ,
184214 this . exportContext . context ,
185215 ) ;
216+
217+ return downloadOk ;
186218 }
219+
187220}
0 commit comments