Skip to content

Commit e042a64

Browse files
committed
fix(browser): delete selected versions with standard S3 APIs
1 parent 64ba86b commit e042a64

7 files changed

Lines changed: 337 additions & 86 deletions

File tree

‎components/object/list.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import {
6363
import { OBJECT_LIST_DEFAULT_PAGE_SIZE, resolveObjectListPageSize } from "@/lib/object-list-pagination"
6464
import {
6565
resolveBucketVersioningState,
66-
shouldForceDeleteObjects,
66+
shouldDeleteAllVersions,
6767
shouldShowDeleteAllVersions,
6868
type BucketVersioningState,
6969
} from "@/lib/object-delete"
@@ -715,7 +715,7 @@ export function ObjectList({
715715
setDeleteDialogOpen(false)
716716
if (!keys.length) return
717717

718-
if (shouldForceDeleteObjects(bucketVersioningState, deleteAllVersions)) {
718+
if (shouldDeleteAllVersions(bucketVersioningState, deleteAllVersions)) {
719719
await handleDeleteAllVersions(keys)
720720
} else {
721721
await handleDelete(keys)
@@ -767,7 +767,7 @@ export function ObjectList({
767767
}
768768

769769
if (objectKeys.length > 0) {
770-
addDeleteKeys(objectKeys, bucket, undefined, { forceDelete: true })
770+
addDeleteKeys(objectKeys, bucket, undefined, { deleteAllVersions: true })
771771
}
772772
for (const prefix of folderPrefixes) {
773773
addDeleteFolder(prefix, bucket, { forceDelete: true })

‎contexts/task-context.tsx‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,12 @@ const emptyManager: TaskManagerApi<AnyTask> = {
2424
const TaskContext = React.createContext<{
2525
taskManager: TaskManagerApi<AnyTask>
2626
addUploadFiles: (items: { file: File; key: string }[], bucketName: string) => void
27-
addDeleteKeys: (keys: string[], bucketName: string, prefix?: string, options?: { forceDelete?: boolean }) => void
27+
addDeleteKeys: (
28+
keys: string[],
29+
bucketName: string,
30+
prefix?: string,
31+
options?: { deleteAllVersions?: boolean },
32+
) => void
2833
addDeleteFolder: (prefix: string, bucketName: string, options?: { forceDelete?: boolean }) => void
2934
isTaskPanelOpen: boolean
3035
setTaskPanelOpen: (open: boolean) => void
@@ -101,7 +106,7 @@ export function TaskProvider({ children }: { children: React.ReactNode }) {
101106
)
102107

103108
const addDeleteKeys = React.useCallback(
104-
(keys: string[], bucketName: string, prefix?: string, options?: { forceDelete?: boolean }) => {
109+
(keys: string[], bucketName: string, prefix?: string, options?: { deleteAllVersions?: boolean }) => {
105110
if (!managerState) return
106111
const { manager, deleteHelpers } = managerState
107112
const newTasks = deleteHelpers.createTasks(keys, bucketName, prefix, options)

‎lib/delete-task.ts‎

Lines changed: 76 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -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,

‎lib/object-delete.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,6 @@ export function shouldShowDeleteAllVersions(state: BucketVersioningState): boole
88
return state === "enabled"
99
}
1010

11-
export function shouldForceDeleteObjects(state: BucketVersioningState, deleteAllVersions: boolean): boolean {
11+
export function shouldDeleteAllVersions(state: BucketVersioningState, deleteAllVersions: boolean): boolean {
1212
return state === "enabled" && deleteAllVersions
1313
}

0 commit comments

Comments
 (0)