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:
Claude authored and GitHub committed 2026-10-07 16:38:40 +02:00
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',
})