feat: Support CLI for Vector buckets (#46381)

## Context

> [!IMPORTANT]  
> Will open up for review once CLI PR is merged and deployed so that
it's easier to test

Related PR: https://github.com/supabase/cli/pull/5230

Adding support for vector buckets for local CLI - will need to be tested
locally via `pnpm run dev:studio-local`

## To test
There's a bit of testing instructions in the linear ticket
[here](https://linear.app/supabase/issue/FE-3474/show-vector-buckets-in-local-admin-studio)
as it involves using a branch of CLI - otherwise do reach out to
Fabrizio if any help might be needed, but generally:

### Local CLI
You might need to manually set `isCli` to `true` in `StorageMenuV2` if
the "Vectors" nav item isn't showing up on the storage UI given we're
testing via `pnpm run dev:studio-local`
- [x] Can create bucket
- [x] Can delete bucket
- [x] Can create indexes
- [x] Can insert data into indexes (via FDW)
- [x] Can delete indexes

Known issues (that aren't directly solvable from FE end)
Reach out to Fabrizio for context as we were both investigating this
- PG database needs to be on 17.6 (otherwise there's no S3 vectors FDW)
- Storage version needs to be on 1.59.0

### Self-hosted (This might be tricky to actually test, but just ensure
that the code satisfies this)
- [x] Cannot see vector buckets

### Hosted
- [x] Everything works status quo

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Vector bucket management UI and platform APIs (create/list/delete
buckets & indexes)
* Local S3 credentials endpoint and client-side hook for self‑hosted/CLI
use

* **Bug Fixes**
* Improved S3 vector setup notifications and clearer error guidance for
manual installation

* **Refactor**
* Deployment-mode gating: platform vs CLI/self‑hosted now controls
feature visibility and page behavior

* **Tests**
* Added suites covering deployment-mode gates and vector bucket
error/usage scenarios

* **Chores**
  * Build env updated to expose local S3 credential vars

<!-- review_stack_entry_start -->

[![Review Change
Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/46381?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack)

<!-- review_stack_entry_end -->
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
Joshen LimandAli Waseem authored and GitHub committed 2026-06-01 08:02:22 -06:00
1 parent b4448fedd2
commit 1d203f6c93
22 files changed
+654 -28

No files matched your search

@@ -1,4 +1,4 @@
import { IS_PLATFORM, useParams } from 'common'
import { useParams } from 'common'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Badge, Menu } from 'ui'
@@ -10,6 +10,7 @@ import {
useIsAnalyticsBucketsEnabled,
useIsVectorBucketsEnabled,
} from '@/data/config/project-storage-config-query'
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
@@ -25,6 +26,8 @@ export const StorageMenuV2 = () => {
const { ref } = useParams()
const page = useStorageV2Page()
const { isCli, isPlatform } = useDeploymentMode()
const { storageAnalytics, storageVectors } = useIsFeatureEnabled([
'storage:analytics',
'storage:vectors',
@@ -33,8 +36,8 @@ export const StorageMenuV2 = () => {
const isAnalyticsBucketsEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref })
const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef: ref })
const showAnalytics = IS_PLATFORM && storageAnalytics
const showVectors = IS_PLATFORM && storageVectors
const showAnalytics = isPlatform && storageAnalytics
const showVectors = (isPlatform && storageVectors) || isCli
useShortcut(SHORTCUT_IDS.NAV_STORAGE_FILES, () => router.push(`/project/${ref}/storage/files`))
useShortcut(
@@ -48,7 +51,7 @@ export const StorageMenuV2 = () => {
{ enabled: showVectors }
)
useShortcut(SHORTCUT_IDS.NAV_STORAGE_S3, () => router.push(`/project/${ref}/storage/s3`), {
enabled: IS_PLATFORM,
enabled: isPlatform,
})
const bucketTypes = Object.entries(BUCKET_TYPES).filter(([key]) => {
@@ -95,7 +98,7 @@ export const StorageMenuV2 = () => {
})}
</div>
{IS_PLATFORM && (
{isPlatform && (
<>
<div className="h-px w-[calc(100%-1.5rem)] mx-auto md:w-full bg-border" />
<div className="md:mx-3">
@@ -141,15 +141,22 @@ export const CreateVectorBucketDialog = ({
}
await createS3VectorsWrapper({ bucketName: values.name })
toast.success(`Successfully created vector bucket ${values.name}`)
} catch (error: any) {
toast.warning(
`Failed to create vector bucket integration: ${error.message}. The bucket will be created but you will need to manually install the integration.`
toast.success(
<div>
<p>Successfully created vector bucket {values.name}</p>
<p className="text-foreground-light text-xs">
However, bucket integration will need to be manually installed as we ran into an error:
</p>
<p className="text-foreground-light text-xs">{error.message}</p>
</div>,
{ duration: 8000 }
)
}
setIsLoading(false)
setIsLoading(false)
track('storage_bucket_created', { bucketType: 'vector' })
toast.success(`Successfully created vector bucket ${values.name}`)
form.reset()
setVisible(false)
}
@@ -34,6 +34,7 @@ import { DocsButton } from '@/components/ui/DocsButton'
import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
import { useVectorBucketIndexCreateMutation } from '@/data/storage/vector-bucket-index-create-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
@@ -104,6 +105,7 @@ interface CreateVectorTableSheetProps {
export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => {
const { data: project } = useSelectedProjectQuery()
const { isCli } = useDeploymentMode()
const [visible, setVisible] = useQueryState(
'newTable',
@@ -117,7 +119,8 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro
?.split('supabase_target_schema=')[1]
// [Joshen] Can remove this once this restriction is removed
const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1'
const showIndexCreationNotice =
isStagingLocal && !isCli && !!project && project?.region !== 'us-east-1'
const defaultValues = {
name: '',
@@ -0,0 +1,52 @@
import { screen } from '@testing-library/react'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import { VectorBucketsErrorState } from './VectorBucketsErrorState'
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { customRender } from '@/tests/lib/custom-render'
import { ResponseError } from '@/types'
const { mockUseDeploymentMode } = vi.hoisted(() => ({
mockUseDeploymentMode: vi.fn<() => DeploymentMode>(),
}))
vi.mock('@/hooks/misc/useDeploymentMode', () => ({
useDeploymentMode: mockUseDeploymentMode,
}))
vi.mock('@/lib/telemetry/track', () => ({
useTrack: () => vi.fn(),
}))
const deploymentMode = (overrides: Partial<DeploymentMode>): DeploymentMode => ({
isPlatform: false,
isCli: false,
isSelfHosted: false,
...overrides,
})
describe('VectorBucketsErrorState', () => {
beforeEach(() => {
mockUseDeploymentMode.mockReset()
})
test('CLI: shows the config.toml enable guidance, not the support error', () => {
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isCli: true }))
customRender(<VectorBucketsErrorState error={new ResponseError('boom')} />)
expect(screen.getByText('Vector buckets are not enabled')).toBeInTheDocument()
expect(screen.getByText('supabase/config.toml')).toBeInTheDocument()
expect(screen.queryByText('Contact support')).not.toBeInTheDocument()
})
test('platform: shows the generic support error', () => {
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
customRender(<VectorBucketsErrorState error={new ResponseError('boom')} />)
expect(screen.getByText('Failed to retrieve vector buckets')).toBeInTheDocument()
expect(screen.getByText('Contact support')).toBeInTheDocument()
expect(screen.queryByText('Vector buckets are not enabled')).not.toBeInTheDocument()
})
})
@@ -0,0 +1,16 @@
import { VectorBucketsLocalDisabledState } from './VectorBucketsLocalDisabledState'
import AlertError from '@/components/ui/AlertError'
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import type { ResponseError } from '@/types'
interface VectorBucketsErrorStateProps {
error: ResponseError | null
}
export const VectorBucketsErrorState = ({ error }: VectorBucketsErrorStateProps) => {
const { isCli } = useDeploymentMode()
if (isCli) return <VectorBucketsLocalDisabledState />
return <AlertError error={error} subject="Failed to retrieve vector buckets" />
}
@@ -0,0 +1,28 @@
import { Admonition } from 'ui-patterns/admonition'
import { CodeBlock } from 'ui-patterns/CodeBlock'
const CONFIG_SNIPPET = `[storage.vector]
enabled = true
max_buckets = 10
max_indexes = 5`
/**
* Shown on the local CLI when listing vector buckets fails — most commonly
* because `[storage.vector]` is not enabled in `config.toml`. Studio can't read
* `config.toml` directly, so we surface the snippet to enable the feature rather
* than the generic "contact support" error.
*/
export const VectorBucketsLocalDisabledState = () => {
return (
<Admonition type="default" title="Vector buckets are not enabled">
<p className="text-foreground-light">
To use vector buckets locally, enable them in your{' '}
<code className="text-code-inline">supabase/config.toml</code> and restart with{' '}
<code className="text-code-inline">supabase start</code>.
</p>
<CodeBlock language="toml" hideLineNumbers className="mt-2 max-w-full">
{CONFIG_SNIPPET}
</CodeBlock>
</Admonition>
)
}
@@ -14,7 +14,7 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateBucketButton } from '../NewBucketButton'
import { CreateVectorBucketDialog } from './CreateVectorBucketDialog'
import AlertError from '@/components/ui/AlertError'
import { VectorBucketsErrorState } from './VectorBucketsErrorState'
import { AlphaNotice } from '@/components/ui/AlphaNotice'
import { useVectorBucketsQuery } from '@/data/storage/vector-buckets-query'
import { createNavigationHandler } from '@/lib/navigation'
@@ -62,9 +62,7 @@ export const VectorsBuckets = () => {
{isLoadingBuckets && <GenericSkeletonLoader />}
{isErrorBuckets && (
<AlertError error={bucketsError} subject="Failed to retrieve vector buckets" />
)}
{isErrorBuckets && <VectorBucketsErrorState error={bucketsError} />}
{isSuccessBuckets && (
<>
@@ -0,0 +1,113 @@
import { waitFor } from '@testing-library/react'
import { HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import {
useIsVectorBucketsEnabled,
type ProjectStorageConfigData,
} from './project-storage-config-query'
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { customRenderHook } from '@/tests/lib/custom-render'
import { addAPIMock } from '@/tests/lib/msw'
const { mockIsPlatform, mockUseDeploymentMode } = vi.hoisted(() => ({
mockIsPlatform: { value: false },
mockUseDeploymentMode: vi.fn<() => DeploymentMode>(),
}))
// `useProjectStorageConfigQuery` (same module as the hook under test) gates its
// fetch on the build-time `IS_PLATFORM` constant — mock it so the query fires in
// the platform cases.
vi.mock('@/lib/constants', async () => {
const actual = await vi.importActual<Record<string, unknown>>('@/lib/constants')
return {
...actual,
get IS_PLATFORM() {
return mockIsPlatform.value
},
}
})
vi.mock('@/hooks/misc/useDeploymentMode', () => ({
useDeploymentMode: mockUseDeploymentMode,
}))
const createStorageConfig = (vectorBucketsEnabled: boolean): ProjectStorageConfigData =>
({
capabilities: { iceberg_catalog: false, list_v2: false },
databasePoolMode: 'transaction',
external: { upstreamTarget: 'main' },
features: {
icebergCatalog: { enabled: false, maxCatalogs: 0, maxNamespaces: 0, maxTables: 0 },
imageTransformation: { enabled: false },
s3Protocol: { enabled: false },
vectorBuckets: { enabled: vectorBucketsEnabled, maxBuckets: 0, maxIndexes: 0 },
},
fileSizeLimit: 0,
migrationVersion: 'v1',
}) as ProjectStorageConfigData
const deploymentMode = (overrides: Partial<DeploymentMode>): DeploymentMode => ({
isPlatform: false,
isCli: false,
isSelfHosted: false,
...overrides,
})
describe('useIsVectorBucketsEnabled', () => {
beforeEach(() => {
mockIsPlatform.value = false
mockUseDeploymentMode.mockReset()
})
test('platform + storage config flag enabled: true', async () => {
mockIsPlatform.value = true
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/config/storage',
response: () => HttpResponse.json<ProjectStorageConfigData>(createStorageConfig(true)),
})
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
await waitFor(() => expect(result.current).toBe(true))
})
test('platform + storage config flag disabled: false', async () => {
mockIsPlatform.value = true
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
let configRequested = false
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/config/storage',
response: () => {
configRequested = true
return HttpResponse.json<ProjectStorageConfigData>(createStorageConfig(false))
},
})
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
await waitFor(() => expect(configRequested).toBe(true))
expect(result.current).toBe(false)
})
test('CLI: true regardless of the storage config flag', () => {
// Query is disabled off-platform, so no storage config endpoint is needed.
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isCli: true }))
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
expect(result.current).toBe(true)
})
test('self-hosted: false', () => {
mockUseDeploymentMode.mockReturnValue(deploymentMode({ isSelfHosted: true }))
const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
expect(result.current).toBe(false)
})
})
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { configKeys } from './keys'
import { components } from '@/data/api'
import { get, handleError } from '@/data/fetchers'
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
@@ -60,6 +61,8 @@ export const useIsAnalyticsBucketsEnabled = ({ projectRef }: { projectRef?: stri
export const useIsVectorBucketsEnabled = ({ projectRef }: { projectRef?: string }) => {
const { data } = useProjectStorageConfigQuery({ projectRef })
const isVectorBucketsEnabled = !!data?.features.vectorBuckets?.enabled
const { isCli, isPlatform } = useDeploymentMode()
const isVectorBucketsEnabled = isCli || (isPlatform && !!data?.features.vectorBuckets?.enabled)
return isVectorBucketsEnabled
}
+1
View File
@@ -5,4 +5,5 @@ export const miscKeys = {
ipAddress: () => ['ip-address'] as const,
clockSkew: () => ['clock-skew'] as const,
enabledFeaturesOverride: () => ['enabled-features-override'] as const,
localS3Keys: () => ['local-s3-keys'] as const,
}
@@ -0,0 +1,32 @@
import { useQuery } from '@tanstack/react-query'
import { miscKeys } from './keys'
import { fetchHandler } from '@/data/fetchers'
import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
export async function getLocalS3Keys() {
try {
const data = await fetchHandler(`${BASE_PATH}/api/get-s3-keys`).then((res) => res.json())
return data as { accessKey?: string; secretKey?: string }
} catch (error) {
throw error
}
}
export type LocalS3KeysData = Awaited<ReturnType<typeof getLocalS3Keys>>
export type LocalS3KeysError = ResponseError
/**
* Specifically only for local CLI - to use the S3 keys as defined in the env file
*/
export const useLocalS3KeysQuery = <TData = LocalS3KeysData>({
enabled = true,
...options
}: UseCustomQueryOptions<LocalS3KeysData, LocalS3KeysError, TData> = {}) =>
useQuery<LocalS3KeysData, LocalS3KeysError, TData>({
queryKey: miscKeys.localS3Keys(),
queryFn: () => getLocalS3Keys(),
enabled: enabled && !IS_PLATFORM,
...options,
})
@@ -1,5 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { IS_PLATFORM } from 'common'
import { useLocalS3KeysQuery } from '../misc/local-s3-keys-query'
import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation'
import { WRAPPERS } from '@/components/interfaces/Integrations/Wrappers/Wrappers.constants'
import { getVectorURI } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
@@ -11,14 +13,29 @@ import {
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { FDWCreateVariables, useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const useS3VectorsWrapperCreateMutation = () => {
const { data: project } = useSelectedProjectQuery()
const { data: localKeys } = useLocalS3KeysQuery()
const { isPlatform } = useDeploymentMode()
const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref })
const protocol = settings?.app_config?.protocol ?? 'https'
const endpoint = settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint
/**
* [Joshen] Endpoint for vectors FDW needs to use docker domain
* Not sure if this affects other areas of the dashboard hence why conditionally rendering here
* instead of updating `lib/api/self-hosted/settings -> api_config`
*/
const port =
!isPlatform && !!settings
? new URL(`${protocol}://${settings.app_config?.endpoint}`).port
: null
const endpoint = !isPlatform
? `host.docker.internal:${port}`
: settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint
const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 's3_vectors_wrapper')
@@ -30,13 +47,32 @@ export const useS3VectorsWrapperCreateMutation = () => {
const { mutateAsync: createS3AccessKey, isPending: isCreatingS3AccessKey } =
useS3AccessKeyCreateMutation()
const { mutateAsync: createFDW, isPending: isCreatingFDW } = useFDWCreateMutation()
// [Joshen] Silence the error, handled upstream
const { mutateAsync: createFDW, isPending: isCreatingFDW } = useFDWCreateMutation({
onError: () => {},
})
const mutateAsync = async ({ bucketName }: { bucketName: string }) => {
const createS3KeyData = await createS3AccessKey({
projectRef: project?.ref,
description: getVectorBucketS3KeyName(bucketName),
})
let accessKey: string | undefined
let secretKey: string | undefined
if (IS_PLATFORM) {
const createS3KeyData = await createS3AccessKey({
projectRef: project?.ref,
description: getVectorBucketS3KeyName(bucketName),
})
accessKey = createS3KeyData.access_key
secretKey = createS3KeyData.secret_key
} else {
accessKey = localKeys?.accessKey
secretKey = localKeys?.secretKey
}
if (!accessKey || !secretKey) {
throw new Error(
IS_PLATFORM ? 'Failed to obtain S3 keys from the API' : 'Local S3 keys are not configured'
)
}
const wrapperName = getVectorBucketFDWName(bucketName)
const serverName = getVectorBucketFDWServerName(bucketName)
@@ -48,8 +84,8 @@ export const useS3VectorsWrapperCreateMutation = () => {
formState: {
wrapper_name: wrapperName,
server_name: serverName,
vault_access_key_id: createS3KeyData?.access_key,
vault_secret_access_key: createS3KeyData?.secret_key,
vault_access_key_id: accessKey,
vault_secret_access_key: secretKey,
aws_region: settings!.region,
endpoint_url: getVectorURI(project?.ref ?? '', protocol, endpoint),
},
@@ -51,7 +51,7 @@ describe('api/self-hosted/settings', () => {
expect(settings.db_port).toBe(5432)
expect(settings.db_user).toBe('postgres')
expect(settings.ref).toBe('default')
expect(settings.region).toBe('ap-southeast-1')
expect(settings.region).toBe('local')
expect(settings.status).toBe('ACTIVE_HEALTHY')
expect(settings.ssl_enforced).toBe(false)
})
+1 -1
View File
@@ -40,7 +40,7 @@ export function getProjectSettings() {
process.env.AUTH_JWT_SECRET ?? 'super-secret-jwt-token-with-at-least-32-characters-long',
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
ref: 'default',
region: 'ap-southeast-1',
region: 'local',
service_api_keys: [
{
api_key: process.env.SUPABASE_SERVICE_KEY ?? '',
+10
View File
@@ -0,0 +1,10 @@
import { NextApiRequest, NextApiResponse } from 'next'
const accessKey = process.env.S3_PROTOCOL_ACCESS_KEY_ID
const secretKey = process.env.S3_PROTOCOL_ACCESS_KEY_SECRET
const handler = async (_req: NextApiRequest, res: NextApiResponse) => {
return res.status(200).json({ accessKey: accessKey, secretKey })
}
export default handler
@@ -0,0 +1,37 @@
import { createClient } from '@supabase/supabase-js'
import { NextApiRequest, NextApiResponse } from 'next'
import apiWrapper from '@/lib/api/apiWrapper'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
// eslint-disable-next-line import/no-anonymous-default-export
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
switch (method) {
case 'GET':
return handleGet(req, res)
case 'DELETE':
return handleDelete(req, res)
default:
res.setHeader('Allow', ['GET', 'PATCH', 'DELETE'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
const { id } = req.query
const { data, error } = await supabase.storage.vectors.getBucket(id as string)
if (error) return res.status(400).json({ error: { message: error.message } })
return res.status(200).json(data.vectorBucket)
}
const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
const { id } = req.query
const { data, error } = await supabase.storage.vectors.deleteBucket(id as string)
if (error) return res.status(400).json({ error: { message: error.message } })
return res.status(200).json(data)
}
@@ -0,0 +1,32 @@
import { createClient } from '@supabase/supabase-js'
import { NextApiRequest, NextApiResponse } from 'next'
import apiWrapper from '@/lib/api/apiWrapper'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
// eslint-disable-next-line import/no-anonymous-default-export
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
switch (method) {
case 'DELETE':
return handleDelete(req, res)
default:
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
const { id, indexName } = req.query
const { data, error } = await supabase.storage.vectors
.from(id as string)
.deleteIndex(indexName as string)
if (error) return res.status(400).json({ error: { message: error.message } })
return res.status(200).json(data)
}
@@ -0,0 +1,58 @@
import { createClient } from '@supabase/supabase-js'
import { NextApiRequest, NextApiResponse } from 'next'
import apiWrapper from '@/lib/api/apiWrapper'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
// eslint-disable-next-line import/no-anonymous-default-export
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
switch (method) {
case 'GET':
return handleGet(req, res)
case 'POST':
return handlePost(req, res)
default:
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
const { id } = req.query
const { data, error } = await supabase.storage.vectors
.from(id as string)
.listIndexes({ maxResults: 100 })
if (error) return res.status(500).json({ error: { message: error.message } })
const indexes = await Promise.all(
data.indexes.map(async ({ indexName }) => {
return (await supabase.storage.vectors.from(id as string).getIndex(indexName)).data?.index
})
)
return res.status(200).json({ indexes, nextToken: data.nextToken })
}
const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
const { id } = req.query
const { indexName, dataType, dimension, distanceMetric, metadataKeys } = req.body
const payload = {
indexName,
dataType,
dimension,
distanceMetric,
metadataConfiguration: { nonFilterableMetadataKeys: metadataKeys },
}
const { data, error } = await supabase.storage.vectors.from(id as string).createIndex(payload)
if (error) return res.status(400).json({ error: { message: error.message } })
return res.status(200).json(data)
}
@@ -0,0 +1,38 @@
import { createClient } from '@supabase/supabase-js'
import { NextApiRequest, NextApiResponse } from 'next'
import apiWrapper from '@/lib/api/apiWrapper'
const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
// eslint-disable-next-line import/no-anonymous-default-export
export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
async function handler(req: NextApiRequest, res: NextApiResponse) {
const { method } = req
switch (method) {
case 'GET':
return handleGet(req, res)
case 'POST':
return handlePost(req, res)
default:
res.setHeader('Allow', ['GET', 'POST'])
res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
}
}
const handleGet = async (_req: NextApiRequest, res: NextApiResponse) => {
const { data, error } = await supabase.storage.vectors.listBuckets()
if (error) return res.status(500).json({ error: { message: error.message } })
return res.status(200).json(data)
}
const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
const { bucketName } = req.body
const { data, error } = await supabase.storage.vectors.createBucket(bucketName)
if (error) return res.status(400).json({ error: { message: error.message } })
return res.status(200).json(data)
}
@@ -1,4 +1,4 @@
import { useParams } from 'common'
import { IS_PLATFORM, useParams } from 'common'
import { BucketsUpgradePlan } from '@/components/interfaces/Storage/BucketsUpgradePlan'
import { VectorsBuckets } from '@/components/interfaces/Storage/VectorBuckets'
@@ -23,10 +23,12 @@ const StorageVectorsPage: NextPageWithLayout = () => {
project?.region ?? ''
)
if (!isAvailableInProjectRegion) {
if (IS_PLATFORM && !isAvailableInProjectRegion) {
return <RegionLimitation />
} else if (!isVectorBucketsEnabled) {
} else if (IS_PLATFORM && !isVectorBucketsEnabled) {
return <BucketsUpgradePlan type="vector" />
} else if (!isVectorBucketsEnabled) {
return null
} else {
return <VectorsBuckets />
}
@@ -0,0 +1,155 @@
import { screen, waitFor } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { beforeEach, describe, expect, test, vi } from 'vitest'
import type { ProjectStorageConfigData } from '@/data/config/project-storage-config-query'
import type { ProjectDetail } from '@/data/projects/project-detail-query'
import { API_URL } from '@/lib/constants'
import StorageVectorsPage from '@/pages/project/[ref]/storage/vectors'
import { customRender } from '@/tests/lib/custom-render'
import { addAPIMock, mswServer } from '@/tests/lib/msw'
const { mockIsPlatform } = vi.hoisted(() => ({
mockIsPlatform: { value: true },
}))
// `IS_PLATFORM` is a build-time constant, so it can't be driven over the
// network — mock it in both modules that read it (the page imports from
// `common`; the data hooks read from `@/lib/constants`). Everything else the
// page branches on comes from real queries via MSW.
vi.mock('common', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
useParams: () => ({ ref: 'default' }),
get IS_PLATFORM() {
return mockIsPlatform.value
},
}
})
vi.mock('@/lib/constants', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>()
return {
...actual,
get IS_PLATFORM() {
return mockIsPlatform.value
},
}
})
// Stub the heavy leaf children — each fires its own queries and renders a large
// tree. We only assert which branch the page picks. Keep the real
// `VECTOR_BUCKETS_AVAILABLE_REGIONS` so the region gate runs for real.
vi.mock(
'@/components/interfaces/Storage/VectorBuckets/RegionLimitation',
async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
RegionLimitation: () => <div>region-limitation</div>,
})
)
vi.mock('@/components/interfaces/Storage/VectorBuckets', () => ({
VectorsBuckets: () => <div>vectors-buckets</div>,
}))
vi.mock('@/components/interfaces/Storage/BucketsUpgradePlan', () => ({
BucketsUpgradePlan: ({ type }: { type: string }) => <div>buckets-upgrade-plan-{type}</div>,
}))
const AVAILABLE_REGION = 'us-east-1'
const UNAVAILABLE_REGION = 'ap-south-1'
const mockProject = (region: string) => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref',
// The page only reads `region` off the project
response: () => HttpResponse.json<ProjectDetail>({ region } as unknown as ProjectDetail),
})
}
const mockStorageConfig = (vectorBucketsEnabled: boolean) => {
addAPIMock({
method: 'get',
path: '/platform/projects/:ref/config/storage',
response: () =>
HttpResponse.json<ProjectStorageConfigData>({
capabilities: { iceberg_catalog: false, list_v2: false },
databasePoolMode: 'transaction',
external: { upstreamTarget: 'main' },
features: {
icebergCatalog: { enabled: false, maxCatalogs: 0, maxNamespaces: 0, maxTables: 0 },
imageTransformation: { enabled: false },
s3Protocol: { enabled: false },
vectorBuckets: { enabled: vectorBucketsEnabled, maxBuckets: 0, maxIndexes: 0 },
},
fileSizeLimit: 0,
migrationVersion: 'v1',
} as ProjectStorageConfigData),
})
}
const mockDeploymentMode = (isCli: boolean) => {
mswServer.use(
http.get(`${API_URL}/platform/deployment-mode`, () => HttpResponse.json({ is_cli_mode: isCli }))
)
}
describe('StorageVectorsPage', () => {
beforeEach(() => {
mockIsPlatform.value = true
mockProject(AVAILABLE_REGION)
})
test('platform + region not supported: shows region limitation', async () => {
mockProject(UNAVAILABLE_REGION)
mockStorageConfig(true)
customRender(<StorageVectorsPage dehydratedState={undefined} />)
expect(await screen.findByText('region-limitation')).toBeInTheDocument()
expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
})
test('platform + supported region + not enabled: shows upgrade plan', async () => {
mockStorageConfig(false)
customRender(<StorageVectorsPage dehydratedState={undefined} />)
expect(await screen.findByText('buckets-upgrade-plan-vector')).toBeInTheDocument()
expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
})
test('platform + supported region + enabled: shows vector buckets', async () => {
mockStorageConfig(true)
customRender(<StorageVectorsPage dehydratedState={undefined} />)
expect(await screen.findByText('vectors-buckets')).toBeInTheDocument()
})
test('CLI (non-platform, enabled): shows vector buckets, skips region/upgrade gates', async () => {
mockIsPlatform.value = false
// Even an unsupported region shouldn't gate off-platform
mockProject(UNAVAILABLE_REGION)
mockDeploymentMode(true)
customRender(<StorageVectorsPage dehydratedState={undefined} />)
expect(await screen.findByText('vectors-buckets')).toBeInTheDocument()
expect(screen.queryByText('region-limitation')).not.toBeInTheDocument()
})
test('self-hosted (non-platform): renders nothing', async () => {
mockIsPlatform.value = false
mockDeploymentMode(false)
const { container } = customRender(<StorageVectorsPage dehydratedState={undefined} />)
// `useDeploymentMode` defaults to CLI during its loading window, so the page
// briefly renders before resolving to self-hosted — wait for it to settle.
await waitFor(() => expect(container).toBeEmptyDOMElement())
expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
})
})
+2
View File
@@ -107,6 +107,8 @@
"VERCEL_GIT_COMMIT_SHA",
"SNIPPETS_MANAGEMENT_FOLDER",
"EDGE_FUNCTIONS_MANAGEMENT_FOLDER",
"S3_PROTOCOL_ACCESS_KEY_ID",
"S3_PROTOCOL_ACCESS_KEY_SECRET",
],
"outputs": [".next/**", "!.next/cache/**", "!.next/dev/**/*"],
},