mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(studio): migrate storage infinite-query hook to list-v2 (#50730)
## Problem Storage's `list` (v1) endpoint is being deprecated in favor of `list-v2`, which uses cursor pagination instead of numeric offset (which degrades on large buckets) and fixes folders that differ only by case not both being listable. This is PR 1 of the migration (parent: FE-4423); it covers the shared infinite-query hook and its two consumers. ## Solution Added `listBucketObjectsV2` alongside the existing v1 `listBucketObjects` (still used elsewhere, migrated in a later PR), and replaced the `useBucketObjectsInfiniteQuery` hook with `bucketObjectsInfiniteQueryOptions` built on `infiniteQueryOptions`, following the repo's preferred data-fetching pattern. Pagination now uses `hasNext`/`nextCursor` instead of an offset multiplier, and `queryFn` rejects a response that claims `hasNext` without advancing the cursor so a misbehaving backend can't send `fetchNextPage` into an infinite loop. v2 splits results into separate `folders`/`objects` arrays and has no `search` field, so the two consumers (`BucketFilePickerColumn`, `MoveItemsFolderPicker`) merge/sort those arrays themselves, and search is folded into a `prefix` match instead. Also removed "Time last accessed" from the picker's sort dropdown since v2's `sortBy.column` doesn't support it, with a defensive fallback to `name` in case the shared sort preference (still used by the v1 main file explorer) carries that value over. Added the missing self-hosted `list-v2` API proxy route (`pages/api` + the TanStack `routes/api` wrapper) using storage-js's `listV2()`, since self-hosted Studio only had a v1 route and every v2 request was 404ing there. ## Review instructions 1. Open the bucket file picker (e.g. via an OAuth app logo upload), confirm folders and files both render and paginate correctly, and that searching still filters as expected. 2. Open the "Move items" modal's folder picker, confirm you can navigate into and back out of subfolders, and that folder search still works. 3. Run `pnpm test:studio -- MoveItemsModal`. ## Checklist - [x] I have read [CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md) - [ ] If I wrote a new docs topic or edited an existing topic, I used the `/write-the-docs` or `/edit-the-docs` skill, which references [WORD_LIST](https://github.com/supabase/supabase/blob/master/apps/docs/WORD_LIST.md) and the docs [CONTRIBUTING](https://github.com/supabase/supabase/blob/master/apps/docs/CONTRIBUTING.md) guide 🤖 Generated with [Claude Code](https://claude.com/claude-code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Storage browsing loads large bucket listings in pages, helping keep navigation responsive. * Folder pickers display folders and files together across paginated results. * Moving items between folders uses the same paginated browsing experience. * Search remains available in the final folder level, and folder navigation shows the correct contents. * **Updates** * “Time last accessed” is no longer available as a sorting option in storage pickers. Sorting is available by name, creation time, or update time. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
51a167d910
commit
12894ddd2a
11 files changed
+323
-59
No files matched your search
@@ -0,0 +1,63 @@
|
||||
import { HttpResponse } from 'msw'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { bucketObjectsInfiniteQueryOptions } from './bucket-objects-infinite-query'
|
||||
import { addAPIMock } from '@/tests/lib/msw'
|
||||
|
||||
/** Mocks list-v2 and returns a getter for the last request body it received. */
|
||||
const captureRequestBody = () => {
|
||||
let body: { prefix: string; cursor?: string } | undefined
|
||||
addAPIMock({
|
||||
method: 'post',
|
||||
path: '/platform/storage/:ref/buckets/:id/objects/list-v2',
|
||||
response: async ({ request }) => {
|
||||
body = (await request.json()) as { prefix: string; cursor?: string }
|
||||
return HttpResponse.json({ folders: [], objects: [], hasNext: false })
|
||||
},
|
||||
})
|
||||
return () => body
|
||||
}
|
||||
|
||||
const runQueryFn = (path: string, search?: string, pageParam?: string) => {
|
||||
const options = bucketObjectsInfiniteQueryOptions({
|
||||
projectRef: 'default',
|
||||
bucketId: 'avatars',
|
||||
path,
|
||||
options: search ? { search } : undefined,
|
||||
})
|
||||
return options.queryFn!({ signal: new AbortController().signal, pageParam } as any)
|
||||
}
|
||||
|
||||
describe('bucketObjectsInfiniteQueryOptions', () => {
|
||||
it('browses the bucket root with an empty prefix', async () => {
|
||||
const getBody = captureRequestBody()
|
||||
await runQueryFn('')
|
||||
expect(getBody()?.prefix).toBe('')
|
||||
})
|
||||
|
||||
it('appends a trailing slash to browse into a subfolder', async () => {
|
||||
// list-v2 with with_delimiter:true treats a bare "photos" as a partial name match
|
||||
// against siblings like "photos-old" rather than descending into the folder.
|
||||
const getBody = captureRequestBody()
|
||||
await runQueryFn('photos')
|
||||
expect(getBody()?.prefix).toBe('photos/')
|
||||
})
|
||||
|
||||
it('folds a search string into the prefix without a trailing slash after it', async () => {
|
||||
const getBody = captureRequestBody()
|
||||
await runQueryFn('photos', 'inv')
|
||||
expect(getBody()?.prefix).toBe('photos/inv')
|
||||
})
|
||||
|
||||
it('searches at the bucket root without a leading slash', async () => {
|
||||
const getBody = captureRequestBody()
|
||||
await runQueryFn('', 'inv')
|
||||
expect(getBody()?.prefix).toBe('inv')
|
||||
})
|
||||
|
||||
it('forwards the page param as the cursor', async () => {
|
||||
const getBody = captureRequestBody()
|
||||
await runQueryFn('photos', undefined, 'cursor-123')
|
||||
expect(getBody()?.cursor).toBe('cursor-123')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,8 @@
|
||||
import { InfiniteData, useInfiniteQuery } from '@tanstack/react-query'
|
||||
import { infiniteQueryOptions } from '@tanstack/react-query'
|
||||
|
||||
import { listBucketObjects, type StorageObject } from './bucket-objects-list-mutation'
|
||||
import { listBucketObjectsV2 } from './bucket-objects-list-mutation'
|
||||
import { storageKeys } from './keys'
|
||||
import type { components } from '@/data/api'
|
||||
import type { ResponseError, UseCustomInfiniteQueryOptions } from '@/types'
|
||||
|
||||
const DEFAULT_LIMIT = 200
|
||||
|
||||
@@ -11,45 +10,53 @@ type StorageObjectsQueryParams = {
|
||||
projectRef?: string
|
||||
bucketId?: string
|
||||
path: string
|
||||
options?: Omit<NonNullable<components['schemas']['GetObjectsBody']['options']>, 'offset'>
|
||||
options?: Omit<
|
||||
components['schemas']['GetObjectsV2Body'],
|
||||
'prefix' | 'cursor' | 'with_delimiter'
|
||||
> & {
|
||||
/** Not a real v2 API field: folded into `prefix` so v2 returns entries starting with it */
|
||||
search?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type StorageObjectsData = StorageObject[]
|
||||
export type StorageObjectsError = ResponseError
|
||||
export type StorageObjectsPage = components['schemas']['StorageListResponseV2_Output']
|
||||
|
||||
export const useBucketObjectsInfiniteQuery = <TData = StorageObjectsData>(
|
||||
export const bucketObjectsInfiniteQueryOptions = (
|
||||
{ projectRef, bucketId, path, options }: StorageObjectsQueryParams,
|
||||
{
|
||||
enabled = true,
|
||||
...queryOptions
|
||||
}: UseCustomInfiniteQueryOptions<
|
||||
StorageObjectsData,
|
||||
StorageObjectsError,
|
||||
InfiniteData<TData>,
|
||||
readonly unknown[],
|
||||
number
|
||||
> = {}
|
||||
{ enabled = true }: { enabled?: boolean } = {}
|
||||
) => {
|
||||
const limit = options?.limit ?? DEFAULT_LIMIT
|
||||
const { search, ...v2Options } = options ?? {}
|
||||
// A trailing slash is required to browse a folder's contents with with_delimiter: true —
|
||||
// without it, v2 treats e.g. "docs" as a partial name match against siblings like "docs-old"
|
||||
// rather than descending into the folder.
|
||||
const browsePrefix = path ? `${path}/` : ''
|
||||
const prefix = search ? `${browsePrefix}${search}` : browsePrefix
|
||||
const limit = v2Options.limit ?? DEFAULT_LIMIT
|
||||
|
||||
return useInfiniteQuery({
|
||||
return infiniteQueryOptions({
|
||||
// prefix and search are incorporated into the path.
|
||||
// eslint-disable-next-line @tanstack/query/exhaustive-deps
|
||||
queryKey: storageKeys.objects(projectRef, bucketId, path, options),
|
||||
queryFn: ({ signal, pageParam }) =>
|
||||
listBucketObjects(
|
||||
listBucketObjectsV2(
|
||||
{
|
||||
projectRef: projectRef!,
|
||||
bucketId,
|
||||
path,
|
||||
options: { ...options, limit, offset: pageParam * limit },
|
||||
prefix,
|
||||
cursor: pageParam,
|
||||
options: { ...v2Options, limit },
|
||||
},
|
||||
signal
|
||||
) as Promise<StorageObjectsData>,
|
||||
),
|
||||
enabled: enabled && !!projectRef && !!bucketId,
|
||||
initialPageParam: 0,
|
||||
getNextPageParam(lastPage, pages) {
|
||||
if (lastPage.length < limit) return undefined
|
||||
return pages.length
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage, _pages, lastPageParam) => {
|
||||
if (!lastPage.hasNext) return undefined
|
||||
// A backend that says there's more but doesn't advance the cursor would otherwise send
|
||||
// fetchNextPage into an infinite loop re-fetching the same page forever; treat it as the
|
||||
// end of the list instead.
|
||||
if (!lastPage.nextCursor || lastPage.nextCursor === lastPageParam) return undefined
|
||||
return lastPage.nextCursor
|
||||
},
|
||||
...queryOptions,
|
||||
})
|
||||
}
|
||||
@@ -41,6 +41,40 @@ export const listBucketObjects = async (
|
||||
|
||||
type ListBucketObjectsData = Awaited<ReturnType<typeof listBucketObjects>>
|
||||
|
||||
type ListBucketObjectsV2Params = {
|
||||
projectRef: string
|
||||
bucketId?: string
|
||||
prefix: string
|
||||
cursor?: string
|
||||
options?: Omit<components['schemas']['GetObjectsV2Body'], 'prefix' | 'cursor' | 'with_delimiter'>
|
||||
}
|
||||
|
||||
export const listBucketObjectsV2 = async (
|
||||
{ projectRef, bucketId, prefix, cursor, options }: ListBucketObjectsV2Params,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
if (!bucketId) throw new Error('bucketId is required')
|
||||
|
||||
const { data, error } = await post('/platform/storage/{ref}/buckets/{id}/objects/list-v2', {
|
||||
params: {
|
||||
path: {
|
||||
ref: projectRef,
|
||||
id: bucketId,
|
||||
},
|
||||
},
|
||||
body: {
|
||||
prefix,
|
||||
cursor,
|
||||
with_delimiter: true,
|
||||
...options,
|
||||
},
|
||||
signal,
|
||||
})
|
||||
|
||||
if (error) handleError(error)
|
||||
return data
|
||||
}
|
||||
|
||||
export const useGetSignBucketObjectMutation = ({
|
||||
onSuccess,
|
||||
onError,
|
||||
|
||||
Reference in new issue
Block a user