@@ -4,7 +4,12 @@ import chunk from 'lodash/chunk';
44import { HttpClient , log , authenticationHandler , handleAndLogError } from '@contentstack/cli-utilities' ;
55
66import { withRetry , RetryableHttpError , isRetryableStatus , parseRetryAfterMs } from './retry' ;
7- import { FALLBACK_AM_API_FETCH_CONCURRENCY , FALLBACK_AM_API_PAGE_SIZE } from '../constants/index' ;
7+ import {
8+ CS_ASSETS_BULK_MUTATE_MAX_ITEMS ,
9+ FALLBACK_AM_API_CONCURRENCY ,
10+ FALLBACK_AM_API_FETCH_CONCURRENCY ,
11+ FALLBACK_AM_API_PAGE_SIZE ,
12+ } from '../constants/index' ;
813
914import type {
1015 CSAssetsAPIConfig ,
@@ -14,6 +19,8 @@ import type {
1419 BulkDeleteAssetsResponse ,
1520 BulkMoveAssetsPayload ,
1621 BulkMoveAssetsResponse ,
22+ BulkMutateFailure ,
23+ CsAssetsMutateBatchResponse ,
1724 CreateAssetMetadata ,
1825 CreateAssetTypePayload ,
1926 CreateFieldPayload ,
@@ -93,6 +100,21 @@ export type CustomPromiseHandlerInput = {
93100
94101export type CustomPromiseHandler = ( input : CustomPromiseHandlerInput ) => Promise < any > ;
95102
103+ /**
104+ * Error thrown by {@link CSAssetsAdapter.postJson} for a failed POST. Carries the HTTP
105+ * `status` (undefined for network/transport failures) so callers can classify failures
106+ * without parsing the message string.
107+ */
108+ export class CsAssetsPostError extends Error {
109+ constructor (
110+ message : string ,
111+ public readonly status ?: number ,
112+ ) {
113+ super ( message ) ;
114+ this . name = 'CsAssetsPostError' ;
115+ }
116+ }
117+
96118export class CSAssetsAdapter implements ICSAssetsAdapter {
97119 private readonly config : CSAssetsAPIConfig ;
98120 private readonly apiClient : HttpClient ;
@@ -649,10 +671,11 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
649671 }
650672 const text = await response . text ( ) . catch ( ( ) => '' ) ;
651673 const bodySnippet = this . formatResponseBodyForError ( text ) ;
652- throw new Error (
674+ throw new CsAssetsPostError (
653675 `CS Assets API POST failed: status ${ response . status } path ${ path } ${
654676 bodySnippet ? `\nResponse: ${ bodySnippet } ` : ''
655677 } `,
678+ response . status ,
656679 ) ;
657680 }
658681 return response . json ( ) as Promise < T > ;
@@ -669,12 +692,17 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
669692 : await doPost ( ) ;
670693 } catch ( error ) {
671694 if ( error instanceof RetryableHttpError ) {
672- throw new Error ( `CS Assets API POST failed: path ${ path } (status ${ error . status ?? 'network' } ) - ${ error . message } ` ) ;
695+ throw new CsAssetsPostError (
696+ `CS Assets API POST failed: path ${ path } (status ${ error . status ?? 'network' } ) - ${ error . message } ` ,
697+ error . status ,
698+ ) ;
673699 }
674- if ( error instanceof Error && error . message . includes ( 'CS Assets API POST failed' ) ) {
700+ if ( error instanceof CsAssetsPostError ) {
675701 throw error ;
676702 }
677- throw new Error ( `CS Assets API POST failed: path ${ path } - ${ error instanceof Error ? error . message : String ( error ) } ` ) ;
703+ throw new CsAssetsPostError (
704+ `CS Assets API POST failed: path ${ path } - ${ error instanceof Error ? error . message : String ( error ) } ` ,
705+ ) ;
678706 }
679707 }
680708
@@ -779,18 +807,76 @@ export class CSAssetsAdapter implements ICSAssetsAdapter {
779807 payload : BulkDeleteAssetsPayload ,
780808 ) : Promise < BulkDeleteAssetsResponse > {
781809 const path = `/api/spaces/${ encodeURIComponent ( spaceUid ) } /assets/bulk/delete?workspace=${ encodeURIComponent ( workspaceUid ) } ` ;
782- return this . postJson < BulkDeleteAssetsResponse > ( path , payload , { space_key : spaceUid } ) ;
810+ const bodies = chunk ( payload . assets , CS_ASSETS_BULK_MUTATE_MAX_ITEMS ) . map ( ( assets ) => ( { assets } ) ) ;
811+ const { notices, jobIds, failures, batchesTotal } = await this . dispatchBulkMutateBatches ( spaceUid , path , bodies ) ;
812+ return {
813+ notice : notices [ 0 ] ,
814+ primaryJobId : jobIds [ 0 ] ,
815+ notices,
816+ job_ids : jobIds ,
817+ failures,
818+ batchesTotal,
819+ batchesSucceeded : batchesTotal - failures . length ,
820+ } ;
783821 }
784822
785823 /**
786824 * POST /api/spaces/{spaceUid}/assets/bulk-move — move assets into a folder.
825+ * Split into ≤{@link CS_ASSETS_BULK_MUTATE_MAX_ITEMS}-item requests (same cap as delete).
787826 */
788827 async bulkMoveAssets (
789828 spaceUid : string ,
790829 workspaceUid : string = 'main' ,
791830 payload : BulkMoveAssetsPayload ,
792831 ) : Promise < BulkMoveAssetsResponse > {
793832 const path = `/api/spaces/${ encodeURIComponent ( spaceUid ) } /assets/bulk-move?workspace=${ encodeURIComponent ( workspaceUid ) } ` ;
794- return this . postJson < BulkMoveAssetsResponse > ( path , payload , { space_key : spaceUid } ) ;
833+ const bodies = chunk ( payload . asset_uids , CS_ASSETS_BULK_MUTATE_MAX_ITEMS ) . map ( ( asset_uids ) => ( {
834+ asset_uids,
835+ target_folder_uid : payload . target_folder_uid ,
836+ } ) ) ;
837+ const { notices, failures, batchesTotal } = await this . dispatchBulkMutateBatches ( spaceUid , path , bodies ) ;
838+ return {
839+ notice : notices [ 0 ] ,
840+ notices,
841+ failures,
842+ batchesTotal,
843+ batchesSucceeded : batchesTotal - failures . length ,
844+ } ;
845+ }
846+
847+ /**
848+ * Dispatch pre-chunked bulk-mutate request bodies (each already ≤100 items) through
849+ * {@link postJson} with bounded concurrency via {@link makeConcurrentCall}. A batch
850+ * failure is collected — never rethrown — because the CS Assets bulk endpoints commit
851+ * each request independently, so earlier batches are already applied server-side and
852+ * callers must be able to report partial outcomes. `postJson` retries transient
853+ * (429/5xx) failures; 4xx like the 422 item-cap are not retried.
854+ */
855+ private async dispatchBulkMutateBatches (
856+ spaceUid : string ,
857+ path : string ,
858+ bodies : unknown [ ] ,
859+ ) : Promise < { notices : string [ ] ; jobIds : string [ ] ; failures : BulkMutateFailure [ ] ; batchesTotal : number } > {
860+ const notices : string [ ] = [ ] ;
861+ const jobIds : string [ ] = [ ] ;
862+ const failures : BulkMutateFailure [ ] = [ ] ;
863+ const apiBatches = chunk ( bodies , FALLBACK_AM_API_CONCURRENCY ) ;
864+
865+ await this . makeConcurrentCall ( { module : `bulk-mutate ${ path } ` , apiBatches } , async ( { element, batchIndex, index } ) => {
866+ const globalIndex = batchIndex * FALLBACK_AM_API_CONCURRENCY + index ;
867+ const body = element as { assets ?: { uid : string } [ ] ; asset_uids ?: string [ ] } ;
868+ const uids = body . assets ? body . assets . map ( ( a ) => a . uid ) : ( body . asset_uids ?? [ ] ) ;
869+ try {
870+ const r = await this . postJson < CsAssetsMutateBatchResponse > ( path , body , { space_key : spaceUid } , { retry : true } ) ;
871+ if ( typeof r . notice === 'string' ) notices . push ( r . notice ) ;
872+ if ( typeof r . job_id === 'string' ) jobIds . push ( r . job_id ) ;
873+ } catch ( e ) {
874+ const status = e instanceof CsAssetsPostError ? e . status : undefined ;
875+ const message = e instanceof Error ? e . message : String ( e ) ;
876+ failures . push ( { batchIndex : globalIndex , count : uids . length , status, error : message , uids } ) ;
877+ }
878+ } ) ;
879+
880+ return { notices, jobIds, failures, batchesTotal : bodies . length } ;
795881 }
796882}
0 commit comments