mirror of
https://github.com/supabase/supabase.git
synced 2026-10-16 06:39:09 +03:00
## This PR On a versioned bucket a file or a version can be "soft deleted/archived" or "permanently deleted". - `archived-objects-query.ts` — `ArchivedObject` / `ArchivedObjectVersion` types + `archivedObjectsQueryOptions` - `archived-object-restore-mutation.ts` — bring an archived object back into the live tree - `archived-object-purge-mutation.ts` — delete it and every version, permanently - `archived-object-version-delete-mutation.ts` — remove one retained version - `archivedOverlay.utils.ts` — synthesizes the explorer rows for one folder from the archived list - `archivedVersions.utils.ts` — an archived object's history as one flat, newest-first list --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
import { queryOptions } from '@tanstack/react-query'
|
|
import { components } from 'api-types'
|
|
|
|
import { storageKeys } from '../keys'
|
|
import type { ObjectVersionAction } from './object-versions-query'
|
|
import { handleError, post } from '@/data/fetchers'
|
|
import { IS_PLATFORM } from '@/lib/constants'
|
|
import type { ResponseError } from '@/types'
|
|
|
|
type StorageObjectV2 = components['schemas']['StorageListResponseV2_Output']['objects'][number]
|
|
|
|
export interface ArchivedObjectVersion {
|
|
versionId: string
|
|
size: number
|
|
createdAt: string
|
|
action: ObjectVersionAction
|
|
/** What the preview renders from. A delete marker carries no metadata. */
|
|
mimeType?: string
|
|
}
|
|
|
|
export interface ArchivedObject {
|
|
id: string
|
|
/** Full path including the file name. Only `getArchivedSegments` splits it. */
|
|
path: string
|
|
archivedAt: string
|
|
/** The version live when the object was archived, i.e. the one under the delete marker. */
|
|
currentVersion: ArchivedObjectVersion
|
|
/** Newest first. */
|
|
noncurrentVersions: ArchivedObjectVersion[]
|
|
}
|
|
|
|
export interface ArchivedObjectsResult {
|
|
objects: ArchivedObject[]
|
|
/** The bucket holds more rows than one listing run can carry, so `objects` is incomplete. */
|
|
isTruncated: boolean
|
|
}
|
|
|
|
export type ArchivedObjectsVariables = {
|
|
projectRef?: string
|
|
bucketId?: string
|
|
}
|
|
|
|
export type ArchivedObjectsError = ResponseError
|
|
|
|
const PAGE_SIZE = 1000
|
|
// Bounded so a pathological bucket can't page forever.
|
|
const MAX_PAGES = 20
|
|
|
|
const toVersion = (object: StorageObjectV2, action: ObjectVersionAction) => ({
|
|
versionId: object.version as string,
|
|
size: Number(object.metadata?.size ?? 0),
|
|
createdAt: object.created_at ?? object.updated_at ?? '',
|
|
action,
|
|
mimeType: object.metadata?.mimetype ?? undefined,
|
|
})
|
|
|
|
/** An object is archived when the row currently at its path is a delete marker. */
|
|
export const toArchivedObjects = (objects: StorageObjectV2[]): ArchivedObject[] => {
|
|
const byPath = new Map<string, StorageObjectV2[]>()
|
|
for (const object of objects) {
|
|
if (!object.version) continue
|
|
const rows = byPath.get(object.name) ?? []
|
|
rows.push(object)
|
|
byPath.set(object.name, rows)
|
|
}
|
|
|
|
const archived: ArchivedObject[] = []
|
|
|
|
for (const [path, rows] of byPath) {
|
|
const sorted = [...rows].sort((a, b) => (b.created_at ?? '').localeCompare(a.created_at ?? ''))
|
|
const current = sorted.find((row) => !row.archived_at)
|
|
if (!current?.is_delete_marker) continue
|
|
|
|
const retained = sorted.filter((row) => row !== current && !row.is_delete_marker)
|
|
const [liveAtArchive, ...rest] = retained
|
|
if (liveAtArchive === undefined) continue
|
|
|
|
archived.push({
|
|
id: current.version as string,
|
|
path,
|
|
archivedAt: current.created_at ?? current.updated_at ?? '',
|
|
currentVersion: toVersion(liveAtArchive, rest.length === 0 ? 'initial upload' : 'overwrite'),
|
|
noncurrentVersions: rest.map((row, index) =>
|
|
toVersion(row, index === rest.length - 1 ? 'initial upload' : 'overwrite')
|
|
),
|
|
})
|
|
}
|
|
|
|
return archived.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt))
|
|
}
|
|
|
|
/**
|
|
* The listing is ordered by name, so a page cap can cut a path's rows in half. `toArchivedObjects`
|
|
* would then group a fragment: a missing delete marker reads as a live object, and missing retained
|
|
* versions drop an archived one. Discard the trailing path rather than classify it on partial rows.
|
|
*/
|
|
export const dropPartialTrailingPath = (objects: StorageObjectV2[]): StorageObjectV2[] => {
|
|
const partialPath = objects.at(-1)?.name
|
|
if (partialPath === undefined) return objects
|
|
|
|
const kept = [...objects]
|
|
while (kept.length > 0 && kept[kept.length - 1].name === partialPath) kept.pop()
|
|
return kept
|
|
}
|
|
|
|
async function getArchivedObjects(
|
|
{ projectRef, bucketId }: ArchivedObjectsVariables,
|
|
signal?: AbortSignal
|
|
): Promise<ArchivedObjectsResult> {
|
|
if (!projectRef) throw new Error('projectRef is required')
|
|
if (!bucketId) throw new Error('bucketId is required')
|
|
|
|
let objects: StorageObjectV2[] = []
|
|
let cursor: string | undefined
|
|
let isTruncated = false
|
|
|
|
for (let page = 0; page < MAX_PAGES; page++) {
|
|
const { data, error } = await post('/platform/storage/{ref}/buckets/{id}/objects/list-v2', {
|
|
params: { path: { ref: projectRef, id: bucketId } },
|
|
body: {
|
|
limit: PAGE_SIZE,
|
|
cursor,
|
|
// Flat: the overlay synthesizes folders from full paths, so it needs the whole bucket.
|
|
with_delimiter: false,
|
|
noncurrentVersions: 'include',
|
|
deleteMarkers: 'include',
|
|
},
|
|
signal,
|
|
})
|
|
|
|
if (error) handleError(error)
|
|
objects.push(...(data?.objects ?? []))
|
|
|
|
if (!data?.hasNext || !data.nextCursor) break
|
|
cursor = data.nextCursor
|
|
isTruncated = page === MAX_PAGES - 1
|
|
}
|
|
|
|
if (isTruncated) objects = dropPartialTrailingPath(objects)
|
|
|
|
return { objects: toArchivedObjects(objects), isTruncated }
|
|
}
|
|
|
|
export type ArchivedObjectsData = Awaited<ReturnType<typeof getArchivedObjects>>
|
|
|
|
export const archivedObjectsQueryOptions = ({ projectRef, bucketId }: ArchivedObjectsVariables) =>
|
|
queryOptions({
|
|
queryKey: storageKeys.archivedObjects(projectRef, bucketId),
|
|
queryFn: ({ signal }) => getArchivedObjects({ projectRef, bucketId }, signal),
|
|
enabled: IS_PLATFORM && typeof projectRef !== 'undefined' && typeof bucketId !== 'undefined',
|
|
})
|