feat(storage): add archived objects data layer

On a versioned bucket a delete is a soft delete, and the file preview panel
already calls that action Archive and promises the versions stay recoverable.
Nothing in the dashboard lets a user see or restore an archived file yet. This is
the data layer for that, with no UI: query and mutation shapes written to the
studio conventions, endpoints stubbed, returning empty.

- `archived-objects-query.ts` — `ArchivedObject` / `ArchivedObjectVersion` types
  and `archivedObjectsQueryOptions`
- `archived-object-restore-mutation.ts` — bring an archived object back
- `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 list

Two prototype problems fixed rather than carried over:

The prototype identified the "was current when archived" row by the sentinel
`versionId === objectId`, which was load-bearing across three files and would
break the moment real version ids arrived. `ArchivedObject` now carries a
`currentVersion` record, so merging invents no fields and the distinction is an
explicit `wasCurrentAtArchive` flag.

The prototype's object had both `name` and `originalPath`, inconsistently — the
path normalization existed mostly to strip a duplicated leaf folder that
inconsistency produced. There is now a single `path`, and `getArchivedSegments`
is the only function that interprets it, so a different API shape is a one-place
change.

Also drops `deletedBy` and `expiresAt`, which the prototype never rendered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Francesco SansalvadoreandClaude Sonnet 5 authored and GitHub committed 2026-10-07 16:38:46 +02:00
1 parent e6b4495c04
commit 44ff5525dc
10 files changed
+497

No files matched your search

@@ -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 }
@@ -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> = {}): 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'])
})
})
@@ -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<string>
}
/**
* 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<string>()
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]
}
@@ -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)
})
})
@@ -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 })),
]
+2
View File
@@ -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 }) =>
@@ -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<void, ResponseError, ArchivedObjectPurgeVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<void, ResponseError, ArchivedObjectPurgeVariables>({
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,
})
}
@@ -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<void, ResponseError, ArchivedObjectRestoreVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<void, ResponseError, ArchivedObjectRestoreVariables>({
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,
})
}
@@ -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<void, ResponseError, ArchivedObjectVersionDeleteVariables>,
'mutationFn'
> = {}) => {
const queryClient = useQueryClient()
return useMutation<void, ResponseError, ArchivedObjectVersionDeleteVariables>({
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,
})
}
@@ -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<ArchivedObject[]> {
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<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',
})