@@ -17,7 +17,7 @@ export interface DeleteTask extends ManagedTask<DeleteStatus> {
1717 bucketName : string
1818 prefix ?: string
1919 versionId ?: string
20- forceDelete ?: boolean
20+ deleteAllVersions ?: boolean
2121 actionLabel : string
2222 displayName : string
2323 subInfo : string
@@ -47,13 +47,12 @@ export interface DeleteTaskHelpers {
4747 keys : string [ ] ,
4848 bucketName : string ,
4949 prefix ?: string ,
50- options ?: { forceDelete ?: boolean } ,
50+ options ?: { deleteAllVersions ?: boolean } ,
5151 ) => DeleteTask [ ]
5252 createVersionedTasks : (
5353 items : { key : string ; versionId ?: string } [ ] ,
5454 bucketName : string ,
5555 prefix ?: string ,
56- options ?: { forceDelete ?: boolean } ,
5756 ) => DeleteTask [ ]
5857 createFolderDeleteTask : ( prefix : string , bucketName : string , options ?: { forceDelete ?: boolean } ) => FolderDeleteTask
5958}
@@ -73,7 +72,7 @@ function assertDeleteObjectsSucceeded(errors: { Key?: string; Code?: string; Mes
7372 throw new Error ( first ?. Message || first ?. Code || `Failed to delete ${ errors . length } object(s)${ target } ` )
7473}
7574
76- function attachForceDeleteHeader ( command : DeleteObjectCommand | DeleteObjectsCommand ) {
75+ function attachForceDeleteHeader ( command : DeleteObjectCommand ) {
7776 // eslint-disable-next-line @typescript-eslint/no-explicit-any
7877 ; ( command . middlewareStack . add as any ) (
7978 ( next : ( args : unknown ) => Promise < unknown > ) => async ( args : { request ?: { headers ?: Record < string , string > } } ) => {
@@ -91,67 +90,74 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
9190 const retryDelay = config . retryDelay ?? 1000
9291
9392 const perform : TaskHandler < DeleteTask > [ "perform" ] = async ( task ) => {
94- const { key, bucketName, prefix, versionId, forceDelete } = task
95- const abortController = new AbortController ( )
96- task . abortController = abortController
97-
98- const command = new DeleteObjectCommand ( {
99- Bucket : bucketName ,
100- Key : ( prefix ?? "" ) + key ,
101- ...( versionId ? { VersionId : versionId } : { } ) ,
102- } )
103-
104- if ( forceDelete ) attachForceDeleteHeader ( command )
105-
106- await s3Client . send ( command , { abortSignal : abortController . signal } )
107- task . progress = 100
108- }
109-
110- const performFolder : TaskHandler < FolderDeleteTask > [ "perform" ] = async ( task ) => {
111- const { bucketName, prefix, forceDelete } = task
93+ const { key, bucketName, prefix, versionId, deleteAllVersions } = task
11294 const abortController = new AbortController ( )
11395 task . abortController = abortController
11496
115- if ( forceDelete ) {
116- let isTruncated = true
97+ if ( deleteAllVersions && ! versionId ) {
98+ // The recursive extension also covers child keys. A file selection must
99+ // delete only the versions listed for this exact key, including markers.
100+ const objectKey = ( prefix ?? "" ) + key
101+ const versions : { Key : string ; VersionId : string } [ ] = [ ]
117102 let keyMarker : string | undefined
118103 let versionIdMarker : string | undefined
119-
120- while ( isTruncated ) {
104+ while ( true ) {
121105 const data = await s3Client . send (
122106 new ListObjectVersionsCommand ( {
123107 Bucket : bucketName ,
124- Prefix : prefix ,
108+ Prefix : objectKey ,
125109 KeyMarker : keyMarker ,
126110 VersionIdMarker : versionIdMarker ,
127111 EncodingType : "url" ,
128112 } ) ,
129113 { abortSignal : abortController . signal } ,
130114 )
131115 decodeS3UrlEncodedObjectVersions ( data )
132-
133- const objectsToDelete : { Key : string ; VersionId ?: string } [ ] = [ ]
134- data . Versions ?. forEach ( ( v ) => {
135- if ( v . Key ) objectsToDelete . push ( { Key : v . Key , VersionId : v . VersionId } )
136- } )
137- data . DeleteMarkers ?. forEach ( ( m ) => {
138- if ( m . Key ) objectsToDelete . push ( { Key : m . Key , VersionId : m . VersionId } )
139- } )
140-
141- if ( objectsToDelete . length > 0 ) {
142- const command = new DeleteObjectsCommand ( {
143- Bucket : bucketName ,
144- Delete : { Objects : objectsToDelete , Quiet : true } ,
145- } )
146- attachForceDeleteHeader ( command )
147- const result = await s3Client . send ( command , { abortSignal : abortController . signal } )
148- assertDeleteObjectsSucceeded ( result . Errors )
116+ for ( const version of [ ...( data . Versions ?? [ ] ) , ...( data . DeleteMarkers ?? [ ] ) ] ) {
117+ if ( version . Key !== objectKey ) continue
118+ if ( ! version . VersionId ) throw new Error ( "Object version listing did not return a version ID" )
119+ versions . push ( { Key : objectKey , VersionId : version . VersionId } )
120+ }
121+ if ( ! data . IsTruncated || ( data . NextKeyMarker && data . NextKeyMarker !== objectKey ) ) break
122+ if ( ! data . NextKeyMarker || ( data . NextKeyMarker === keyMarker && data . NextVersionIdMarker === versionIdMarker ) ) {
123+ throw new Error ( "Object version listing did not advance" )
149124 }
150-
151- isTruncated = data . IsTruncated ?? false
152125 keyMarker = data . NextKeyMarker
153126 versionIdMarker = data . NextVersionIdMarker
154127 }
128+ for ( let offset = 0 ; offset < versions . length ; offset += 1000 ) {
129+ const result = await s3Client . send (
130+ new DeleteObjectsCommand ( {
131+ Bucket : bucketName ,
132+ Delete : { Objects : versions . slice ( offset , offset + 1000 ) , Quiet : true } ,
133+ } ) ,
134+ { abortSignal : abortController . signal } ,
135+ )
136+ assertDeleteObjectsSucceeded ( result . Errors )
137+ }
138+ task . progress = 100
139+ return
140+ }
141+
142+ const command = new DeleteObjectCommand ( {
143+ Bucket : bucketName ,
144+ Key : ( prefix ?? "" ) + key ,
145+ ...( versionId ? { VersionId : versionId } : { } ) ,
146+ } )
147+
148+ await s3Client . send ( command , { abortSignal : abortController . signal } )
149+ task . progress = 100
150+ }
151+
152+ const performFolder : TaskHandler < FolderDeleteTask > [ "perform" ] = async ( task ) => {
153+ const { bucketName, prefix, forceDelete } = task
154+ const abortController = new AbortController ( )
155+ task . abortController = abortController
156+
157+ if ( forceDelete ) {
158+ const command = new DeleteObjectCommand ( { Bucket : bucketName , Key : prefix } )
159+ attachForceDeleteHeader ( command )
160+ await s3Client . send ( command , { abortSignal : abortController . signal } )
155161 } else {
156162 let isTruncated = true
157163 let continuationToken : string | undefined
@@ -189,9 +195,21 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
189195 const shouldRetry = ( task : DeleteTask | FolderDeleteTask , error : unknown ) => {
190196 if ( task . status === lifecycle . canceled ) return false
191197 if ( ( task . retryCount ?? 0 ) >= maxRetries ) return false
192- const errorMessage = String ( ( error as Error ) ?. message ?? "" ) . toLowerCase ( )
193- const nonRetryableErrors = [ "access denied" , "forbidden" , "invalid credentials" , "bucket not found" , "no such key" ]
194- return ! nonRetryableErrors . some ( ( msg ) => errorMessage . includes ( msg ) )
198+ const errorRecord = error as { name ?: unknown ; code ?: unknown ; Code ?: unknown ; message ?: unknown }
199+ const errorText = [ errorRecord ?. name , errorRecord ?. code , errorRecord ?. Code , errorRecord ?. message ]
200+ . filter ( ( value ) : value is string => typeof value === "string" )
201+ . join ( " " )
202+ . toLowerCase ( )
203+ . replace ( / [ \s _ - ] + / g, "" )
204+ const nonRetryableErrors = [
205+ "accessdenied" ,
206+ "forbidden" ,
207+ "invalidcredentials" ,
208+ "bucketnotfound" ,
209+ "nosuchbucket" ,
210+ "nosuchkey" ,
211+ ]
212+ return ! nonRetryableErrors . some ( ( code ) => errorText . includes ( code ) )
195213 }
196214
197215 const handler : TaskHandler < DeleteTask > = {
@@ -215,7 +233,7 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
215233 }
216234
217235 const createTasksFromItems = (
218- items : { key : string ; versionId ?: string ; forceDelete ?: boolean } [ ] ,
236+ items : { key : string ; versionId ?: string ; deleteAllVersions ?: boolean } [ ] ,
219237 bucketName : string ,
220238 prefix ?: string ,
221239 ) : DeleteTask [ ] =>
@@ -224,7 +242,7 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
224242 kind : "delete" as const ,
225243 key : item . key ,
226244 versionId : item . versionId ,
227- forceDelete : item . forceDelete ,
245+ deleteAllVersions : item . deleteAllVersions ,
228246 status : lifecycle . pending ,
229247 progress : 0 ,
230248 bucketName,
@@ -237,25 +255,21 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
237255 retryCount : 0 ,
238256 } ) )
239257
240- const createTasks = ( keys : string [ ] , bucketName : string , prefix ?: string , options ?: { forceDelete ?: boolean } ) =>
241- createTasksFromItems (
242- keys . map ( ( key ) => ( { key, forceDelete : options ?. forceDelete } ) ) ,
243- bucketName ,
244- prefix ,
245- )
246-
247- const createVersionedTasks = (
248- items : { key : string ; versionId ?: string } [ ] ,
258+ const createTasks = (
259+ keys : string [ ] ,
249260 bucketName : string ,
250261 prefix ?: string ,
251- options ?: { forceDelete ?: boolean } ,
262+ options ?: { deleteAllVersions ?: boolean } ,
252263 ) =>
253264 createTasksFromItems (
254- items . map ( ( item ) => ( { ... item , forceDelete : options ?. forceDelete } ) ) ,
265+ keys . map ( ( key ) => ( { key , deleteAllVersions : options ?. deleteAllVersions } ) ) ,
255266 bucketName ,
256267 prefix ,
257268 )
258269
270+ const createVersionedTasks = ( items : { key : string ; versionId ?: string } [ ] , bucketName : string , prefix ?: string ) =>
271+ createTasksFromItems ( items , bucketName , prefix )
272+
259273 const createFolderDeleteTask = (
260274 prefix : string ,
261275 bucketName : string ,
0 commit comments