mirror of
https://github.com/supabase/supabase.git
synced 2026-10-12 12:55:06 +03:00
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N3euXsz25sTybsGTcxCFfn
This commit is contained in:
1 parent
a3a8f5b87c
commit
ea3f383d60
5 files changed
+215
-42
No files matched your search
@@ -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<UseMutationOptions<void, ResponseError, ObjectPurgeVariables>, '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<void, ResponseError, ObjectPurgeVariables>({
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { toObjectVersions } from './object-versions-query'
|
||||
|
||||
const object = (overrides: Record<string, unknown>) =>
|
||||
({
|
||||
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([])
|
||||
})
|
||||
})
|
||||
@@ -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<ObjectVersion[]> {
|
||||
{ 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<ReturnType<typeof getObjectVersions>>
|
||||
@@ -57,15 +114,15 @@ export type ObjectVersionsData = Awaited<ReturnType<typeof getObjectVersions>>
|
||||
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',
|
||||
})
|
||||
Reference in new issue
Block a user