Files
supabase/apps/studio/data/read-replicas/replicas-query.ts
62fe0fca4a fix(studio): guard branches and read-replicas queries against non-array 200 bodies (#50347)
## Summary
- `getBranches()` (`apps/studio/data/branches/branches-query.ts`) and
`getReadReplicas()` (`apps/studio/data/read-replicas/replicas-query.ts`)
cast the raw API response body to an array with no runtime check.
- When the endpoint returns a defined-but-non-array 200 body, the `??
[]` fallback in each consumer doesn't catch it (the value isn't
nullish), and the first `.find`/`.filter` call throws, crashing the
whole page via `globalErrorBoundary`.
- This is the same known class of bug already fixed elsewhere in the
codebase (e.g. `apps/studio/data/lint/lint-query.ts`, and the
`api-keys`/`oauthApps`/`secrets` fetchers) — applies the same
`Array.isArray(data) ? data : EMPTY_ARR` guard.

## Evidence (Sentry, past week)
- [SUPABASE-APP-KA2](https://supabase.sentry.io/issues/7722780387/) —
`(m??[]).find is not a function` in `ActivityStats.tsx`
(`branchesData.find`), full-page crash on `/dashboard/project/[ref]`.
- [SUPABASE-APP-KAE](https://supabase.sentry.io/issues/7729679561/) —
`u.filter is not a function` in `AWSPrivateLinkForm.tsx`
(`databases.filter`), full-page crash on
`/project/[ref]/settings/integrations`.

## Test plan
- [ ] Existing query hook tests still pass
- [ ] Manually verified `Array.isArray` guard mirrors the established
`lint-query.ts` pattern

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01RUrmUfMBpPqkgerh9onNTM

---
_Generated by [Claude
Code](https://claude.ai/code/session_01RUrmUfMBpPqkgerh9onNTM)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
2026-09-14 19:16:41 +00:00

131 lines
4.4 KiB
TypeScript

import { useQuery } from '@tanstack/react-query'
import { LOCAL_STORAGE_KEYS, useFeatureFlags, useFlag, useParams } from 'common'
import { replicaKeys } from './keys'
import { DashboardPreference } from '@/components/interfaces/Settings/General/DashboardPreferences'
import type { components } from '@/data/api'
import { get, handleError } from '@/data/fetchers'
import { useLocalStorageQuery } from '@/hooks/misc/useLocalStorage'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { EMPTY_ARR } from '@/lib/void'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
/**
* Unless explicitly defined here, caps default to `READ_REPLICAS_MAX_COUNT`.
*/
export const READ_REPLICA_COMPUTE_CAPS: Record<string, number> = {
ci_pico: 0,
ci_nano: 0,
ci_micro: 0,
ci_small: 4,
ci_medium: 4,
ci_large: 4,
}
export const READ_REPLICAS_MAX_COUNT = 5
export function getMaxReplicas(computeAddon?: string): number {
return READ_REPLICA_COMPUTE_CAPS[`${computeAddon}`] ?? READ_REPLICAS_MAX_COUNT
}
export type ReadReplicasVariables = {
projectRef?: string
}
export type Database = components['schemas']['DatabaseDetailResponse_Output']
export async function getReadReplicas(
{ projectRef }: ReadReplicasVariables,
signal?: AbortSignal,
headers?: HeadersInit
) {
if (!projectRef) throw new Error('Project ref is required')
const { data, error } = await get(`/platform/projects/{ref}/databases`, {
params: { path: { ref: projectRef } },
headers,
signal,
})
if (error) handleError(error)
return Array.isArray(data) ? data : EMPTY_ARR
}
export type ReadReplicasData = Awaited<ReturnType<typeof getReadReplicas>>
export type ReadReplicasError = ResponseError
export const useReadReplicasQuery = <TData = ReadReplicasData>(
{ projectRef }: ReadReplicasVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<ReadReplicasData, ReadReplicasError, TData> = {}
) => {
return useQuery<ReadReplicasData, ReadReplicasError, TData>({
queryKey: replicaKeys.list(projectRef),
queryFn: ({ signal }) => getReadReplicas({ projectRef }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
...options,
})
}
export const usePrimaryDatabase = ({ projectRef }: { projectRef?: string }) => {
const {
data: databases = [],
error,
isPending: isLoading,
isError,
isSuccess,
} = useReadReplicasQuery({ projectRef })
const primaryDatabase = databases.find((x) => x.identifier === projectRef)
return { database: primaryDatabase, error, isLoading, isError, isSuccess }
}
/**
* [Joshen] JFYI this logic here can and should be optimized
* Returns the connection string of read replica if available, otherwise default to project's (primary)
* If multiple read replicas available, (naively) prioritise replica in the same region as primary
* to minimize any latency. Otherwise just use the first available read replica
*/
export const useConnectionStringForReadOps = (): {
type: 'replica' | 'primary' | undefined
identifier: string | undefined
connectionString: string | undefined | null
} => {
const { ref: projectRef } = useParams()
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const showDashboardPreferences = useFlag('dashboardPreferences')
const [dashboardPreferences, , { isLoading: isLoadingLocalStorage }] =
useLocalStorageQuery<DashboardPreference>(
LOCAL_STORAGE_KEYS.DASHBOARD_PREFERENCES(projectRef ?? '_'),
{}
)
const { data: project, isSuccess: isSuccessProject } = useSelectedProjectQuery()
const { data: databases = [], isLoading: isLoadingDatabases } = useReadReplicasQuery({
projectRef: project?.ref,
})
if (!isSuccessProject || isLoadingDatabases || !flagsLoaded || isLoadingLocalStorage) {
return { connectionString: undefined, type: undefined, identifier: undefined }
}
if (!showDashboardPreferences) {
return { type: 'primary', identifier: project.ref, connectionString: project.connectionString }
}
const readReplicas = databases.filter(
(x) => x.identifier !== project?.ref && x.status === 'ACTIVE_HEALTHY'
)
const readReplica = readReplicas.find(
(x) => x.identifier === dashboardPreferences.defaultDatabase
)
return {
type: !!readReplica ? 'replica' : 'primary',
identifier: !!readReplica ? readReplica.identifier : project.ref,
connectionString: !!readReplica ? readReplica.connectionString : project.connectionString,
}
}