Files
supabase/apps/studio/data/storage/versioning/archived-objects-query.ts
T
Francesco SansalvadoreandClaude Sonnet 5 876bb8cf9e feat(storage): archived objects data layer (#49677)
## 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>
2026-10-12 09:57:39 +02:00

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',
})