From ea3f383d602cf0f5cc0f317a9d2c8ea6d4cc07a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 21 Sep 2026 11:42:34 +0000 Subject: [PATCH] feat(storage): fetch and mutate object versions against Storage The object list endpoint now takes `noncurrentVersions`, `deleteMarkers` and `exactMatch`, and returns `version`, `archived_at` and `is_delete_marker` per row, so the version history has a real source. - `objectVersionsQueryOptions` lists one object's history, with `exactMatch` so the path isn't read as a folder prefix - Restoring is a move onto the object's own path with `sourceVersionId`, which consumes the source version rather than duplicating it - Deleting one version addresses it as `{ path, versionId }`, which is a hard delete even on a versioned bucket - Purging reads the history first and deletes every version by id, since a bare path would only hide whatever is current Variables take the object's full bucket-relative `path` rather than its leaf name, which is what these endpoints address. `ObjectVersionAction` drops `restore`: the list endpoint has no provenance field, so a restore is indistinguishable from an overwrite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn --- .../versioning/object-purge-mutation.ts | 46 +++++++--- .../object-version-delete-mutation.ts | 23 +++-- .../object-version-restore-mutation.ts | 25 ++++-- .../versioning/object-versions-query.test.ts | 78 +++++++++++++++++ .../versioning/object-versions-query.ts | 85 ++++++++++++++++--- 5 files changed, 215 insertions(+), 42 deletions(-) create mode 100644 apps/studio/data/storage/versioning/object-versions-query.test.ts diff --git a/apps/studio/data/storage/versioning/object-purge-mutation.ts b/apps/studio/data/storage/versioning/object-purge-mutation.ts index 44ccab290a2..906b95710df 100644 --- a/apps/studio/data/storage/versioning/object-purge-mutation.ts +++ b/apps/studio/data/storage/versioning/object-purge-mutation.ts @@ -2,27 +2,23 @@ import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/ import { toast } from 'sonner' import { storageKeys } from '../keys' +import { objectVersionsQueryOptions } from './object-versions-query' +import { del, handleError } from '@/data/fetchers' import type { ResponseError } from '@/types' export type ObjectPurgeVariables = { projectRef: string bucketId: string - objectName: string + /** The object's full path within the bucket, not just its leaf name. */ + path: string } /** - * Permanently removes an object and every version of it. On a versioned bucket an - * ordinary delete only hides the object; this bypasses versioning entirely. + * Permanently removes an object and every version of it. There is no single + * endpoint for this: a bare path only hides whatever is current, so the history + * is read first and each version deleted by id. Delete markers are included — + * leaving one behind would keep the object listed as archived. */ -async function purgeObject({ projectRef, bucketId, objectName }: ObjectPurgeVariables) { - if (!projectRef) throw new Error('projectRef is required') - if (!bucketId) throw new Error('bucketId is required') - if (!objectName) throw new Error('objectName is required') - - // TODO(storage-versioning): call the real endpoint once Storage exposes it. - throw new Error('Permanently deleting an object is not available yet') -} - export const useObjectPurgeMutation = ({ onSuccess, onError, @@ -30,6 +26,30 @@ export const useObjectPurgeMutation = ({ }: Omit, 'mutationFn'> = {}) => { const queryClient = useQueryClient() + const purgeObject = async ({ projectRef, bucketId, path }: ObjectPurgeVariables) => { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketId) throw new Error('bucketId is required') + if (!path) throw new Error('path is required') + + const versions = await queryClient.fetchQuery( + objectVersionsQueryOptions({ projectRef, bucketId, path }) + ) + + // A bucket that was never versioned has no version ids to address, so the + // bare path is the only way to name the object. + const paths = + versions.length > 0 + ? versions.map((version) => ({ path, versionId: version.versionId })) + : [path] + + const { error } = await del('/platform/storage/{ref}/buckets/{id}/objects', { + params: { path: { ref: projectRef, id: bucketId } }, + body: { paths }, + }) + + if (error) handleError(error) + } + return useMutation({ mutationFn: purgeObject, async onSuccess(data, variables, context) { @@ -39,7 +59,7 @@ export const useObjectPurgeMutation = ({ queryKey: storageKeys.objectVersions( variables.projectRef, variables.bucketId, - variables.objectName + variables.path ), }) await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/storage/versioning/object-version-delete-mutation.ts b/apps/studio/data/storage/versioning/object-version-delete-mutation.ts index dcadda2fb40..deb1a6b22b2 100644 --- a/apps/studio/data/storage/versioning/object-version-delete-mutation.ts +++ b/apps/studio/data/storage/versioning/object-version-delete-mutation.ts @@ -2,30 +2,39 @@ import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/ import { toast } from 'sonner' import { storageKeys } from '../keys' +import { del, handleError } from '@/data/fetchers' import type { ResponseError } from '@/types' export type ObjectVersionDeleteVariables = { projectRef: string bucketId: string - objectName: string + /** The object's full path within the bucket, not just its leaf name. */ + path: string /** The single version to remove. Other versions of the object are untouched. */ versionId: string } +/** + * Addressing a version explicitly is a hard delete even on a versioned bucket — + * a bare path would only hide whatever is currently at it. + */ async function deleteObjectVersion({ projectRef, bucketId, - objectName, + path, versionId, }: ObjectVersionDeleteVariables) { if (!projectRef) throw new Error('projectRef is required') if (!bucketId) throw new Error('bucketId is required') - if (!objectName) throw new Error('objectName is required') + if (!path) throw new Error('path is required') if (!versionId) throw new Error('versionId is required') - // TODO(storage-versioning): call the real endpoint once Storage exposes it. - // Deleting a specific version is permanent even on a versioned bucket. - throw new Error('Deleting a version is not available yet') + const { error } = await del('/platform/storage/{ref}/buckets/{id}/objects', { + params: { path: { ref: projectRef, id: bucketId } }, + body: { paths: [{ path, versionId }] }, + }) + + if (error) handleError(error) } export const useObjectVersionDeleteMutation = ({ @@ -45,7 +54,7 @@ export const useObjectVersionDeleteMutation = ({ queryKey: storageKeys.objectVersions( variables.projectRef, variables.bucketId, - variables.objectName + variables.path ), }) await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/storage/versioning/object-version-restore-mutation.ts b/apps/studio/data/storage/versioning/object-version-restore-mutation.ts index af4e9ddadba..ef65dc05983 100644 --- a/apps/studio/data/storage/versioning/object-version-restore-mutation.ts +++ b/apps/studio/data/storage/versioning/object-version-restore-mutation.ts @@ -2,31 +2,40 @@ import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/ import { toast } from 'sonner' import { storageKeys } from '../keys' +import { handleError, post } from '@/data/fetchers' import type { ResponseError } from '@/types' export type ObjectVersionRestoreVariables = { projectRef: string bucketId: string - objectName: string + /** The object's full path within the bucket, not just its leaf name. */ + path: string /** The noncurrent version to promote back to current. */ versionId: string } +/** + * A move onto the object's own path. Storage consumes the source version, so the + * restored one leaves the history rather than being duplicated within it, and + * whatever was current becomes the newest noncurrent version. + */ async function restoreObjectVersion({ projectRef, bucketId, - objectName, + path, versionId, }: ObjectVersionRestoreVariables) { if (!projectRef) throw new Error('projectRef is required') if (!bucketId) throw new Error('bucketId is required') - if (!objectName) throw new Error('objectName is required') + if (!path) throw new Error('path is required') if (!versionId) throw new Error('versionId is required') - // TODO(storage-versioning): call the real endpoint once Storage exposes it. - // Restoring copies the version back over the object, which itself produces a - // new noncurrent version of whatever was current. - throw new Error('Restoring a version is not available yet') + const { error } = await post('/platform/storage/{ref}/buckets/{id}/objects/move', { + params: { path: { ref: projectRef, id: bucketId } }, + body: { from: path, to: path, sourceVersionId: versionId }, + }) + + if (error) handleError(error) } export const useObjectVersionRestoreMutation = ({ @@ -46,7 +55,7 @@ export const useObjectVersionRestoreMutation = ({ queryKey: storageKeys.objectVersions( variables.projectRef, variables.bucketId, - variables.objectName + variables.path ), }) await onSuccess?.(data, variables, context) diff --git a/apps/studio/data/storage/versioning/object-versions-query.test.ts b/apps/studio/data/storage/versioning/object-versions-query.test.ts new file mode 100644 index 00000000000..dc4137cc6c7 --- /dev/null +++ b/apps/studio/data/storage/versioning/object-versions-query.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'vitest' + +import { toObjectVersions } from './object-versions-query' + +const object = (overrides: Record) => + ({ + name: 'folder/photo.jpg', + id: null, + updated_at: null, + created_at: null, + last_accessed_at: null, + metadata: { size: 100 }, + ...overrides, + }) as any + +describe('toObjectVersions', () => { + it('returns newest first', () => { + const versions = toObjectVersions([ + object({ version: 'old', created_at: '2026-01-01T00:00:00Z', archived_at: '2026-02-01' }), + object({ version: 'new', created_at: '2026-03-01T00:00:00Z' }), + object({ version: 'mid', created_at: '2026-02-01T00:00:00Z', archived_at: '2026-03-01' }), + ]) + + expect(versions.map((version) => version.versionId)).toEqual(['new', 'mid', 'old']) + }) + + it('treats a row with no archived_at as the current version', () => { + const versions = toObjectVersions([ + object({ version: 'a', created_at: '2026-01-01T00:00:00Z' }), + object({ version: 'b', created_at: '2026-02-01T00:00:00Z', archived_at: '2026-03-01' }), + ]) + + expect(versions.find((version) => version.versionId === 'a')?.isCurrent).toBe(true) + expect(versions.find((version) => version.versionId === 'b')?.isCurrent).toBe(false) + }) + + it('labels the oldest upload and treats the rest as overwrites', () => { + const versions = toObjectVersions([ + object({ version: 'a', created_at: '2026-01-01T00:00:00Z' }), + object({ version: 'b', created_at: '2026-02-01T00:00:00Z' }), + object({ version: 'c', created_at: '2026-03-01T00:00:00Z' }), + ]) + + expect(versions.map((version) => version.action)).toEqual([ + 'overwrite', + 'overwrite', + 'initial upload', + ]) + }) + + it('labels delete markers and does not count them as the initial upload', () => { + const versions = toObjectVersions([ + object({ version: 'upload', created_at: '2026-01-01T00:00:00Z' }), + object({ + version: 'marker', + created_at: '2026-02-01T00:00:00Z', + is_delete_marker: true, + metadata: null, + }), + ]) + + expect(versions.map((version) => version.action)).toEqual(['delete marker', 'initial upload']) + }) + + it('reads the size off the metadata, defaulting to zero', () => { + const versions = toObjectVersions([ + object({ version: 'a', created_at: '2026-01-01T00:00:00Z', metadata: { size: 2048 } }), + object({ version: 'b', created_at: '2026-02-01T00:00:00Z', metadata: null }), + ]) + + expect(versions.find((version) => version.versionId === 'a')?.size).toBe(2048) + expect(versions.find((version) => version.versionId === 'b')?.size).toBe(0) + }) + + it('drops rows the API returned without a version id', () => { + expect(toObjectVersions([object({ created_at: '2026-01-01T00:00:00Z' })])).toEqual([]) + }) +}) diff --git a/apps/studio/data/storage/versioning/object-versions-query.ts b/apps/studio/data/storage/versioning/object-versions-query.ts index c707e3512c6..45f6b3ca13f 100644 --- a/apps/studio/data/storage/versioning/object-versions-query.ts +++ b/apps/studio/data/storage/versioning/object-versions-query.ts @@ -1,16 +1,24 @@ import { queryOptions } from '@tanstack/react-query' +import { components } from 'api-types' import { storageKeys } from '../keys' +import { handleError, post } from '@/data/fetchers' import { IS_PLATFORM } from '@/lib/constants' import type { ResponseError } from '@/types' +type StorageObject = components['schemas']['StorageObject_Output'] + /** * What produced a version. A `delete marker` is the empty placeholder S3 writes * to the top of the version stack on a soft delete; it can outlive the delete * (delete → upload → delete → restore leaves one mid-history), so a live file's * history can contain them. + * + * A restore is a copy of an older version over the current one, which the list + * endpoint reports exactly like any other overwrite — there is no provenance + * field to tell them apart, so restores read as overwrites. */ -export type ObjectVersionAction = 'initial upload' | 'overwrite' | 'restore' | 'delete marker' +export type ObjectVersionAction = 'initial upload' | 'overwrite' | 'delete marker' export interface ObjectVersion { versionId: string @@ -32,24 +40,73 @@ export interface LifecyclePolicy { export type ObjectVersionsVariables = { projectRef?: string bucketId?: string - objectName?: string + /** The object's full path within the bucket, not just its leaf name. */ + path?: string lifecyclePolicy?: LifecyclePolicy } export type ObjectVersionsError = ResponseError +// One object's history. The ceiling the list endpoint accepts. +const MAX_VERSIONS = 1000 + +/** + * Newest first. The oldest surviving upload is labelled as the initial one, which + * is only true while it hasn't expired out of the history — there is no flag for + * it, and calling the oldest row an overwrite would be wrong more often. + */ +export const toObjectVersions = (objects: StorageObject[]): ObjectVersion[] => { + const versions = objects + .filter((object) => !!object.version) + .map((object) => ({ + versionId: object.version as string, + size: Number(object.metadata?.size ?? 0), + createdAt: object.created_at ?? object.updated_at ?? '', + isCurrent: !object.archived_at, + isDeleteMarker: !!object.is_delete_marker, + })) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) + + const oldestUploadIndex = versions.reduce( + (oldest, version, index) => (version.isDeleteMarker ? oldest : index), + -1 + ) + + return versions.map(({ isDeleteMarker, ...version }, index) => { + if (isDeleteMarker) return { ...version, action: 'delete marker' as const } + return { + ...version, + action: index === oldestUploadIndex ? ('initial upload' as const) : ('overwrite' as const), + } + }) +} + async function getObjectVersions( - { projectRef, bucketId, objectName }: ObjectVersionsVariables, - _signal?: AbortSignal -): Promise { + { projectRef, bucketId, path }: ObjectVersionsVariables, + signal?: AbortSignal +) { if (!projectRef) throw new Error('projectRef is required') if (!bucketId) throw new Error('bucketId is required') - if (!objectName) throw new Error('objectName is required') + if (!path) throw new Error('path is required') - // TODO(storage-versioning): replace with the real endpoint once Storage exposes - // it, following the standard `get()` + `handleError()` shape. Every object - // reports no versions until then. - return [] + const { data, error } = await post('/platform/storage/{ref}/buckets/{id}/objects/list', { + params: { path: { ref: projectRef, id: bucketId } }, + body: { + path, + options: { + limit: MAX_VERSIONS, + // Without `exactMatch` the path is read as a folder prefix, which would + // return every sibling's versions alongside this object's. + exactMatch: true, + noncurrentVersions: 'include', + deleteMarkers: 'include', + }, + }, + signal, + }) + + if (error) handleError(error) + return toObjectVersions(data ?? []) } export type ObjectVersionsData = Awaited> @@ -57,15 +114,15 @@ export type ObjectVersionsData = Awaited> export const objectVersionsQueryOptions = ({ projectRef, bucketId, - objectName, + path, lifecyclePolicy, }: ObjectVersionsVariables) => queryOptions({ - queryKey: storageKeys.objectVersions(projectRef, bucketId, objectName, lifecyclePolicy), - queryFn: ({ signal }) => getObjectVersions({ projectRef, bucketId, objectName }, signal), + queryKey: storageKeys.objectVersions(projectRef, bucketId, path, lifecyclePolicy), + queryFn: ({ signal }) => getObjectVersions({ projectRef, bucketId, path }, signal), enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof bucketId !== 'undefined' && - typeof objectName !== 'undefined', + typeof path !== 'undefined', })