From c37af25c4c4d4c01a00a0a6ac19e4f7ab99fc8a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 10:08:41 +0000 Subject: [PATCH] fix(storage): don't let a purge round delete a file that went live The drain loop fed each listing straight back into the next delete. Deleting a version id is unconditional, so a restore or a new upload to the same path while the purge was running would be picked up by the following round and destroyed. Deleting the delete marker promotes nothing in Storage, so mid-drain every surviving row is still archived. A current version appearing in a later round therefore means the path was written again, and the purge now stops instead of claiming it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn --- .../versioning/archived-object-purge-mutation.test.tsx | 8 ++++++++ .../storage/versioning/archived-object-purge-mutation.ts | 7 +++++++ 2 files changed, 15 insertions(+) diff --git a/apps/studio/data/storage/versioning/archived-object-purge-mutation.test.tsx b/apps/studio/data/storage/versioning/archived-object-purge-mutation.test.tsx index 2a6b6eea2a8..3c8b4522484 100644 --- a/apps/studio/data/storage/versioning/archived-object-purge-mutation.test.tsx +++ b/apps/studio/data/storage/versioning/archived-object-purge-mutation.test.tsx @@ -79,6 +79,14 @@ describe('useArchivedObjectPurgeMutation', () => { expect(deleted).toEqual([]) }) + it('stops rather than delete a version that went live mid-purge', async () => { + // The first round clears the history; by the second, the path has been written again. + mockListPages([[marker(), row('v2')], [row('v-new', { archived_at: null })]]) + + await expect(purge()).rejects.toThrow(/restored or replaced/) + expect(deleted).toEqual([[MARKER, 'v2']]) + }) + it('gives up rather than spin when a round deletes nothing', async () => { mockListPages([[marker(), row('v1')]]) diff --git a/apps/studio/data/storage/versioning/archived-object-purge-mutation.ts b/apps/studio/data/storage/versioning/archived-object-purge-mutation.ts index 381c51a09aa..277bc43c107 100644 --- a/apps/studio/data/storage/versioning/archived-object-purge-mutation.ts +++ b/apps/studio/data/storage/versioning/archived-object-purge-mutation.ts @@ -69,6 +69,13 @@ export const useArchivedObjectPurgeMutation = ({ await deleteVersions(versions) const remaining = await listVersions() + // Deleting the marker promotes nothing, so nothing here should be live. One that is + // arrived while the purge was running — a restore, or a new upload to the same path — + // and belongs to the user, not to the archive the next round would hand to `del`. + const live = remaining.find((version) => version.isCurrent) + if (live !== undefined && live.versionId !== archivedObjectId) { + throw new Error(`${path} was restored or replaced while it was being deleted`) + } // Nothing went away, so another round would spin rather than make progress. if (remaining.length >= versions.length) { throw new Error(`Some versions of ${path} could not be deleted`)