diff --git a/apps/studio/components/interfaces/Storage/Storage.types.ts b/apps/studio/components/interfaces/Storage/Storage.types.ts index 3763158c309..8556d9c5754 100644 --- a/apps/studio/components/interfaces/Storage/Storage.types.ts +++ b/apps/studio/components/interfaces/Storage/Storage.types.ts @@ -40,6 +40,11 @@ export interface StorageItem { // UI specific properties, not from API isCorrupted: boolean path?: string + /** + * Set on a row synthesized from the archived list. `archivedObjectId` is absent + * on a folder that exists only because something archived sits inside it. + */ + archived?: { archivedObjectId?: string } } export type StorageItemWithColumn = StorageItem & { columnIndex: number } diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/archivedOverlay.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedOverlay.utils.test.ts new file mode 100644 index 00000000000..593f26913e9 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedOverlay.utils.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest' + +import { STORAGE_ROW_TYPES } from '../Storage.constants' +import { getArchivedOverlayItems, getArchivedSegments } from './archivedOverlay.utils' +import type { ArchivedObject } from '@/data/storage/versioning/archived-objects-query' + +const archived = (path: string, overrides: Partial = {}): ArchivedObject => ({ + id: `id-${path}`, + path, + archivedAt: '2026-08-01T00:00:00Z', + currentVersion: { + versionId: `v-${path}`, + size: 1024, + createdAt: '2026-07-01T00:00:00Z', + action: 'overwrite', + }, + noncurrentVersions: [], + ...overrides, +}) + +const overlay = ( + folderSegments: string[], + archivedObjects: ArchivedObject[], + existingItemNames: string[] = [] +) => + getArchivedOverlayItems({ + folderSegments, + archivedObjects, + existingItemNames: new Set(existingItemNames), + }) + +describe('getArchivedSegments', () => { + it('splits a nested path into segments', () => { + expect(getArchivedSegments(archived('matches/round-3/final.png'))).toEqual([ + 'matches', + 'round-3', + 'final.png', + ]) + }) + + it('tolerates leading, trailing and repeated slashes', () => { + expect(getArchivedSegments(archived('/matches//round-3/final.png/'))).toEqual([ + 'matches', + 'round-3', + 'final.png', + ]) + }) + + it('handles a file at the bucket root', () => { + expect(getArchivedSegments(archived('logo.svg'))).toEqual(['logo.svg']) + }) +}) + +describe('getArchivedOverlayItems', () => { + it('returns nothing when there is nothing archived', () => { + expect(overlay([], [])).toEqual([]) + }) + + it('surfaces a root-level object as a file row at the root', () => { + const [row] = overlay([], [archived('logo.svg')]) + expect(row.name).toBe('logo.svg') + expect(row.type).toBe(STORAGE_ROW_TYPES.FILE) + expect(row.archived).toEqual({ archivedObjectId: 'id-logo.svg' }) + expect(row.path).toBe('logo.svg') + }) + + it('carries the archived size and timestamp onto the row', () => { + const [row] = overlay([], [archived('logo.svg')]) + expect(row.metadata?.size).toBe(1024) + expect(row.updated_at).toBe('2026-08-01T00:00:00Z') + }) + + it('does not surface a root object when viewing a subfolder', () => { + expect(overlay(['matches'], [archived('logo.svg')])).toEqual([]) + }) + + it('surfaces a nested object as a folder row one level up', () => { + const rows = overlay([], [archived('matches/final.png')]) + expect(rows).toHaveLength(1) + expect(rows[0].name).toBe('matches') + expect(rows[0].type).toBe(STORAGE_ROW_TYPES.FOLDER) + expect(rows[0].archived).toEqual({}) + }) + + it('surfaces the file itself once inside that folder', () => { + const rows = overlay(['matches'], [archived('matches/final.png')]) + expect(rows).toHaveLength(1) + expect(rows[0].name).toBe('final.png') + expect(rows[0].type).toBe(STORAGE_ROW_TYPES.FILE) + }) + + it('coalesces several objects under one subfolder into a single row', () => { + const rows = overlay( + [], + [archived('matches/a.png'), archived('matches/b.png'), archived('matches/deep/c.png')] + ) + expect(rows.map((r) => r.name)).toEqual(['matches']) + }) + + it('skips a folder that already exists in the live listing', () => { + expect(overlay([], [archived('matches/final.png')], ['matches'])).toEqual([]) + }) + + it('skips a file that already exists in the live listing', () => { + expect(overlay([], [archived('logo.svg')], ['logo.svg'])).toEqual([]) + }) + + it('orders folders before files', () => { + const rows = overlay([], [archived('logo.svg'), archived('matches/final.png')]) + expect(rows.map((r) => r.type)).toEqual([STORAGE_ROW_TYPES.FOLDER, STORAGE_ROW_TYPES.FILE]) + }) + + it('only descends one level at a time', () => { + const rows = overlay([], [archived('a/b/c/d.png')]) + expect(rows.map((r) => r.name)).toEqual(['a']) + expect(overlay(['a'], [archived('a/b/c/d.png')]).map((r) => r.name)).toEqual(['b']) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/archivedOverlay.utils.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedOverlay.utils.ts new file mode 100644 index 00000000000..edf85db8b09 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedOverlay.utils.ts @@ -0,0 +1,91 @@ +import { STORAGE_ROW_STATUS, STORAGE_ROW_TYPES } from '../Storage.constants' +import type { StorageItem } from '../Storage.types' +import type { ArchivedObject } from '@/data/storage/versioning/archived-objects-query' + +const splitPath = (path: string): string[] => path.split('/').filter((segment) => segment !== '') + +/** + * The only place that interprets `ArchivedObject.path`, so a different API shape + * is a one-function change. + */ +export const getArchivedSegments = (object: ArchivedObject): string[] => splitPath(object.path) + +const isUnderFolder = (folderSegments: string[], objectSegments: string[]): boolean => { + if (objectSegments.length <= folderSegments.length) return false + return folderSegments.every((segment, index) => segment === objectSegments[index]) +} + +export interface ArchivedOverlayInput { + /** Empty at the bucket root. */ + folderSegments: string[] + archivedObjects: ArchivedObject[] + /** Names from the live listing; a live item of the same name always wins. */ + existingItemNames: Set +} + +/** + * Objects deeper down are coalesced into one folder row per next segment, so + * drilling in runs this again a level lower. + */ +export const getArchivedOverlayItems = ({ + folderSegments, + archivedObjects, + existingItemNames, +}: ArchivedOverlayInput): StorageItem[] => { + const files: StorageItem[] = [] + const folderNames = new Set() + + for (const object of archivedObjects) { + const segments = getArchivedSegments(object) + if (!isUnderFolder(folderSegments, segments)) continue + + const nextSegment = segments[folderSegments.length] + const isDirectChild = segments.length === folderSegments.length + 1 + + if (!isDirectChild) { + folderNames.add(nextSegment) + continue + } + + if (existingItemNames.has(nextSegment)) continue + + files.push({ + id: object.id, + name: nextSegment, + type: STORAGE_ROW_TYPES.FILE, + status: STORAGE_ROW_STATUS.READY, + metadata: { + size: object.currentVersion.size, + mimetype: '', + cacheControl: '', + contentLength: object.currentVersion.size, + httpStatusCode: 0, + eTag: '', + lastModified: object.archivedAt, + }, + created_at: null, + updated_at: object.archivedAt, + last_accessed_at: null, + isCorrupted: false, + path: [...folderSegments, nextSegment].join('/'), + archived: { archivedObjectId: object.id }, + }) + } + + const folders: StorageItem[] = [...folderNames] + .filter((folderName) => !existingItemNames.has(folderName)) + .map((folderName) => ({ + id: null, + name: folderName, + type: STORAGE_ROW_TYPES.FOLDER, + status: STORAGE_ROW_STATUS.READY, + metadata: null, + created_at: null, + updated_at: null, + last_accessed_at: null, + isCorrupted: false, + archived: {}, + })) + + return [...folders, ...files] +} diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/archivedVersions.utils.test.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedVersions.utils.test.ts new file mode 100644 index 00000000000..43c499930d4 --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedVersions.utils.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { getMergedArchivedVersions } from './archivedVersions.utils' +import type { ArchivedObject } from '@/data/storage/versioning/archived-objects-query' + +const object: ArchivedObject = { + id: 'obj-1', + path: 'matches/final.png', + archivedAt: '2026-08-10T00:00:00Z', + currentVersion: { + versionId: 'v-current', + size: 900, + createdAt: '2026-08-01T00:00:00Z', + action: 'overwrite', + }, + noncurrentVersions: [ + { versionId: 'v-2', size: 800, createdAt: '2026-07-20T00:00:00Z', action: 'overwrite' }, + { versionId: 'v-1', size: 700, createdAt: '2026-07-01T00:00:00Z', action: 'initial upload' }, + ], +} + +describe('getMergedArchivedVersions', () => { + it('puts the version that was live at archive time first', () => { + const [first] = getMergedArchivedVersions(object) + expect(first.versionId).toBe('v-current') + expect(first.wasCurrentAtArchive).toBe(true) + }) + + it('follows it with the retained noncurrent versions, newest first', () => { + expect(getMergedArchivedVersions(object).map((v) => v.versionId)).toEqual([ + 'v-current', + 'v-2', + 'v-1', + ]) + }) + + it('flags only the first row as having been current', () => { + const rows = getMergedArchivedVersions(object) + expect(rows.filter((v) => v.wasCurrentAtArchive)).toHaveLength(1) + }) + + it('preserves each version’s own size and action rather than inventing them', () => { + const rows = getMergedArchivedVersions(object) + expect(rows.map((v) => v.size)).toEqual([900, 800, 700]) + expect(rows.map((v) => v.action)).toEqual(['overwrite', 'overwrite', 'initial upload']) + }) + + it('returns just the archived version when nothing else was retained', () => { + const rows = getMergedArchivedVersions({ ...object, noncurrentVersions: [] }) + expect(rows).toHaveLength(1) + expect(rows[0].wasCurrentAtArchive).toBe(true) + }) +}) diff --git a/apps/studio/components/interfaces/Storage/StorageExplorer/archivedVersions.utils.ts b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedVersions.utils.ts new file mode 100644 index 00000000000..af7e4de3dfa --- /dev/null +++ b/apps/studio/components/interfaces/Storage/StorageExplorer/archivedVersions.utils.ts @@ -0,0 +1,15 @@ +import type { + ArchivedObject, + ArchivedObjectVersion, +} from '@/data/storage/versioning/archived-objects-query' + +export interface ArchivedVersionRow extends ArchivedObjectVersion { + /** Deleting this one promotes the next version behind it, so actions differ. */ + wasCurrentAtArchive: boolean +} + +/** Newest first: the version live at archive time, then the rest. */ +export const getMergedArchivedVersions = (object: ArchivedObject): ArchivedVersionRow[] => [ + { ...object.currentVersion, wasCurrentAtArchive: true }, + ...object.noncurrentVersions.map((version) => ({ ...version, wasCurrentAtArchive: false })), +] diff --git a/apps/studio/data/storage/keys.ts b/apps/studio/data/storage/keys.ts index 1a7ad93ad07..5d91e853b87 100644 --- a/apps/studio/data/storage/keys.ts +++ b/apps/studio/data/storage/keys.ts @@ -97,6 +97,8 @@ export const storageKeys = { path, ...(versionId === undefined ? [] : [versionId]), ] as const, + archivedObjects: (projectRef: string | undefined, bucketId: string | undefined) => + ['projects', projectRef, 'buckets', bucketId, 'archived-objects'] as const, retentionUsage: (orgSlug: string | undefined) => ['organizations', orgSlug, 'storage-retention-usage'] as const, icebergNamespaces: ({ projectRef, warehouse }: { projectRef?: string; warehouse?: string }) => diff --git a/apps/studio/data/storage/versioning/archived-object-purge-mutation.ts b/apps/studio/data/storage/versioning/archived-object-purge-mutation.ts new file mode 100644 index 00000000000..89d24b1a53a --- /dev/null +++ b/apps/studio/data/storage/versioning/archived-object-purge-mutation.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { storageKeys } from '../keys' +import type { ResponseError } from '@/types' + +export type ArchivedObjectPurgeVariables = { + projectRef: string + bucketId: string + archivedObjectId: string +} + +async function purgeArchivedObject({ + projectRef, + bucketId, + archivedObjectId, +}: ArchivedObjectPurgeVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketId) throw new Error('bucketId is required') + if (!archivedObjectId) throw new Error('archivedObjectId is required') + + // TODO(storage-versioning): real endpoint once Storage exposes it. + throw new Error('Permanently deleting an archived file is not available yet') +} + +export const useArchivedObjectPurgeMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: purgeArchivedObject, + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.archivedObjects(variables.projectRef, variables.bucketId), + }) + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) toast.error(`Failed to delete file: ${error.message}`) + else onError(error, variables, context) + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/versioning/archived-object-restore-mutation.ts b/apps/studio/data/storage/versioning/archived-object-restore-mutation.ts new file mode 100644 index 00000000000..1f2d1989c02 --- /dev/null +++ b/apps/studio/data/storage/versioning/archived-object-restore-mutation.ts @@ -0,0 +1,50 @@ +import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { storageKeys } from '../keys' +import type { ResponseError } from '@/types' + +export type ArchivedObjectRestoreVariables = { + projectRef: string + bucketId: string + archivedObjectId: string +} + +async function restoreArchivedObject({ + projectRef, + bucketId, + archivedObjectId, +}: ArchivedObjectRestoreVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketId) throw new Error('bucketId is required') + if (!archivedObjectId) throw new Error('archivedObjectId is required') + + // TODO(storage-versioning): real endpoint once Storage exposes it. + throw new Error('Restoring an archived file is not available yet') +} + +export const useArchivedObjectRestoreMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: restoreArchivedObject, + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.archivedObjects(variables.projectRef, variables.bucketId), + }) + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) toast.error(`Failed to restore file: ${error.message}`) + else onError(error, variables, context) + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/versioning/archived-object-version-delete-mutation.ts b/apps/studio/data/storage/versioning/archived-object-version-delete-mutation.ts new file mode 100644 index 00000000000..cb826082b21 --- /dev/null +++ b/apps/studio/data/storage/versioning/archived-object-version-delete-mutation.ts @@ -0,0 +1,58 @@ +import { useMutation, useQueryClient, type UseMutationOptions } from '@tanstack/react-query' +import { toast } from 'sonner' + +import { storageKeys } from '../keys' +import type { ResponseError } from '@/types' + +export type ArchivedObjectVersionDeleteVariables = { + projectRef: string + bucketId: string + archivedObjectId: string + versionId: string + /** + * Deleting the version that was live at archive time promotes the next one + * behind it, or removes the object when it was the last — hence the flag. + */ + wasCurrentAtArchive: boolean +} + +async function deleteArchivedObjectVersion({ + projectRef, + bucketId, + archivedObjectId, + versionId, +}: ArchivedObjectVersionDeleteVariables) { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketId) throw new Error('bucketId is required') + if (!archivedObjectId) throw new Error('archivedObjectId is required') + if (!versionId) throw new Error('versionId is required') + + // TODO(storage-versioning): real endpoint once Storage exposes it. + throw new Error('Deleting an archived version is not available yet') +} + +export const useArchivedObjectVersionDeleteMutation = ({ + onSuccess, + onError, + ...options +}: Omit< + UseMutationOptions, + 'mutationFn' +> = {}) => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: deleteArchivedObjectVersion, + async onSuccess(data, variables, context) { + await queryClient.invalidateQueries({ + queryKey: storageKeys.archivedObjects(variables.projectRef, variables.bucketId), + }) + await onSuccess?.(data, variables, context) + }, + async onError(error, variables, context) { + if (onError === undefined) toast.error(`Failed to delete version: ${error.message}`) + else onError(error, variables, context) + }, + ...options, + }) +} diff --git a/apps/studio/data/storage/versioning/archived-objects-query.ts b/apps/studio/data/storage/versioning/archived-objects-query.ts new file mode 100644 index 00000000000..7f204ae9aff --- /dev/null +++ b/apps/studio/data/storage/versioning/archived-objects-query.ts @@ -0,0 +1,55 @@ +import { queryOptions } from '@tanstack/react-query' + +import { storageKeys } from '../keys' +import type { ObjectVersionAction } from './object-versions-query' +import { IS_PLATFORM } from '@/lib/constants' +import type { ResponseError } from '@/types' + +export interface ArchivedObjectVersion { + versionId: string + size: number + createdAt: string + action: ObjectVersionAction +} + +export interface ArchivedObject { + id: string + /** Full path including the file name. Only `getArchivedSegments` splits it. */ + path: string + archivedAt: string + /** + * The version that was live when the object was archived. S3 puts a delete + * marker on top of the stack; this UI hides that and surfaces the version + * underneath as an ordinary history entry. + */ + currentVersion: ArchivedObjectVersion + /** Newest first. */ + noncurrentVersions: ArchivedObjectVersion[] +} + +export type ArchivedObjectsVariables = { + projectRef?: string + bucketId?: string +} + +export type ArchivedObjectsError = ResponseError + +async function getArchivedObjects( + { projectRef, bucketId }: ArchivedObjectsVariables, + _signal?: AbortSignal +): Promise { + if (!projectRef) throw new Error('projectRef is required') + if (!bucketId) throw new Error('bucketId is required') + + // TODO(storage-versioning): real endpoint once Storage exposes it. + return [] +} + +export type ArchivedObjectsData = Awaited> + +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', + })