diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx
new file mode 100644
index 0000000000000..244222531a937
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.test.tsx
@@ -0,0 +1,53 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { PublicationsAvailability } from './PublicationsAvailability'
+
+const { mockUseHighAvailability } = vi.hoisted(() => ({
+ mockUseHighAvailability: vi.fn(),
+}))
+
+vi.mock('@/hooks/misc/useHighAvailability', () => ({
+ useHighAvailability: mockUseHighAvailability,
+}))
+
+describe('PublicationsAvailability', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('shows the disabled empty state instead of page content for High Availability projects', () => {
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: true })
+
+ render(
+
+ Publications content
+
+ )
+
+ expect(
+ screen.getByText('Publications unavailable on High Availability projects')
+ ).toBeInTheDocument()
+ expect(
+ screen.getByText(
+ "We're working to bring publications to High Availability projects. Contact support if this is blocking your work."
+ )
+ ).toBeInTheDocument()
+ expect(screen.queryByText('Publications content')).not.toBeInTheDocument()
+ })
+
+ it('renders page content for non-High Availability projects', () => {
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: false })
+
+ render(
+
+ Publications content
+
+ )
+
+ expect(screen.getByText('Publications content')).toBeInTheDocument()
+ expect(
+ screen.queryByText('Publications unavailable on High Availability projects')
+ ).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx
new file mode 100644
index 0000000000000..d38156ada7144
--- /dev/null
+++ b/apps/studio/components/interfaces/Database/Publications/PublicationsAvailability.tsx
@@ -0,0 +1,23 @@
+import { BookOpen } from 'lucide-react'
+import type { PropsWithChildren } from 'react'
+
+import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
+
+export const PublicationsAvailability = ({ children }: PropsWithChildren) => {
+ const { isHighAvailability } = useHighAvailability()
+
+ if (isHighAvailability) {
+ return (
+
+
+
+ )
+ }
+
+ return children
+}
diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx
new file mode 100644
index 0000000000000..9718945acbcdb
--- /dev/null
+++ b/apps/studio/components/interfaces/Organization/Usage/Usage.test.tsx
@@ -0,0 +1,90 @@
+import { screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { Usage } from './Usage'
+import { customRender } from '@/tests/lib/custom-render'
+
+const {
+ mockUseAsyncCheckPermissions,
+ mockUseOrgDailyStatsQuery,
+ mockUseOrgSubscriptionQuery,
+ mockUseProjectDetailQuery,
+} = vi.hoisted(() => ({
+ mockUseAsyncCheckPermissions: vi.fn(),
+ mockUseOrgDailyStatsQuery: vi.fn(),
+ mockUseOrgSubscriptionQuery: vi.fn(),
+ mockUseProjectDetailQuery: vi.fn(),
+}))
+
+vi.mock('common', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useParams: () => ({ slug: 'test-org' }),
+}))
+
+vi.mock('@/hooks/misc/useCheckPermissions', () => ({
+ useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
+}))
+
+vi.mock('@/data/analytics/org-daily-stats-query', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useOrgDailyStatsQuery: mockUseOrgDailyStatsQuery,
+}))
+
+vi.mock('@/data/projects/project-detail-query', () => ({
+ useProjectDetailQuery: mockUseProjectDetailQuery,
+}))
+
+vi.mock('@/data/subscriptions/org-subscription-query', () => ({
+ useOrgSubscriptionQuery: mockUseOrgSubscriptionQuery,
+}))
+
+describe('Usage', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockUseAsyncCheckPermissions.mockReturnValue({ can: true, isLoading: false })
+ mockUseProjectDetailQuery.mockReturnValue({
+ data: {
+ ref: 'ha-project',
+ name: 'HA project',
+ high_availability: true,
+ },
+ isPending: false,
+ })
+ mockUseOrgSubscriptionQuery.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isPending: false,
+ isError: false,
+ isSuccess: false,
+ })
+ mockUseOrgDailyStatsQuery.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isPending: false,
+ isError: false,
+ })
+ })
+
+ it('shows a coming-soon empty state for a High Availability project', () => {
+ customRender( , {
+ nuqs: { searchParams: { projectRef: 'ha-project' } },
+ })
+
+ expect(screen.getByText('Usage unavailable on High Availability projects')).toBeInTheDocument()
+ expect(
+ screen.getByText('Usage insights for High Availability projects are coming soon.')
+ ).toBeInTheDocument()
+ expect(screen.queryByText('Usage filtered by project')).not.toBeInTheDocument()
+
+ expect(mockUseProjectDetailQuery).toHaveBeenCalledWith({ ref: 'ha-project' })
+ expect(mockUseOrgSubscriptionQuery).toHaveBeenCalledWith(
+ { orgSlug: 'test-org' },
+ { enabled: false }
+ )
+ expect(mockUseOrgDailyStatsQuery).toHaveBeenCalledWith(
+ expect.objectContaining({ orgSlug: 'test-org', projectRef: 'ha-project' }),
+ { enabled: false }
+ )
+ })
+})
diff --git a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx
index 436a2894f77f2..90138f2caeca7 100644
--- a/apps/studio/components/interfaces/Organization/Usage/Usage.tsx
+++ b/apps/studio/components/interfaces/Organization/Usage/Usage.tsx
@@ -1,7 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useParams } from 'common'
import dayjs from 'dayjs'
-import { Check, ChevronDown } from 'lucide-react'
+import { ChartArea, Check, ChevronDown } from 'lucide-react'
import Link from 'next/link'
import { useQueryState } from 'nuqs'
import { useMemo, useState } from 'react'
@@ -26,12 +26,14 @@ import {
} from '@/components/layouts/Scaffold'
import { AlertError } from '@/components/ui/AlertError'
import { DateRangePicker } from '@/components/ui/DateRangePicker'
+import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState'
import { NoPermission } from '@/components/ui/NoPermission'
import { OrganizationProjectSelector } from '@/components/ui/OrganizationProjectSelector'
import { useOrgDailyStatsQuery } from '@/data/analytics/org-daily-stats-query'
import { useProjectDetailQuery } from '@/data/projects/project-detail-query'
import { useOrgSubscriptionQuery } from '@/data/subscriptions/org-subscription-query'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { resolveHighAvailability } from '@/hooks/misc/useHighAvailability'
import { TIME_PERIODS_BILLING, TIME_PERIODS_REPORTS } from '@/lib/constants/metrics'
export const Usage = () => {
@@ -42,6 +44,12 @@ export const Usage = () => {
const [selectedProjectRef, setSelectedProjectRef] = useQueryState('projectRef')
const [openProjectSelector, setOpenProjectSelector] = useState(false)
+ const { data: selectedProject, isPending: isLoadingSelectedProject } = useProjectDetailQuery({
+ ref: selectedProjectRef ?? undefined,
+ })
+ const isHighAvailability = resolveHighAvailability(selectedProject)
+ const canLoadUsage = !selectedProjectRef || (!isLoadingSelectedProject && !isHighAvailability)
+
const { can: canReadSubscriptions, isLoading: isLoadingPermissions } = useAsyncCheckPermissions(
PermissionAction.BILLING_READ,
'stripe.subscriptions'
@@ -53,11 +61,7 @@ export const Usage = () => {
isPending: isLoadingSubscription,
isError: isErrorSubscription,
isSuccess: isSuccessSubscription,
- } = useOrgSubscriptionQuery({ orgSlug: slug })
-
- const { data: selectedProject } = useProjectDetailQuery({
- ref: selectedProjectRef ?? undefined,
- })
+ } = useOrgSubscriptionQuery({ orgSlug: slug }, { enabled: canLoadUsage })
const billingCycleStart = useMemo(() => {
return dayjs.unix(subscription?.current_period_start ?? 0).utc()
@@ -107,12 +111,27 @@ export const Usage = () => {
error: orgDailyStatsError,
isPending: isLoadingOrgDailyStats,
isError: isErrorOrgDailyStats,
- } = useOrgDailyStatsQuery({
- orgSlug: slug,
- projectRef: selectedProjectRef ?? undefined,
- startDate,
- endDate,
- })
+ } = useOrgDailyStatsQuery(
+ {
+ orgSlug: slug,
+ projectRef: selectedProjectRef ?? undefined,
+ startDate,
+ endDate,
+ },
+ { enabled: canLoadUsage }
+ )
+
+ if (isHighAvailability) {
+ return (
+
+
+
+ )
+ }
return (
<>
diff --git a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx
index ebffc046a6c04..6b150756a5d91 100644
--- a/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx
+++ b/apps/studio/components/interfaces/OrganizationInvite/OrganizationInvite.tsx
@@ -194,7 +194,7 @@ export const OrganizationInvite = () => {
Accept invite
- Decline
+ Decline
diff --git a/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx
index 0b78c9f0378a8..5053016ed3981 100644
--- a/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx
+++ b/apps/studio/components/interfaces/Project/ResumeProjectButton.tsx
@@ -2,7 +2,7 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { PermissionAction } from '@supabase/shared-types/out/constants'
import { useFlag, useParams } from 'common'
import { useRouter } from 'next/router'
-import { useMemo, useState, type ComponentPropsWithoutRef } from 'react'
+import { useMemo, useRef, useState, type ComponentPropsWithoutRef } from 'react'
import { useForm } from 'react-hook-form'
import { AWS_REGIONS, CloudProvider } from 'shared-data'
import { toast } from 'sonner'
@@ -99,6 +99,7 @@ export const ResumeProjectButton = ({
mode: 'onChange',
defaultValues: { postgresVersionSelection: '' },
})
+ const lastValidPostgresVersionSelection = useRef('')
const onSelectRestore = () => {
if (project?.status !== PROJECT_STATUS.INACTIVE) {
@@ -209,6 +210,7 @@ export const ResumeProjectButton = ({
dbRegion={region?.displayName ?? ''}
cloudProvider={(project?.cloud_provider ?? 'AWS') as CloudProvider}
organizationSlug={selectedOrganization?.slug}
+ lastValidSelectionRef={lastValidPostgresVersionSelection}
/>
)}
/>
diff --git a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx
index fce289df2746d..93b8e7ad0f810 100644
--- a/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/HighAvailabilityInput.tsx
@@ -1,5 +1,7 @@
+import { useEffect, useRef } from 'react'
import { UseFormReturn } from 'react-hook-form'
-import { FormControl, FormField, Switch } from 'ui'
+import { type CloudProvider } from 'shared-data'
+import { Badge, FormControl, FormField, Switch, useWatch } from 'ui'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
import { CreateProjectForm } from './ProjectCreation.schema'
@@ -8,10 +10,86 @@ import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
interface HighAvailabilityInputProps {
form: UseFormReturn
+ // Derived from an org-wide regions query owned by the parent form, since fetching it
+ // here would mean duplicating that query's slug/cloud-provider/instance-size context.
+ highAvailabilityRegionName: string | undefined
}
-export const HighAvailabilityInput = ({ form }: HighAvailabilityInputProps) => {
+export const HighAvailabilityInput = ({
+ form,
+ highAvailabilityRegionName,
+}: HighAvailabilityInputProps) => {
+ const { getValues, setValue } = form
const { hasAccess } = useCheckEntitlements('instances.high_availability')
+ const highAvailability = useWatch({ control: form.control, name: 'highAvailability' })
+
+ // Fields to revert to when toggling off HA, so previously selected values aren't lost.
+ const beforeHighAvailability = useRef<{
+ cloudProvider: CloudProvider | undefined
+ postgresVersionSelection: string | undefined
+ dbRegion: string | null
+ }>({
+ cloudProvider: undefined,
+ postgresVersionSelection: undefined,
+ dbRegion: null,
+ })
+
+ const handleHighAvailabilityChange = (checked: boolean) => {
+ if (checked) {
+ const currentCloudProvider = getValues('cloudProvider') as CloudProvider
+ if (currentCloudProvider !== 'AWS_K8S') {
+ beforeHighAvailability.current.cloudProvider = currentCloudProvider
+ setValue('cloudProvider', 'AWS_K8S')
+ }
+
+ beforeHighAvailability.current.postgresVersionSelection = getValues(
+ 'postgresVersionSelection'
+ )
+ setValue('useOrioleDb', false)
+
+ const currentRegion = getValues('dbRegion')
+ if (
+ highAvailabilityRegionName !== undefined &&
+ currentRegion !== highAvailabilityRegionName
+ ) {
+ beforeHighAvailability.current.dbRegion = currentRegion ?? null
+ setValue('dbRegion', highAvailabilityRegionName)
+ }
+ } else {
+ if (beforeHighAvailability.current.cloudProvider !== undefined) {
+ setValue('cloudProvider', beforeHighAvailability.current.cloudProvider)
+ beforeHighAvailability.current.cloudProvider = undefined
+ }
+
+ if (beforeHighAvailability.current.postgresVersionSelection !== undefined) {
+ setValue(
+ 'postgresVersionSelection',
+ beforeHighAvailability.current.postgresVersionSelection
+ )
+ beforeHighAvailability.current.postgresVersionSelection = undefined
+ }
+
+ if (beforeHighAvailability.current.dbRegion !== null) {
+ setValue('dbRegion', beforeHighAvailability.current.dbRegion)
+ beforeHighAvailability.current.dbRegion = null
+ }
+ }
+ }
+
+ // Catches the case where highAvailabilityRegionName wasn't loaded yet at the moment the
+ // toggle fired above (the org's available-regions query for AWS_K8S may still be in
+ // flight). The region auto-fill effect in the parent form skips dirty fields, so a
+ // manually chosen region would otherwise keep showing in the trigger — force it over
+ // explicitly once the HA region becomes known.
+ useEffect(() => {
+ if (!highAvailability || highAvailabilityRegionName === undefined) return
+ const currentRegion = getValues('dbRegion')
+ if (currentRegion === highAvailabilityRegionName) return
+ if (beforeHighAvailability.current.dbRegion === null) {
+ beforeHighAvailability.current.dbRegion = currentRegion ?? null
+ }
+ setValue('dbRegion', highAvailabilityRegionName)
+ }, [highAvailability, highAvailabilityRegionName, getValues, setValue])
if (!hasAccess) return null
@@ -22,12 +100,24 @@ export const HighAvailabilityInput = ({ form }: HighAvailabilityInputProps) => {
name="highAvailability"
render={({ field }) => (
+ High availability
+ Alpha
+
+ }
+ description="Horizontally scalable Postgres for highly available deployments. Free during Alpha for up to 2 projects."
layout="horizontal"
>
-
+ {
+ handleHighAvailabilityChange(checked)
+ field.onChange(checked)
+ }}
+ />
)}
diff --git a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx
index e1c62c30d4d2b..3779c2912f7a7 100644
--- a/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/InternalOnlyConfiguration.tsx
@@ -1,7 +1,8 @@
import { useParams } from 'common'
+import { useRef } from 'react'
import { UseFormReturn } from 'react-hook-form'
import { type CloudProvider } from 'shared-data'
-import { FormControl, FormField, Input } from 'ui'
+import { FormControl, FormField, Input, useWatch } from 'ui'
import { CollapsibleCardSection } from 'ui-patterns/CollapsibleCardSection'
import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout'
@@ -17,6 +18,10 @@ interface InternalOnlyConfigurationProps {
export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationProps) => {
const { slug } = useParams()
const showNonProdFields = process.env.NEXT_PUBLIC_ENVIRONMENT !== 'prod'
+ const highAvailability = useWatch({ control: form.control, name: 'highAvailability' })
+ // Held here (outside the collapsible content) so the selector's last valid
+ // selection survives the section being collapsed and reopened.
+ const lastValidPostgresVersionSelection = useRef('')
return (
@@ -36,6 +41,8 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro
cloudProvider={form.getValues('cloudProvider') as CloudProvider}
organizationSlug={slug}
dbRegion={form.getValues('dbRegion')}
+ disabled={highAvailability}
+ lastValidSelectionRef={lastValidPostgresVersionSelection}
/>
)}
/>
@@ -49,21 +56,23 @@ export const InternalOnlyConfiguration = ({ form }: InternalOnlyConfigurationPro
-
(
-
-
-
-
-
- )}
- />
+ {!highAvailability && (
+ (
+
+
+
+
+
+ )}
+ />
+ )}
form: UseFormReturn
+ /**
+ * Owned by the form owner (not this component) so the last valid selection
+ * survives this selector unmounting, e.g. when its collapsible section
+ * closes. Create it with useRef('') alongside the form.
+ */
+ lastValidSelectionRef: { current: string }
type?: 'create' | 'unpause'
layout?: 'vertical' | 'horizontal'
label?: string
@@ -56,9 +63,11 @@ export const extractPostgresVersionDetails = (value: string): PostgresVersionDet
export const PostgresVersionSelector = ({
cloudProvider,
dbRegion,
+ disabled = false,
organizationSlug,
field,
form,
+ lastValidSelectionRef,
type = 'create',
layout = 'horizontal',
label = 'Postgres version',
@@ -96,13 +105,32 @@ export const PostgresVersionSelector = ({
name: 'postgresVersionSelection',
})
+ // react-hook-form intermittently drops this field's value when its Controller
+ // remounts, so a one-shot "set the default once versions load" effect leaves
+ // the select stuck empty. Instead this effect re-asserts off the watched value:
+ // a selection present in the current list is kept (and remembered in the
+ // owner-held lastValidSelectionRef), and when the value is missing or cleared
+ // out from under us it restores the last valid selection, falling back to the
+ // GA default.
useEffect(() => {
- if (availableVersions.length > 0) {
- const gaVersion = availableVersions.find((x) => x.release_channel === 'ga')
- const defaultValue = gaVersion ? formatValue(gaVersion) : formatValue(availableVersions[0])
- form.setValue('postgresVersionSelection', defaultValue)
+ if (availableVersions.length === 0) return
+ const isSelectionAvailable = (selection: string) =>
+ availableVersions.some((version) => formatValue(version) === selection)
+
+ if (postgresVersionSelection && isSelectionAvailable(postgresVersionSelection)) {
+ lastValidSelectionRef.current = postgresVersionSelection
+ return
+ }
+
+ if (isSelectionAvailable(lastValidSelectionRef.current)) {
+ form.setValue('postgresVersionSelection', lastValidSelectionRef.current)
+ return
}
- }, [isSuccess, availableVersions, form])
+
+ const gaVersion = availableVersions.find((x) => x.release_channel === 'ga')
+ const defaultValue = gaVersion ? formatValue(gaVersion) : formatValue(availableVersions[0])
+ form.setValue('postgresVersionSelection', defaultValue)
+ }, [isSuccess, availableVersions, postgresVersionSelection, lastValidSelectionRef, form])
return (
@@ -110,6 +138,7 @@ export const PostgresVersionSelector = ({
value={postgresVersionSelection}
onValueChange={field.onChange}
disabled={
+ disabled ||
availableVersions.length === 0 ||
(type === 'create' && isLoadingProjectCreateVersions) ||
(type === 'unpause' && isLoadingProjectUnpauseVersions)
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts
index 2cdb36b8c86ba..fb6dcd1d258a7 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.constants.ts
@@ -1,4 +1,11 @@
-import { DesiredInstanceSize } from '@/data/projects/new-project.constants'
+import type {
+ DesiredInstanceSize,
+ PostgresEngine,
+ ReleaseChannel,
+} from '@/data/projects/new-project.constants'
+
+export const HIGH_AVAILABILITY_POSTGRES_ENGINE = '17' satisfies PostgresEngine
+export const HIGH_AVAILABILITY_RELEASE_CHANNEL = 'ga' satisfies ReleaseChannel
// [Joshen] Obtained from https://gist.github.com/tadast/8827699
export const COUNTRY_LAT_LON = {
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts
new file mode 100644
index 0000000000000..cb820643b13b0
--- /dev/null
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest'
+
+import {
+ HIGH_AVAILABILITY_POSTGRES_ENGINE,
+ HIGH_AVAILABILITY_RELEASE_CHANNEL,
+} from './ProjectCreation.constants'
+import {
+ filterHighAvailabilityRegions,
+ getHighAvailabilityRegionCode,
+} from './ProjectCreation.utils'
+
+describe('High Availability project creation constraints', () => {
+ it('pins the Alpha Postgres engine and release channel', () => {
+ expect(HIGH_AVAILABILITY_POSTGRES_ENGINE).toBe('17')
+ expect(HIGH_AVAILABILITY_RELEASE_CHANNEL).toBe('ga')
+ })
+
+ it.each([
+ ['local', 'eu-central-1'],
+ ['staging', 'us-east-1'],
+ ['prod', undefined],
+ ])('resolves the %s region restriction', (environment, expectedRegion) => {
+ expect(getHighAvailabilityRegionCode(environment)).toBe(expectedRegion)
+ })
+
+ it.each([
+ ['local', 'eu-central-1'],
+ ['staging', 'us-east-1'],
+ ['prod', undefined],
+ ])('limits %s projects to the required region', (environment, expectedRegion) => {
+ const regions = [{ code: 'us-east-1' }, { code: 'eu-central-1' }]
+
+ expect(filterHighAvailabilityRegions(regions, true, environment)).toEqual(
+ expectedRegion === undefined ? regions : [{ code: expectedRegion }]
+ )
+ expect(filterHighAvailabilityRegions(regions, false, environment)).toEqual(regions)
+ })
+})
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
index 3b399505d9ef2..c77d48d680b02 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreation.utils.ts
@@ -45,3 +45,22 @@ export const monthlyInstancePrice = (instance: string | undefined): number => {
export const instanceLabel = (instance: string | undefined): string => {
return instanceSizeSpecs[instance as DesiredInstanceSize]?.label || 'Micro'
}
+
+export const getHighAvailabilityRegionCode = (
+ environment = process.env.NEXT_PUBLIC_ENVIRONMENT
+) => {
+ if (environment === 'local') return 'eu-central-1'
+ if (environment === 'staging') return 'us-east-1'
+ return undefined
+}
+
+export const filterHighAvailabilityRegions = (
+ regions: T[],
+ highAvailability: boolean,
+ environment = process.env.NEXT_PUBLIC_ENVIRONMENT
+) => {
+ const regionCode = getHighAvailabilityRegionCode(environment)
+ return highAvailability && regionCode !== undefined
+ ? regions.filter((region) => region.code === regionCode)
+ : regions
+}
diff --git a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx
index 897ba9b4c8e67..4aee33481c3a9 100644
--- a/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx
+++ b/apps/studio/components/interfaces/ProjectCreation/ProjectCreationForm.tsx
@@ -23,9 +23,14 @@ import { HighAvailabilityInput } from './HighAvailabilityInput'
import { InternalOnlyConfiguration } from './InternalOnlyConfiguration'
import { OrganizationSelector } from './OrganizationSelector'
import { extractPostgresVersionDetails } from './PostgresVersionSelector'
-import { sizes } from './ProjectCreation.constants'
+import {
+ HIGH_AVAILABILITY_POSTGRES_ENGINE,
+ HIGH_AVAILABILITY_RELEASE_CHANNEL,
+ sizes,
+} from './ProjectCreation.constants'
import { FormSchema } from './ProjectCreation.schema'
import {
+ getHighAvailabilityRegionCode,
instanceLabel,
monthlyInstancePrice,
smartRegionToExactRegion,
@@ -185,6 +190,7 @@ export const ProjectCreationForm = ({
const { dirtyFields } = useFormState(form)
const isDbRegionDirty = dirtyFields.dbRegion
const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS'
+ const highAvailabilityRegionCode = getHighAvailabilityRegionCode()
// Read dirty state during render rather than depending on form.formState in the
// effect — form.formState is a Proxy that gets a new reference every render, which
@@ -265,15 +271,24 @@ export const ProjectCreationForm = ({
}
)
+ const highAvailabilityRegion =
+ highAvailability && highAvailabilityRegionCode !== undefined
+ ? availableRegionsData?.all.specific.find(
+ (region) => region.code === highAvailabilityRegionCode
+ )
+ : undefined
const recommendedSmartRegion = smartRegionEnabled
? availableRegionsData?.recommendations.smartGroup.name
: ''
const fixedDefaultRegion = PROVIDERS[selectedCloudProvider].default_region.displayName
const regionError = smartRegionEnabled ? availableRegionsError : defaultRegionError
- const defaultRegion = smartRegionEnabled
- ? availableRegionsData?.recommendations.smartGroup.name
- : (autoDefaultRegion ?? fixedDefaultRegion)
+ const defaultRegion =
+ highAvailability && highAvailabilityRegionCode !== undefined
+ ? highAvailabilityRegion?.name
+ : smartRegionEnabled
+ ? recommendedSmartRegion
+ : (autoDefaultRegion ?? fixedDefaultRegion)
const canCreateProject = isAdmin && !freePlanWithExceedingLimits && !hasOutstandingInvoices
const canConfigureGitHubOnCreate =
@@ -381,7 +396,11 @@ export const ProjectCreationForm = ({
shouldRunMigrations,
} = values
- if (postgresVersion && !postgresVersion.match(/1[2-9]\..*/)) {
+ // HA projects never take a custom version — the API resolves the image from
+ // postgresEngine + releaseChannel.
+ const customPostgresVersion = highAvailability ? undefined : postgresVersion
+
+ if (customPostgresVersion && !customPostgresVersion.match(/1[2-9]\..*/)) {
return toast.error(
`Invalid Postgres version, should start with a number between 12-19, a dot and additional characters, i.e. 15.2 or 15.2.0-3`
)
@@ -402,9 +421,19 @@ export const ProjectCreationForm = ({
extractPostgresVersionDetails(postgresVersionSelection)
const { smartGroup = [], specific = [] } = availableRegionsData?.all ?? {}
- const selectedRegion = smartRegionEnabled
- ? (smartGroup.find((x) => x.name === dbRegion) ?? specific.find((x) => x.name === dbRegion))
- : undefined
+ const selectedRegion =
+ highAvailability && highAvailabilityRegionCode !== undefined
+ ? specific.find((region) => region.code === highAvailabilityRegionCode)
+ : smartRegionEnabled
+ ? (smartGroup.find((x) => x.name === dbRegion) ??
+ specific.find((x) => x.name === dbRegion))
+ : undefined
+
+ if (highAvailability && highAvailabilityRegionCode !== undefined && !selectedRegion) {
+ return toast.error(
+ `High Availability projects are not available in the required region (${highAvailabilityRegionCode})`
+ )
+ }
const parsedGitHubRepositoryId =
githubRepositoryId.length > 0 ? Number(githubRepositoryId) : undefined
const shouldIncludeGitHubFields =
@@ -449,8 +478,16 @@ export const ProjectCreationForm = ({
dataApiExposedSchemas: !dataApi ? [] : undefined,
dataApiUseApiSchema: false,
dataApiRevokeDefaultPrivileges: dataApi && !dataApiDefaultPrivileges,
- postgresEngine: useOrioleDb ? availableOrioleVersion?.postgres_engine : postgresEngine,
- releaseChannel: useOrioleDb ? availableOrioleVersion?.release_channel : releaseChannel,
+ postgresEngine: highAvailability
+ ? HIGH_AVAILABILITY_POSTGRES_ENGINE
+ : useOrioleDb
+ ? availableOrioleVersion?.postgres_engine
+ : postgresEngine,
+ releaseChannel: highAvailability
+ ? HIGH_AVAILABILITY_RELEASE_CHANNEL
+ : useOrioleDb
+ ? availableOrioleVersion?.release_channel
+ : releaseChannel,
...(smartRegionEnabled ? { regionSelection: selectedRegion } : { dbRegion }),
...(shouldIncludeGitHubFields
? {
@@ -460,11 +497,11 @@ export const ProjectCreationForm = ({
: {}),
}
- if (postgresVersion || instanceType) {
+ if (customPostgresVersion || instanceType) {
data['customSupabaseRequest'] = {
ami: {
- ...(postgresVersion && {
- search_tags: { 'tag:postgresVersion': postgresVersion },
+ ...(customPostgresVersion && {
+ search_tags: { 'tag:postgresVersion': customPostgresVersion },
}),
...(instanceType && { instance_type: instanceType }),
},
@@ -510,24 +547,12 @@ export const ProjectCreationForm = ({
}
}, [defaultRegion, isDbRegionDirty, setValue])
- useEffect(() => {
- if (!isDbRegionDirty && recommendedSmartRegion) {
- setValue('dbRegion', recommendedSmartRegion)
- }
- }, [recommendedSmartRegion, isDbRegionDirty, setValue])
-
useEffect(() => {
if (regionError && fixedDefaultRegion) {
resetField('dbRegion', { defaultValue: fixedDefaultRegion })
}
}, [regionError, resetField, fixedDefaultRegion])
- useEffect(() => {
- if (highAvailability && cloudProvider !== 'AWS_K8S') {
- setValue('cloudProvider', 'AWS_K8S')
- }
- }, [highAvailability, cloudProvider, setValue])
-
useEffect(() => {
if (watchedInstanceSize !== instanceSize) {
setValue('instanceSize', instanceSize, {
@@ -657,9 +682,12 @@ export const ProjectCreationForm = ({
)}
- {canChooseInstanceSize && }
+
-
+ {canChooseInstanceSize && }
@@ -674,9 +702,9 @@ export const ProjectCreationForm = ({
{showInternalOnlyConfiguration && }
- {showAdvancedConfig && !!availableOrioleVersion && (
-
- )}
+ {showAdvancedConfig &&
+ !!availableOrioleVersion &&
+ highAvailability !== true && }
{shouldShowFreeProjectInfo ? (
{
const { slug } = useParams()
const cloudProvider = form.getValues('cloudProvider') as CloudProvider
+ const highAvailability = useWatch({ control: form.control, name: 'highAvailability' })
+ const dbRegion = useWatch({ control: form.control, name: 'dbRegion' })
+ const highAvailabilityRegionCode = getHighAvailabilityRegionCode()
const { hasLoaded: flagsLoaded } = useFeatureFlags()
const smartRegionEnabled = cloudProvider !== 'AWS_NIMBUS'
@@ -91,8 +101,11 @@ export const RegionSelector = ({
{ enabled: smartRegionEnabled, staleTime: 1000 * 60 * 5 } // 5 minutes
)
- const smartRegions = availableRegionsData?.all.smartGroup ?? []
+ const allSmartRegions = availableRegionsData?.all.smartGroup ?? []
const allRegions = availableRegionsData?.all.specific ?? []
+ const restrictHighAvailabilityRegion =
+ highAvailability && highAvailabilityRegionCode !== undefined
+ const smartRegions = highAvailability ? [] : allSmartRegions
const recommendedSmartRegions = new Set(
[availableRegionsData?.recommendations.smartGroup.code].filter(Boolean)
@@ -111,7 +124,11 @@ export const RegionSelector = ({
}
})
- const regionOptions = smartRegionEnabled ? allRegions : regionsArray
+ const unfilteredRegionOptions = smartRegionEnabled ? allRegions : regionsArray
+ const regionOptions = filterHighAvailabilityRegions(
+ [...unfilteredRegionOptions],
+ highAvailability
+ )
const isLoading = smartRegionEnabled ? isLoadingAvailableRegions : isLoadingDefaultRegion
const showNonProdFields =
@@ -120,6 +137,32 @@ export const RegionSelector = ({
const allSelectableRegions = [...smartRegions, ...regionOptions]
+ // react-hook-form intermittently drops this field's value when its Controller
+ // remounts (e.g. a sibling section mounting/unmounting in the same update, such as
+ // toggling high availability), so a one-shot effect isn't enough. Instead this effect
+ // re-asserts off the watched value: a region present in the current list is kept (and
+ // remembered in lastValidRegionRef), and when it's missing or cleared out from under us
+ // it restores the last valid region. allSelectableRegions is intentionally omitted from
+ // deps — it's a new array every render, and comparing it by reference would defeat the
+ // point of reacting to genuine content changes on every render where they occur.
+ const lastValidRegionRef = useRef(undefined)
+ useEffect(() => {
+ if (allSelectableRegions.length === 0) return
+ const isRegionAvailable = (name: string | undefined) =>
+ !!name && allSelectableRegions.some((region) => region.name === name)
+
+ if (isRegionAvailable(dbRegion)) {
+ lastValidRegionRef.current = dbRegion
+ return
+ }
+
+ const lastValidRegion = lastValidRegionRef.current
+ if (lastValidRegion !== undefined && isRegionAvailable(lastValidRegion)) {
+ form.setValue('dbRegion', lastValidRegion)
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [dbRegion, form])
+
if (isErrorAvailableRegions) {
return
}
@@ -131,9 +174,16 @@ export const RegionSelector = ({
name="dbRegion"
render={({ field }) => {
const selectedRegion = allSelectableRegions.find((region) => {
- return !!region.name && region.name === field.value
+ return !!region.name && region.name === dbRegion
})
+ const selectedRegionLabel = selectedRegion?.name
+ ? getDisplayNameForSmartRegion(selectedRegion.name)
+ : dbRegion
+ const triggerLabel = isLoadingAvailableRegions
+ ? 'Loading available regions...'
+ : selectedRegionLabel
+
const affectingIncidents = incidents.filter((incident) => {
const affectedRegions = incident.cache?.affected_regions ?? []
if (affectedRegions.length === 0 || selectedRegion?.code === undefined) return false
@@ -156,21 +206,28 @@ export const RegionSelector = ({
description={
<>
Select the region closest to your users for the best performance.
- {showNonProdFields && (
+ {restrictHighAvailabilityRegion ? (
-
Only these regions are supported for local/staging projects:
-
- East US (North Virginia)
- Central EU (Frankfurt)
- Southeast Asia (Singapore)
-
+ High Availability projects are currently limited to{' '}
+ {regionOptions[0]?.name ?? highAvailabilityRegionCode}.
+ ) : (
+ showNonProdFields && (
+
+
Only these regions are supported for local/staging projects:
+
+ East US (North Virginia)
+ Central EU (Frankfurt)
+ Southeast Asia (Singapore)
+
+
+ )
)}
>
}
>
-
+
- {field.value !== undefined && (
+ {dbRegion !== undefined && (
+ {isLoadingAvailableRegions && (
+
+ )}
{selectedRegion?.code && (
// For some reason, Safari considered the empty string alt text on this icon as misspelled (with VoiceOver)
// Only way to fix it is to set the role. Not needed for the combobox options
@@ -194,17 +254,13 @@ export const RegionSelector = ({
src={`${BASE_PATH}/img/regions/${selectedRegion.code}.svg`}
/>
)}
-
- {selectedRegion?.name
- ? getDisplayNameForSmartRegion(selectedRegion.name)
- : field.value}
-
+ {triggerLabel}
)}
- {smartRegionEnabled && (
+ {smartRegionEnabled && !highAvailability && (
<>
General regions
@@ -244,7 +300,9 @@ export const RegionSelector = ({
)}
- Specific regions
+
+ {highAvailability ? 'High Availability Regions' : 'Specific regions'}
+
{regionOptions.map((value) => {
return (
{
+ it('shows branches as unavailable for HA projects without cached branch data', () => {
+ render(
+
+ )
+
+ expect(screen.getByText('Unavailable')).toBeInTheDocument()
+ expect(screen.queryByText('No branches')).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx
index 220d9d7106301..cc68eb0a6bd7e 100644
--- a/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx
+++ b/apps/studio/components/interfaces/ProjectHome/ActivityStats.tsx
@@ -8,28 +8,91 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { HighAvailabilityBadge } from './HighAvailabilityBadge'
import { ServiceStatus } from './ServiceStatus'
import { ComputeBadgeWrapper } from '@/components/ui/ComputeBadgeWrapper'
+import { DisableInteraction } from '@/components/ui/DisableInteraction'
import { SingleStat } from '@/components/ui/SingleStat'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { useBackupsQuery } from '@/data/database/backups-query'
import { DatabaseMigration, useMigrationsQuery } from '@/data/database/migrations-query'
import { useGitHubConnectionsQuery } from '@/data/integrations/github-connections-query'
import { useResourceWarningsQuery } from '@/data/usage/resource-warnings-query'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { PROJECT_STATUS } from '@/lib/constants'
import { EMPTY_ARR } from '@/lib/void'
+interface BranchStatValueProps {
+ currentBranch?: { created_at?: string }
+ isDefaultProject: boolean
+ isError: boolean
+ isHighAvailability: boolean
+ isLoading: boolean
+ latestNonDefaultBranch?: { name?: string }
+}
+
+export const BranchStatValue = ({
+ currentBranch,
+ isDefaultProject,
+ isError,
+ isHighAvailability,
+ isLoading,
+ latestNonDefaultBranch,
+}: BranchStatValueProps) => {
+ if (isHighAvailability) {
+ return Unavailable
+ }
+
+ if (isLoading) {
+ return
+ }
+
+ if (isError) {
+ return Unable to load
+ }
+
+ if (isDefaultProject) {
+ return (
+
+ {latestNonDefaultBranch?.name ?? 'No branches'}
+
+ )
+ }
+
+ if (currentBranch?.created_at) {
+ return (
+
+ )
+ }
+
+ return Unknown
+}
+
export const ActivityStats = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
+ const { isHighAvailability } = useHighAvailability()
const { data: organization } = useSelectedOrganizationQuery()
const { data: resourceWarnings } = useResourceWarningsQuery({ slug: organization?.slug })
const projectResourceWarnings = resourceWarnings?.find((warning) => warning.project === ref)
const parentProjectRef = project?.parent_project_ref ?? project?.ref
- const { data: branchesData, isPending: isLoadingBranches } = useBranchesQuery({
- projectRef: parentProjectRef,
- })
+ const {
+ data: branchesData,
+ isPending: isLoadingBranches,
+ isError: isBranchesError,
+ } = useBranchesQuery(
+ {
+ projectRef: parentProjectRef,
+ },
+ { enabled: !isHighAvailability }
+ )
const isDefaultProject = project?.parent_project_ref === undefined
const currentBranch = useMemo(
() => (branchesData ?? []).find((b) => b.project_ref === ref),
@@ -132,38 +195,27 @@ export const ActivityStats = () => {
}
/>
- }
- label={{isDefaultProject ? 'Recent branch' : 'Branch Created'} }
- trackingProperties={{
- stat_type: 'branches',
- stat_value: branchesData?.length ?? 0,
- }}
- value={
- isLoadingBranches ? (
-
- ) : isDefaultProject ? (
-
- {latestNonDefaultBranch?.name ?? 'No branches'}
-
- ) : currentBranch?.created_at ? (
-
+ }
+ label={{isDefaultProject ? 'Recent branch' : 'Branch Created'} }
+ trackingProperties={{
+ stat_type: 'branches',
+ stat_value: branchesData?.length ?? 0,
+ }}
+ value={
+
- ) : (
- Unknown
- )
- }
- />
+ }
+ />
+
{
+ it('marks Realtime as disabled on High Availability projects', () => {
+ expect(resolveRealtimeServiceStatus(true, 'UNHEALTHY')).toBe('DISABLED')
+ })
+
+ it('preserves Realtime health on standard projects', () => {
+ expect(resolveRealtimeServiceStatus(false, 'ACTIVE_HEALTHY')).toBe('ACTIVE_HEALTHY')
+ expect(resolveRealtimeServiceStatus(false, 'UNHEALTHY')).toBe('UNHEALTHY')
+ })
+})
diff --git a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx
index 369eb5d88f788..570778754e2e9 100644
--- a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx
+++ b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.tsx
@@ -5,23 +5,22 @@ import Link from 'next/link'
import { cn, HoverCard, HoverCardContent, HoverCardTrigger, InfoIcon } from 'ui'
import { useUnifiedLogsPreview } from '../App/FeaturePreview/FeaturePreviewContext'
+import { resolveRealtimeServiceStatus, type ProjectServiceStatus } from './ServiceStatus.utils'
import { InlineLink } from '@/components/ui/InlineLink'
import { SingleStat } from '@/components/ui/SingleStat'
import { useBranchesQuery } from '@/data/branches/branches-query'
import { useEdgeFunctionServiceStatusQuery } from '@/data/service-status/edge-functions-status-query'
import {
useProjectServiceStatusQuery,
- type ProjectServiceStatus as APIProjectServiceStatus,
type ServiceHealthResponse,
} from '@/data/service-status/service-status-query'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
const SERVICE_STATUS_THRESHOLD = 5 // minutes
-type ProjectServiceStatus = APIProjectServiceStatus | 'DISABLED'
-
const iconProps = {
size: 18,
strokeWidth: 1.5,
@@ -101,6 +100,7 @@ const extractDbSchema = (response: ServiceHealthResponse | undefined) => {
export const ServiceStatus = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
+ const { isHighAvailability } = useHighAvailability()
const { isEnabled: isUnifiedLogsEnabled } = useUnifiedLogsPreview()
const {
@@ -136,6 +136,9 @@ export const ServiceStatus = () => {
refetchInterval: (query) => {
const data = query.state.data
const isServiceUnhealthy = data?.some((service) => {
+ if (isHighAvailability && service.name === 'realtime') {
+ return false
+ }
// if the postgrest service has an empty schema, postgrest has been disabled
if (service.name === 'rest' && extractDbSchema(service) === '') {
return false
@@ -213,7 +216,7 @@ export const ServiceStatus = () => {
error: realtimeStatus?.error,
docsUrl: undefined,
isLoading,
- status: realtimeStatus?.status ?? 'UNHEALTHY',
+ status: resolveRealtimeServiceStatus(isHighAvailability, realtimeStatus?.status),
logsUrl: isUnifiedLogsEnabled
? '/logs?filter=log_type:eq:realtime'
: '/logs/realtime-logs',
diff --git a/apps/studio/components/interfaces/ProjectHome/ServiceStatus.utils.ts b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.utils.ts
new file mode 100644
index 0000000000000..9affbfb9f27a7
--- /dev/null
+++ b/apps/studio/components/interfaces/ProjectHome/ServiceStatus.utils.ts
@@ -0,0 +1,10 @@
+import type { ProjectServiceStatus as APIProjectServiceStatus } from '@/data/service-status/service-status-query'
+
+export type ProjectServiceStatus = APIProjectServiceStatus | 'DISABLED'
+
+export const resolveRealtimeServiceStatus = (
+ isHighAvailability: boolean,
+ status?: APIProjectServiceStatus
+): ProjectServiceStatus => {
+ return isHighAvailability ? 'DISABLED' : (status ?? 'UNHEALTHY')
+}
diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx
new file mode 100644
index 0000000000000..491aaf978fe7d
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.test.tsx
@@ -0,0 +1,154 @@
+import { screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { ConnectionPooling } from './ConnectionPooling'
+import { customRender } from '@/tests/lib/custom-render'
+
+const {
+ mockUpdatePoolerConfig,
+ mockUseAsyncCheckPermissions,
+ mockUseCheckEntitlements,
+ mockUseHighAvailability,
+ mockUseMaxConnectionsQuery,
+ mockUsePgbouncerConfigQuery,
+ mockUsePgbouncerConfigurationUpdateMutation,
+ mockUseProjectAddonsQuery,
+ mockUseSelectedProjectQuery,
+} = vi.hoisted(() => ({
+ mockUpdatePoolerConfig: vi.fn(),
+ mockUseAsyncCheckPermissions: vi.fn(),
+ mockUseCheckEntitlements: vi.fn(),
+ mockUseHighAvailability: vi.fn(),
+ mockUseMaxConnectionsQuery: vi.fn(),
+ mockUsePgbouncerConfigQuery: vi.fn(),
+ mockUsePgbouncerConfigurationUpdateMutation: vi.fn(),
+ mockUseProjectAddonsQuery: vi.fn(),
+ mockUseSelectedProjectQuery: vi.fn(),
+}))
+
+vi.mock('common', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useParams: () => ({ ref: 'ha-project' }),
+}))
+
+vi.mock('@/hooks/misc/useCheckPermissions', () => ({
+ useAsyncCheckPermissions: mockUseAsyncCheckPermissions,
+}))
+
+vi.mock('@/hooks/misc/useCheckEntitlements', () => ({
+ useCheckEntitlements: mockUseCheckEntitlements,
+}))
+
+vi.mock('@/hooks/misc/useHighAvailability', () => ({
+ useHighAvailability: mockUseHighAvailability,
+}))
+
+vi.mock('@/hooks/misc/useSelectedProject', () => ({
+ useSelectedProjectQuery: mockUseSelectedProjectQuery,
+}))
+
+vi.mock('@/data/database/max-connections-query', () => ({
+ useMaxConnectionsQuery: mockUseMaxConnectionsQuery,
+}))
+
+vi.mock('@/data/database/pgbouncer-config-query', () => ({
+ usePgbouncerConfigQuery: mockUsePgbouncerConfigQuery,
+}))
+
+vi.mock('@/data/database/pgbouncer-config-update-mutation', () => ({
+ usePgbouncerConfigurationUpdateMutation: mockUsePgbouncerConfigurationUpdateMutation,
+}))
+
+vi.mock('@/data/subscriptions/project-addons-query', () => ({
+ useProjectAddonsQuery: mockUseProjectAddonsQuery,
+}))
+
+const expectEveryQueryCall = (queryMock: ReturnType, enabled: boolean) => {
+ expect(queryMock).toHaveBeenCalled()
+ for (const [, options] of queryMock.mock.calls) {
+ expect(options).toEqual({ enabled })
+ }
+}
+
+describe('ConnectionPooling', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockUseSelectedProjectQuery.mockReturnValue({
+ data: {
+ id: 1,
+ ref: 'ha-project',
+ infra_compute_size: 'small',
+ connectionString: 'postgresql://example',
+ },
+ })
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false })
+ mockUseAsyncCheckPermissions.mockReturnValue({ can: true })
+ mockUseCheckEntitlements.mockReturnValue({ hasAccess: true })
+ mockUsePgbouncerConfigQuery.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isPending: false,
+ isError: false,
+ isSuccess: false,
+ })
+ mockUseMaxConnectionsQuery.mockReturnValue({ data: undefined })
+ mockUseProjectAddonsQuery.mockReturnValue({
+ data: {
+ selected_addons: [
+ {
+ type: 'compute_instance',
+ variant: { name: 'Small', identifier: 'ci_small' },
+ },
+ ],
+ },
+ isSuccess: true,
+ })
+ mockUsePgbouncerConfigurationUpdateMutation.mockReturnValue({
+ mutate: mockUpdatePoolerConfig,
+ isPending: false,
+ })
+ })
+
+ it('renders High Availability pooling settings as read-only', () => {
+ customRender( )
+
+ expect(
+ screen.getAllByText(
+ 'Connection pooling settings are managed automatically on High Availability projects'
+ )
+ ).toHaveLength(1)
+ expect(screen.getByRole('link', { name: 'Learn more' })).toHaveAttribute(
+ 'href',
+ 'https://multigres.com/blog/pooling-without-choosing-a-mode'
+ )
+
+ expect(screen.queryByText('Enable IPv4 add-on')).not.toBeInTheDocument()
+ expect(screen.getByPlaceholderText('Managed automatically')).toBeDisabled()
+ expect(screen.getByDisplayValue('100000')).toBeDisabled()
+ expect(screen.queryByRole('button', { name: 'Cancel' })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: 'Save' })).not.toBeInTheDocument()
+ expect(mockUpdatePoolerConfig).not.toHaveBeenCalled()
+
+ expectEveryQueryCall(mockUsePgbouncerConfigQuery, false)
+ expectEveryQueryCall(mockUseMaxConnectionsQuery, false)
+ })
+
+ it('does not fetch pooling config while the high availability state is pending', () => {
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: true })
+
+ customRender( )
+
+ expectEveryQueryCall(mockUsePgbouncerConfigQuery, false)
+ expectEveryQueryCall(mockUseMaxConnectionsQuery, false)
+ })
+
+ it('fetches pooling config for non high availability projects', () => {
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false })
+
+ customRender( )
+
+ expectEveryQueryCall(mockUsePgbouncerConfigQuery, true)
+ expectEveryQueryCall(mockUseMaxConnectionsQuery, true)
+ })
+})
diff --git a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx
index 79f46c844e132..d6d3a31638bb3 100644
--- a/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx
+++ b/apps/studio/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.tsx
@@ -38,6 +38,7 @@ import { POOLING_OPTIMIZATIONS } from './ConnectionPooling.constants'
import { AlertError } from '@/components/ui/AlertError'
import { DocsButton } from '@/components/ui/DocsButton'
import { FormActions } from '@/components/ui/Forms/FormActions'
+import { HighAvailabilityDisabledSectionNotice } from '@/components/ui/HighAvailability/HighAvailabilityDisabledSectionNotice'
import { InlineLink } from '@/components/ui/InlineLink'
import Panel from '@/components/ui/Panel'
import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
@@ -46,10 +47,14 @@ import { usePgbouncerConfigurationUpdateMutation } from '@/data/database/pgbounc
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
const formId = 'pooling-configuration-form'
+const HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS = 100_000
+const HA_DISABLED_TITLE =
+ 'Connection pooling settings are managed automatically on High Availability projects'
const PoolingConfigurationFormSchema = z.object({
default_pool_size: z.preprocess(
@@ -68,6 +73,8 @@ const PoolingConfigurationFormSchema = z.object({
export const ConnectionPooling = () => {
const { ref: projectRef } = useParams()
const { data: project } = useSelectedProjectQuery()
+ const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability()
+ const canLoadPoolingConfig = !isHighAvailability && !isHighAvailabilityPending
const { can: canUpdateConnectionPoolingConfiguration } = useAsyncCheckPermissions(
PermissionAction.UPDATE,
'projects',
@@ -80,15 +87,18 @@ export const ConnectionPooling = () => {
isPending: isLoadingPgbouncerConfig,
isError: isErrorPgbouncerConfig,
isSuccess: isSuccessPgbouncerConfig,
- } = usePgbouncerConfigQuery({ projectRef })
+ } = usePgbouncerConfigQuery({ projectRef }, { enabled: canLoadPoolingConfig })
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
const disablePoolModeSelection = !hasDedicatedPooler
- const { data: maxConnData } = useMaxConnectionsQuery({
- projectRef: project?.ref,
- connectionString: project?.connectionString,
- })
+ const { data: maxConnData } = useMaxConnectionsQuery(
+ {
+ projectRef: project?.ref,
+ connectionString: project?.connectionString,
+ },
+ { enabled: canLoadPoolingConfig }
+ )
const { data: addons, isSuccess: isSuccessAddons } = useProjectAddonsQuery({ projectRef })
const { mutate: updatePoolerConfig, isPending: isUpdatingPoolerConfig } =
@@ -120,7 +130,7 @@ export const ConnectionPooling = () => {
const onSubmit: SubmitHandler> = async (data) => {
const { default_pool_size } = data
- if (!projectRef) return console.error('Project ref is required')
+ if (!projectRef || isHighAvailability) return
updatePoolerConfig(
{
@@ -165,7 +175,24 @@ export const ConnectionPooling = () => {
- {isSuccessAddons && !disablePoolModeSelection && !hasIpv4Addon && (
+ {isHighAvailability && (
+
+ High Availability projects run one pooler per Postgres pod, each supporting up to{' '}
+ {HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toLocaleString()} active or passive
+ connections, with pooling behavior selected automatically.{' '}
+
+ Learn more
+
+ .
+ >
+ }
+ />
+ )}
+
+ {isSuccessAddons && !isHighAvailability && !disablePoolModeSelection && !hasIpv4Addon && (
{
resetForm()}
- helper={
- !canUpdateConnectionPoolingConfiguration
- ? 'You need additional permissions to update connection pooling settings'
- : undefined
- }
- />
+ isHighAvailability ? undefined : (
+ resetForm()}
+ helper={
+ !canUpdateConnectionPoolingConfiguration
+ ? 'You need additional permissions to update connection pooling settings'
+ : undefined
+ }
+ />
+ )
}
>
- {isLoadingPgbouncerConfig && (
+ {!isHighAvailability && isLoadingPgbouncerConfig && (
{Array.from({ length: 4 }).map((_, i) => (
@@ -213,31 +242,40 @@ export const ConnectionPooling = () => {
)}
- {isErrorPgbouncerConfig && (
+ {!isHighAvailability && isErrorPgbouncerConfig && (
)}
- {connectionPoolingUnavailable && (
+ {!isHighAvailability && connectionPoolingUnavailable && (
)}
- {isSuccessPgbouncerConfig && !connectionPoolingUnavailable && (
+ {(isHighAvailability ||
+ (isSuccessPgbouncerConfig && !connectionPoolingUnavailable)) && (
<>
Connection poolers
- Configuration is shared across all connection poolers.
+ {isHighAvailability
+ ? 'One pooler runs for each Postgres pod in the cluster.'
+ : 'Configuration is shared across all connection poolers.'}
- Shared
- {!disablePoolModeSelection && Dedicated }
+ {isHighAvailability ? (
+ High Availability
+ ) : (
+ <>
+ Shared
+ {!disablePoolModeSelection && Dedicated }
+ >
+ )}
@@ -255,11 +293,18 @@ export const ConnectionPooling = () => {
layout="flex-row-reverse"
label="Connection pool size"
description={
-
- The maximum number of connections made to the underlying Postgres
- cluster, per user+db combination. Pool size has a default of{' '}
- {defaultPoolSize} based on your compute size of {computeSize}.
-
+ isHighAvailability ? (
+
+ Pool size is managed automatically for each Postgres pod and cannot
+ be changed.
+
+ ) : (
+
+ The maximum number of connections made to the underlying Postgres
+ cluster, per user+db combination. Pool size has a default of{' '}
+ {defaultPoolSize} based on your compute size of {computeSize}.
+
+ )
}
className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full"
>
@@ -267,10 +312,15 @@ export const ConnectionPooling = () => {
field.onChange(
isNaN(event.target.valueAsNumber)
@@ -279,12 +329,15 @@ export const ConnectionPooling = () => {
)
}
/>
-
- connections
-
+ {!isHighAvailability && (
+
+ connections
+
+ )}
- {!!maxConnData &&
+ {!isHighAvailability &&
+ !!maxConnData &&
(default_pool_size ?? 15) > maxConnData.maxConnections * 0.8 && (
@@ -313,7 +366,14 @@ export const ConnectionPooling = () => {
label="Max client connections"
className="[&>div]:md:w-1/2 [&>div]:xl:w-2/5 [&>div>div]:w-full"
description={
- <>
+ isHighAvailability ? (
+
+ Each pooler can support up to{' '}
+ {HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toLocaleString()} active
+ or passive client connections. This value is managed automatically
+ and cannot be changed.
+
+ ) : (
The maximum number of concurrent client connections allowed. This
value is fixed at {defaultMaxClientConn} based on your compute size
@@ -324,7 +384,7 @@ export const ConnectionPooling = () => {
Learn more
- >
+ )
}
>
@@ -333,8 +393,16 @@ export const ConnectionPooling = () => {
{...field}
type="number"
className="w-full"
- value={pgbouncerConfig?.max_client_conn ?? ''}
- placeholder={defaultMaxClientConn.toString()}
+ value={
+ isHighAvailability
+ ? HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS
+ : (pgbouncerConfig?.max_client_conn ?? '')
+ }
+ placeholder={
+ isHighAvailability
+ ? HIGH_AVAILABILITY_MAX_CLIENT_CONNECTIONS.toString()
+ : defaultMaxClientConn.toString()
+ }
onChange={(event) =>
field.onChange(
isNaN(event.target.valueAsNumber)
diff --git a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.test.tsx b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.test.tsx
new file mode 100644
index 0000000000000..3258a0327b166
--- /dev/null
+++ b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.test.tsx
@@ -0,0 +1,84 @@
+import { screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { PoolingModesModal } from './PoolingModesModal'
+import { customRender } from '@/tests/lib/custom-render'
+
+const {
+ mockUseDatabaseSelectorStateSnapshot,
+ mockUseDatabaseSettingsStateSnapshot,
+ mockUseHighAvailability,
+ mockUseSupavisorConfigurationQuery,
+} = vi.hoisted(() => ({
+ mockUseDatabaseSelectorStateSnapshot: vi.fn(),
+ mockUseDatabaseSettingsStateSnapshot: vi.fn(),
+ mockUseHighAvailability: vi.fn(),
+ mockUseSupavisorConfigurationQuery: vi.fn(),
+}))
+
+vi.mock('common', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useParams: () => ({ ref: 'ha-project' }),
+}))
+
+vi.mock('@/hooks/misc/useHighAvailability', () => ({
+ useHighAvailability: mockUseHighAvailability,
+}))
+
+vi.mock('@/data/database/supavisor-configuration-query', () => ({
+ useSupavisorConfigurationQuery: mockUseSupavisorConfigurationQuery,
+}))
+
+vi.mock('@/state/database-selector', () => ({
+ useDatabaseSelectorStateSnapshot: mockUseDatabaseSelectorStateSnapshot,
+}))
+
+vi.mock('@/state/database-settings', () => ({
+ useDatabaseSettingsStateSnapshot: mockUseDatabaseSettingsStateSnapshot,
+}))
+
+const expectEveryQueryCall = (queryMock: ReturnType, enabled: boolean) => {
+ expect(queryMock).toHaveBeenCalled()
+ for (const [, options] of queryMock.mock.calls) {
+ expect(options).toEqual({ enabled })
+ }
+}
+
+describe('PoolingModesModal', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mockUseDatabaseSettingsStateSnapshot.mockReturnValue({
+ showPoolingModeHelper: true,
+ setShowPoolingModeHelper: vi.fn(),
+ })
+ mockUseDatabaseSelectorStateSnapshot.mockReturnValue({ selectedDatabaseId: 'ha-project' })
+ mockUseSupavisorConfigurationQuery.mockReturnValue({ data: undefined })
+ })
+
+ it('renders nothing and skips the supavisor query for High Availability projects', () => {
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: true, isPending: false })
+
+ const { container } = customRender( )
+
+ expect(container).toBeEmptyDOMElement()
+ expectEveryQueryCall(mockUseSupavisorConfigurationQuery, false)
+ })
+
+ it('does not fetch supavisor config while the high availability state is pending', () => {
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: true })
+
+ customRender( )
+
+ expectEveryQueryCall(mockUseSupavisorConfigurationQuery, false)
+ })
+
+ it('renders the modal and fetches supavisor config for non high availability projects', () => {
+ mockUseHighAvailability.mockReturnValue({ isHighAvailability: false, isPending: false })
+
+ customRender( )
+
+ expect(screen.getByText('Which pooling mode should I use?')).toBeInTheDocument()
+ expectEveryQueryCall(mockUseSupavisorConfigurationQuery, true)
+ })
+})
diff --git a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx
index d9ef6ce37acb6..3e16bc7b8cbd9 100644
--- a/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx
+++ b/apps/studio/components/interfaces/Settings/Database/PoolingModesModal.tsx
@@ -19,6 +19,7 @@ import {
import { Markdown } from '@/components/interfaces/Markdown'
import { DocsButton } from '@/components/ui/DocsButton'
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { DOCS_URL } from '@/lib/constants'
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
import { useDatabaseSettingsStateSnapshot } from '@/state/database-settings'
@@ -27,8 +28,12 @@ export const PoolingModesModal = () => {
const { ref: projectRef } = useParams()
const snap = useDatabaseSettingsStateSnapshot()
const state = useDatabaseSelectorStateSnapshot()
+ const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability()
- const { data } = useSupavisorConfigurationQuery({ projectRef: projectRef })
+ const { data } = useSupavisorConfigurationQuery(
+ { projectRef: projectRef },
+ { enabled: !isHighAvailability && !isHighAvailabilityPending }
+ )
const primaryConfig = data?.find((x) => x.identifier === state.selectedDatabaseId)
const navigateToPoolerSettings = () => {
@@ -36,6 +41,8 @@ export const PoolingModesModal = () => {
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
+ if (isHighAvailability) return null
+
return (
diff --git a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx
index 95e1b1f34a490..2afffd1f012be 100644
--- a/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx
+++ b/apps/studio/components/interfaces/Settings/General/CustomDomainConfig/CustomDomainConfig.tsx
@@ -17,6 +17,7 @@ import { CustomDomainsConfigureHostname } from './CustomDomainsConfigureHostname
import { CustomDomainsShimmerLoader } from './CustomDomainsShimmerLoader'
import { CustomDomainVerify } from './CustomDomainVerify'
import { SupportLink } from '@/components/interfaces/Support/SupportLink'
+import { HighAvailabilityDisabledEmptyState } from '@/components/ui/HighAvailability/HighAvailabilityDisabledEmptyState'
import { InlineLinkClassName } from '@/components/ui/InlineLink'
import { UpgradeToPro } from '@/components/ui/UpgradeToPro'
import {
@@ -24,12 +25,14 @@ import {
type CustomDomainsData,
} from '@/data/custom-domains/custom-domains-query'
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const CustomDomainConfig = () => {
const { ref } = useParams()
const { data: project } = useSelectedProjectQuery()
+ const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability()
const { data: organization } = useSelectedOrganizationQuery()
const isBranch = Boolean(project?.parent_project_ref)
const entityLabel = isBranch ? 'branch' : 'project'
@@ -37,8 +40,12 @@ export const CustomDomainConfig = () => {
const customDomainsDisabledDueToQuota = useFlag('customDomainsDisabledDueToQuota')
const plan = organization?.plan?.id
+ const canLoadCustomDomains = !isHighAvailability && !isHighAvailabilityPending
- const { data: addons, isPending: isLoadingAddons } = useProjectAddonsQuery({ projectRef: ref })
+ const { data: addons, isPending: isLoadingAddons } = useProjectAddonsQuery(
+ { projectRef: ref },
+ { enabled: canLoadCustomDomains }
+ )
const hasCustomDomainAddon = !!addons?.selected_addons.find((x) => x.type === 'custom_domain')
const {
@@ -50,6 +57,7 @@ export const CustomDomainConfig = () => {
} = useCustomDomainsQuery(
{ projectRef: ref },
{
+ enabled: canLoadCustomDomains,
refetchInterval: (query) => {
const data = query.state.data
// while setting up the ssl certificate, we want to poll every 5 seconds
@@ -64,6 +72,28 @@ export const CustomDomainConfig = () => {
const { status } = customDomainData || {}
+ if (isHighAvailability) {
+ return (
+
+
+
+ Custom domains
+
+ Present a branded experience to your users
+
+
+
+
+
+
+
+ )
+ }
+
return (
@@ -75,7 +105,7 @@ export const CustomDomainConfig = () => {
- {isLoadingAddons ? (
+ {isHighAvailabilityPending || isLoadingAddons ? (
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.test.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.test.tsx
new file mode 100644
index 0000000000000..01f45f3a1213d
--- /dev/null
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.test.tsx
@@ -0,0 +1,76 @@
+import { render, screen } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, expect, it, vi } from 'vitest'
+
+import { TableRealtimeToggle } from './TableEditor'
+
+describe('TableRealtimeToggle', () => {
+ it('disables Realtime and shows the HA-specific description for HA projects', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('checkbox', { name: 'Enable Realtime' })).toBeDisabled()
+ expect(
+ screen.getByText('Realtime is unavailable on High Availability projects.')
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByText('Broadcast changes on this table to authorized subscribers.')
+ ).not.toBeInTheDocument()
+ })
+
+ it('keeps Realtime available with the standard description for non-HA projects', () => {
+ render(
+
+ )
+
+ expect(screen.getByRole('checkbox', { name: 'Enable Realtime' })).toBeEnabled()
+ expect(
+ screen.getByText('Broadcast changes on this table to authorized subscribers.')
+ ).toBeInTheDocument()
+ expect(
+ screen.queryByText('Realtime is unavailable on High Availability projects.')
+ ).not.toBeInTheDocument()
+ })
+
+ it('prevents toggling until HA detection completes', async () => {
+ const user = userEvent.setup()
+ const onCheckedChange = vi.fn()
+ const { rerender } = render(
+
+ )
+
+ const checkbox = screen.getByRole('checkbox', { name: 'Enable Realtime' })
+ expect(checkbox).toBeDisabled()
+ await user.click(checkbox)
+ expect(onCheckedChange).not.toHaveBeenCalled()
+
+ rerender(
+
+ )
+
+ expect(checkbox).toBeEnabled()
+ await user.click(checkbox)
+ expect(onCheckedChange).toHaveBeenCalledOnce()
+ })
+})
diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
index b1f32ee4873a5..8988c2b651830 100644
--- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
+++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx
@@ -32,6 +32,7 @@ import { useForeignKeyConstraintsQuery } from '@/data/database/foreign-key-const
import { useEnumeratedTypesQuery } from '@/data/enumerated-types/enumerated-types-query'
import { useCustomContent } from '@/hooks/custom-content/useCustomContent'
import { useChanged } from '@/hooks/misc/useChanged'
+import { useHighAvailability } from '@/hooks/misc/useHighAvailability'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useQuerySchemaState } from '@/hooks/misc/useSchemaQueryState'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -63,6 +64,44 @@ export interface TableEditorProps {
apiAccessToggleHandler: TableApiAccessHandlerWithHistoryReturn
}
+interface TableRealtimeToggleProps {
+ checked: boolean
+ isHighAvailability: boolean
+ isPending: boolean
+ onCheckedChange: () => void
+}
+
+export const TableRealtimeToggle = ({
+ checked,
+ isHighAvailability,
+ isPending,
+ onCheckedChange,
+}: TableRealtimeToggleProps) => {
+ return (
+
+
+
+
+ Enable Realtime
+
+
+ {isHighAvailability
+ ? 'Realtime is unavailable on High Availability projects.'
+ : 'Broadcast changes on this table to authorized subscribers.'}
+
+
+
+ )
+}
+
export const TableEditor = ({
table,
isDuplicating,
@@ -77,6 +116,7 @@ export const TableEditor = ({
const snap = useTableEditorStateSnapshot()
const tableEditorApi = useContext(TableEditorStateContext)
const { realtimeAll: realtimeEnabled } = useIsFeatureEnabled(['realtime:all'])
+ const { isHighAvailability, isPending: isHighAvailabilityPending } = useHighAvailability()
const { docsRowLevelSecurityGuidePath } = useCustomContent(['docs:row_level_security_guide_path'])
const [params, setParams] = useUrlState()
@@ -220,7 +260,7 @@ export const TableEditor = ({
tableId: table?.id,
importContent,
isRLSEnabled: tableFields.isRLSEnabled,
- isRealtimeEnabled: tableFields.isRealtimeEnabled,
+ isRealtimeEnabled: !isHighAvailability && tableFields.isRealtimeEnabled,
isDuplicateRows: isDuplicateRows,
existingForeignKeyRelations: foreignKeys,
primaryKey,
@@ -500,32 +540,20 @@ export const TableEditor = ({
)}
{realtimeEnabled && (
-
-
{
- track('realtime_toggle_table_clicked', {
- newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled',
- origin: 'tableSidePanel',
- })
- onUpdateField({
- isRealtimeEnabled: !tableFields.isRealtimeEnabled,
- })
- }}
- />
-
-
- Enable Realtime
-
-
- Broadcast changes on this table to authorized subscribers.
-
-
-
+ {
+ track('realtime_toggle_table_clicked', {
+ newState: tableFields.isRealtimeEnabled ? 'disabled' : 'enabled',
+ origin: 'tableSidePanel',
+ })
+ onUpdateField({
+ isRealtimeEnabled: !tableFields.isRealtimeEnabled,
+ })
+ }}
+ />
)}
diff --git a/apps/studio/data/custom-domains/custom-domains-query.ts b/apps/studio/data/custom-domains/custom-domains-query.ts
index b3fe24ba2fd52..f10416f63b7e7 100644
--- a/apps/studio/data/custom-domains/custom-domains-query.ts
+++ b/apps/studio/data/custom-domains/custom-domains-query.ts
@@ -110,7 +110,7 @@ export const useCustomDomainsQuery = (
...options
}: UseCustomQueryOptions = {}
) => {
- const { data } = useProjectAddonsQuery({ projectRef })
+ const { data } = useProjectAddonsQuery({ projectRef }, { enabled: enabled !== false })
const hasCustomDomainsAddon = !!data?.selected_addons.find((x) => x.type === 'custom_domain')
return useQuery({
diff --git a/apps/studio/pages/project/[ref]/database/publications/[id].tsx b/apps/studio/pages/project/[ref]/database/publications/[id].tsx
index 965c22faa2ae1..8ae7129b577ff 100644
--- a/apps/studio/pages/project/[ref]/database/publications/[id].tsx
+++ b/apps/studio/pages/project/[ref]/database/publications/[id].tsx
@@ -4,6 +4,7 @@ import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader'
+import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability'
import { PublicationsTables } from '@/components/interfaces/Database/Publications/PublicationsTables'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
@@ -14,7 +15,7 @@ import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import type { NextPageWithLayout } from '@/types'
-const DatabasePublications: NextPageWithLayout = () => {
+const DatabasePublicationsContent = () => {
const { ref, id } = useParams()
const { data: project } = useSelectedProjectQuery()
const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
@@ -54,6 +55,12 @@ const DatabasePublications: NextPageWithLayout = () => {
)
}
+const DatabasePublications: NextPageWithLayout = () => (
+
+
+
+)
+
DatabasePublications.getLayout = (page) => (
{page}
diff --git a/apps/studio/pages/project/[ref]/database/publications/index.tsx b/apps/studio/pages/project/[ref]/database/publications/index.tsx
index ded564a278aed..59cd351d97bd5 100644
--- a/apps/studio/pages/project/[ref]/database/publications/index.tsx
+++ b/apps/studio/pages/project/[ref]/database/publications/index.tsx
@@ -2,6 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants'
import { PageContainer } from 'ui-patterns/PageContainer'
import { PageSection } from 'ui-patterns/PageSection'
+import { PublicationsAvailability } from '@/components/interfaces/Database/Publications/PublicationsAvailability'
import { PublicationsList } from '@/components/interfaces/Database/Publications/PublicationsList'
import DatabaseLayout from '@/components/layouts/DatabaseLayout/DatabaseLayout'
import { DefaultLayout } from '@/components/layouts/DefaultLayout'
@@ -10,7 +11,7 @@ import { NoPermission } from '@/components/ui/NoPermission'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
import type { NextPageWithLayout } from '@/types'
-const DatabasePublications: NextPageWithLayout = () => {
+const DatabasePublicationsContent = () => {
const { can: canViewPublications, isSuccess: isPermissionsLoaded } = useAsyncCheckPermissions(
PermissionAction.TENANT_SQL_ADMIN_READ,
'publications'
@@ -31,6 +32,12 @@ const DatabasePublications: NextPageWithLayout = () => {
)
}
+const DatabasePublications: NextPageWithLayout = () => (
+
+
+
+)
+
DatabasePublications.getLayout = (page) => (
{page}
diff --git a/apps/studio/routes/__root.tsx b/apps/studio/routes/__root.tsx
index 1e5542d5c7fa9..4d1c42e7c4556 100644
--- a/apps/studio/routes/__root.tsx
+++ b/apps/studio/routes/__root.tsx
@@ -30,6 +30,7 @@ import {
Outlet,
redirect,
Scripts,
+ type AnyRouter,
} from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import {
@@ -76,6 +77,7 @@ import { AuthProvider } from '@/lib/auth'
import { configureMonacoLoader } from '@/lib/configure-monaco-loader'
import { API_URL, BASE_PATH, IS_PLATFORM, useDefaultProvider } from '@/lib/constants'
import { TimezoneProvider, useTimezone } from '@/lib/datetime'
+import { splitInternalUrl } from '@/lib/internal-url'
// Custom adapter instead of `nuqs/adapters/tanstack-router` — the stock one
// injects a trailing slash before the query on every nuqs write (see module).
import { NuqsAdapter } from '@/lib/nuqs-tanstack-adapter'
@@ -330,8 +332,21 @@ export const Route = createRootRouteWithContext()({
hash: location.hash,
})
if (!match) return
- const href = BASE_PATH ? `${BASE_PATH}${match.destination}` : match.destination
- throw redirect({ href, statusCode: match.permanent ? 308 : 307 })
+ // `to`/`search`/`hash`, never `href`: the router treats `href` as an
+ // opaque (external) target, and preloading a Link whose beforeLoad
+ // throws `redirect({ href })` recurses forever — the preload retry
+ // rebuilds the origin location and re-runs this beforeLoad
+ // (https://github.com/TanStack/router/issues/7141). `to` is also
+ // basepath-relative, so no manual BASE_PATH prefix. The explicit
+ // `search`/`hash` fallbacks clear the incoming values rather than
+ // inherit them — `preserveQueryAndHash` already merged what carries over.
+ const { to, search, hash } = splitInternalUrl(match.destination)
+ throw redirect({
+ to,
+ search: search ?? {},
+ hash: hash ?? '',
+ statusCode: match.permanent ? 308 : 307,
+ })
},
component: RootComponent,
shellComponent: RootDocument,
diff --git a/apps/studio/routes/index.tsx b/apps/studio/routes/index.tsx
index bafddebe8e436..e4211538c8b11 100644
--- a/apps/studio/routes/index.tsx
+++ b/apps/studio/routes/index.tsx
@@ -1,7 +1,6 @@
-import { createFileRoute, redirect } from '@tanstack/react-router'
+import { createFileRoute, redirect, type AnyRouter } from '@tanstack/react-router'
import { IS_PLATFORM } from '@/lib/constants'
-import { stringifySearch } from '@/lib/router-search-params'
// `/` is never rendered — it always redirects. Mirrors the Next.js
// `redirects()` rules in next.config.ts: platform sends users to `/org`
@@ -16,21 +15,30 @@ export const Route = createFileRoute('/')({
// destination — deep links like `/?next=new-project&projectName=x` must
// keep `projectName`. Only the consumed `next` param is dropped (and only
// when it matched); everything else passes through.
- const suffix = (shouldConsumeNext: boolean) => {
- const carried = { ...location.search } as Record
- if (shouldConsumeNext) delete carried.next
- return `${stringifySearch(carried)}${location.hash ? `#${location.hash}` : ''}`
+ const carried = (shouldConsumeNext: boolean) => {
+ const carriedSearch: Record = { ...location.search }
+ if (shouldConsumeNext) delete carriedSearch.next
+ return carriedSearch
}
- // `href` instead of `to` because these targets aren't in the TanStack
- // routeTree yet — they're still on the Next.js pages side during the
- // migration. Swap to `to` once `/org`, `/new/new-project`, and
- // `/project/default` are migrated.
+ // `to`, never `href`: preloading a Link whose beforeLoad throws
+ // `redirect({ href })` recurses forever
+ // (https://github.com/TanStack/router/issues/7141). The `` type arguments opt out of the registered route tree's strict
+ // typing so the carried free-form search record is accepted.
if (IS_PLATFORM) {
if (search.next === 'new-project') {
- throw redirect({ href: `/new/new-project${suffix(true)}` })
+ throw redirect({
+ to: '/new/new-project',
+ search: carried(true),
+ hash: location.hash,
+ })
}
- throw redirect({ href: `/org${suffix(false)}` })
+ throw redirect({ to: '/org', search: carried(false), hash: location.hash })
}
- throw redirect({ href: `/project/default${suffix(false)}` })
+ throw redirect({
+ to: '/project/default',
+ search: carried(false),
+ hash: location.hash,
+ })
},
})
diff --git a/apps/studio/tests/components/OrganizationInvite.test.tsx b/apps/studio/tests/components/OrganizationInvite.test.tsx
index 7c0c2ff8fe2eb..baad458e0e9bf 100644
--- a/apps/studio/tests/components/OrganizationInvite.test.tsx
+++ b/apps/studio/tests/components/OrganizationInvite.test.tsx
@@ -156,7 +156,7 @@ describe('OrganizationInvite', () => {
expect(screen.getByText('Signed in as')).toBeInTheDocument()
expect(screen.getByText('jane@acmecorp.io')).toBeInTheDocument()
expect(screen.getByRole('button', { name: 'Accept invite' })).toBeInTheDocument()
- expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/projects')
+ expect(screen.getByRole('link', { name: 'Decline' })).toHaveAttribute('href', '/organizations')
})
test('accepts an invite with the current slug and token', async () => {
diff --git a/apps/studio/tests/pages/new/[slug].test.tsx b/apps/studio/tests/pages/new/[slug].test.tsx
index 7579b282bb20a..652b1f59c8778 100644
--- a/apps/studio/tests/pages/new/[slug].test.tsx
+++ b/apps/studio/tests/pages/new/[slug].test.tsx
@@ -102,6 +102,19 @@ const DEFAULT_AVAILABLE_REGIONS: RegionsInfo = {
},
}
+const FRANKFURT = 'Central EU (Frankfurt)'
+
+const AVAILABLE_REGIONS_WITH_FRANKFURT: RegionsInfo = {
+ ...DEFAULT_AVAILABLE_REGIONS,
+ all: {
+ ...DEFAULT_AVAILABLE_REGIONS.all,
+ specific: [
+ ...DEFAULT_AVAILABLE_REGIONS.all.specific,
+ { code: 'eu-central-1', name: FRANKFURT, provider: 'AWS', type: 'specific' },
+ ],
+ },
+}
+
const DEFAULT_AVAILABLE_VERSIONS: { available_versions: AvailableVersion[] } = {
available_versions: [
{ postgres_engine: '15', release_channel: 'ga', version: 'supabase-postgres-15.6.1.139' },
@@ -496,6 +509,28 @@ describe('project creation wizard', () => {
})
describe('postgres version and orioledb', () => {
+ test('hides advanced configuration when orioledb is unavailable', async () => {
+ mockWizardEndpoints()
+ const onAvailableVersionsRequest = vi.fn()
+ addAPIMock({
+ method: 'post',
+ path: '/platform/organizations/:slug/available-versions',
+ response: () => {
+ onAvailableVersionsRequest()
+ return HttpResponse.json<{ available_versions: AvailableVersion[] }>({
+ available_versions: DEFAULT_AVAILABLE_VERSIONS.available_versions.slice(0, 1),
+ })
+ },
+ })
+
+ await renderWizard()
+
+ await waitFor(() => expect(onAvailableVersionsRequest).toHaveBeenCalled())
+ expect(
+ screen.queryByRole('button', { name: 'Advanced Configuration' })
+ ).not.toBeInTheDocument()
+ })
+
test('selecting orioledb shows the alpha warning and submits the oriole engine/channel', async () => {
mockWizardEndpoints()
const onRequest = vi.fn()
@@ -520,7 +555,7 @@ describe('project creation wizard', () => {
expect(body.release_channel).toBe('alpha')
})
- test('rejects high availability combined with orioledb', async () => {
+ test('hides advanced configuration only while high availability is enabled', async () => {
mockWizardEndpoints()
const onRequest = vi.fn()
mockCreateProject(onRequest)
@@ -528,32 +563,101 @@ describe('project creation wizard', () => {
await renderWizard()
await fillProjectName('HA Oriole Project')
+ await generateAndWaitForStrongPassword()
await selectRegion(/Americas/)
- // The high availability toggle is now a top-level field, gated only by the entitlement.
- await user.click(await screen.findByRole('switch'))
-
fireEvent.click(await screen.findByRole('button', { name: 'Advanced Configuration' }))
await user.click(await screen.findByRole('radio', { name: /Postgres with OrioleDB/ }))
- await screen.findByText('High availability is not supported with OrioleDB images')
- expect(onRequest).not.toHaveBeenCalled()
+ const highAvailabilitySwitch = await screen.findByRole('switch')
+ await user.click(highAvailabilitySwitch)
+
+ expect(
+ screen.queryByRole('button', { name: 'Advanced Configuration' })
+ ).not.toBeInTheDocument()
+
+ await user.click(highAvailabilitySwitch)
+ expect(
+ await screen.findByRole('button', { name: 'Advanced Configuration' })
+ ).toBeInTheDocument()
+
+ await user.click(highAvailabilitySwitch)
+ fireEvent.click(screen.getByRole('button', { name: 'Create new project' }))
+ await waitFor(() => expect(onRequest).toHaveBeenCalled())
+ expect(onRequest.mock.calls[0][0].postgres_engine).toBe('17')
+ expect(onRequest.mock.calls[0][0].release_channel).toBe('ga')
})
- test('blocks submission with a toast when orioledb becomes unavailable after selection', async () => {
- const FRANKFURT = 'Central EU (Frankfurt)'
- mockWizardEndpoints({
- availableRegions: {
- ...DEFAULT_AVAILABLE_REGIONS,
- all: {
- ...DEFAULT_AVAILABLE_REGIONS.all,
- specific: [
- ...DEFAULT_AVAILABLE_REGIONS.all.specific,
- { code: 'eu-central-1', name: FRANKFURT, provider: 'AWS', type: 'specific' },
- ],
- },
+ test('keeps a manually selected Postgres version when available versions refetch', async () => {
+ mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT })
+ const onAvailableVersionsRequest = vi.fn()
+ addAPIMock({
+ method: 'post',
+ path: '/platform/organizations/:slug/available-versions',
+ response: () => {
+ onAvailableVersionsRequest()
+ return HttpResponse.json<{ available_versions: AvailableVersion[] }>(
+ DEFAULT_AVAILABLE_VERSIONS
+ )
},
})
+
+ await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } })
+
+ await fillProjectName('Sticky Version Project')
+ await selectRegion(/Americas/)
+ fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' }))
+
+ // Auto-selects the GA default once versions load
+ await waitFor(() =>
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139')
+ )
+
+ await user.click(screen.getByLabelText('Postgres version'))
+ await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ }))
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999')
+
+ const requestsBeforeRegionChange = onAvailableVersionsRequest.mock.calls.length
+ await selectRegion(/Frankfurt/)
+ await waitFor(() =>
+ expect(onAvailableVersionsRequest.mock.calls.length).toBeGreaterThan(
+ requestsBeforeRegionChange
+ )
+ )
+
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999')
+ })
+
+ test('keeps a non-default Postgres version when the internal panel is collapsed and reopened', async () => {
+ mockWizardEndpoints()
+
+ await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } })
+
+ await screen.findByPlaceholderText('Project name')
+ await selectRegion(/Americas/)
+ const panelToggle = await screen.findByRole('button', {
+ name: 'Internal-only Configuration',
+ })
+ fireEvent.click(panelToggle)
+
+ await waitFor(() =>
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139')
+ )
+ await user.click(screen.getByLabelText('Postgres version'))
+ await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ }))
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999')
+
+ fireEvent.click(panelToggle)
+ await waitFor(() => expect(screen.queryByLabelText('Postgres version')).toBeNull())
+ fireEvent.click(panelToggle)
+
+ await waitFor(() =>
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999')
+ )
+ })
+
+ test('blocks submission with a toast when orioledb becomes unavailable after selection', async () => {
+ mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT })
const onRequest = vi.fn()
mockCreateProject(onRequest)
@@ -593,13 +697,19 @@ describe('project creation wizard', () => {
})
describe('high availability', () => {
- test('shows the high availability toggle from the entitlement, without the internal-only flag', async () => {
+ test('shows the high availability toggle when the entitlement is enabled', async () => {
mockWizardEndpoints()
await renderWizard()
expect(await screen.findByText('High availability')).toBeInTheDocument()
- // The toggle must not depend on the internal-only configuration section.
+ expect(screen.getByText('Alpha')).toBeInTheDocument()
+ expect(screen.getByText(/Free during Alpha for up to 2 projects/)).toBeInTheDocument()
+ expect(
+ screen
+ .getByText('High availability')
+ .compareDocumentPosition(screen.getByText('Compute size'))
+ ).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
expect(
screen.queryByRole('button', { name: 'Internal-only Configuration' })
).not.toBeInTheDocument()
@@ -623,7 +733,43 @@ describe('project creation wizard', () => {
expect(screen.queryByText('High availability')).not.toBeInTheDocument()
})
- test('enabling high availability submits the flag and forces the AWS_K8S cloud provider', async () => {
+ test('removes the custom Postgres version field while high availability is enabled', async () => {
+ mockWizardEndpoints()
+
+ await renderWizard({
+ flags: {
+ newProjectInternalOnlyConfiguration: true,
+ },
+ })
+
+ const highAvailabilitySwitch = await screen.findByRole('switch', {
+ name: 'Enable high availability',
+ })
+ await user.click(highAvailabilitySwitch)
+ fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' }))
+
+ expect(screen.getByLabelText('Postgres version')).toBeDisabled()
+ expect(screen.queryByPlaceholderText('e.g 17.6.1.104')).not.toBeInTheDocument()
+
+ await user.click(highAvailabilitySwitch)
+
+ expect(await screen.findByPlaceholderText('e.g 17.6.1.104')).toBeInTheDocument()
+ })
+
+ test('shows high availability regions in a dedicated group', async () => {
+ mockWizardEndpoints()
+
+ await renderWizard()
+
+ await user.click(await screen.findByRole('switch', { name: 'Enable high availability' }))
+ await user.click(getSelectTriggerByLabel('Region'))
+
+ expect(await screen.findByText('High Availability Regions')).toBeInTheDocument()
+ expect(screen.queryByText('General regions')).not.toBeInTheDocument()
+ expect(screen.queryByText('Specific regions')).not.toBeInTheDocument()
+ })
+
+ test('enabling high availability submits the fixed engine and AWS_K8S provider without a custom version', async () => {
mockWizardEndpoints()
const onRequest = vi.fn()
mockCreateProject(onRequest)
@@ -633,7 +779,7 @@ describe('project creation wizard', () => {
await fillProjectName('HA Project')
await generateAndWaitForStrongPassword()
await user.click(await screen.findByRole('switch'))
- await selectRegion(/Americas/)
+ await selectRegion(/East US/)
fireEvent.click(screen.getByRole('button', { name: 'Create new project' }))
@@ -641,6 +787,151 @@ describe('project creation wizard', () => {
const body = onRequest.mock.calls[0][0]
expect(body.high_availability).toBe(true)
expect(body.cloud_provider).toBe('AWS_K8S')
+ expect(body.postgres_engine).toBe('17')
+ expect(body.release_channel).toBe('ga')
+ expect(body.custom_supabase_internal_requests).toBeUndefined()
+ })
+
+ test('forces the high availability region over a manually selected region and restores it', async () => {
+ vi.stubEnv('NEXT_PUBLIC_ENVIRONMENT', 'staging')
+ try {
+ mockWizardEndpoints({ availableRegions: AVAILABLE_REGIONS_WITH_FRANKFURT })
+
+ await renderWizard()
+
+ await screen.findByPlaceholderText('Project name')
+ await selectRegion(/Frankfurt/)
+ expect(getSelectTriggerByLabel('Region')).toHaveTextContent(FRANKFURT)
+
+ const highAvailabilitySwitch = await screen.findByRole('switch', {
+ name: 'Enable high availability',
+ })
+ await user.click(highAvailabilitySwitch)
+
+ await waitFor(() =>
+ expect(getSelectTriggerByLabel('Region')).toHaveTextContent('East US (North Virginia)')
+ )
+
+ await user.click(highAvailabilitySwitch)
+
+ await waitFor(() => expect(getSelectTriggerByLabel('Region')).toHaveTextContent(FRANKFURT))
+ } finally {
+ vi.unstubAllEnvs()
+ }
+ })
+
+ test('restores the Postgres version selection when high availability is toggled off', async () => {
+ mockWizardEndpoints()
+ // Mirror staging: no versions offered for the standard provider, so the
+ // selection only ever gets populated by the AWS_K8S list while HA is on.
+ addAPIMock({
+ method: 'post',
+ path: '/platform/organizations/:slug/available-versions',
+ response: async ({ request }) => {
+ const { provider } = (await request.json()) as { provider: string }
+ return HttpResponse.json<{ available_versions: AvailableVersion[] }>(
+ provider === 'AWS_K8S' ? DEFAULT_AVAILABLE_VERSIONS : { available_versions: [] }
+ )
+ },
+ })
+ const onRequest = vi.fn()
+ mockCreateProject(onRequest)
+
+ await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } })
+
+ await fillProjectName('HA Selection Restore Project')
+ await generateAndWaitForStrongPassword()
+ await selectRegion(/Americas/)
+ fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' }))
+
+ const highAvailabilitySwitch = await screen.findByRole('switch', {
+ name: 'Enable high availability',
+ })
+ await user.click(highAvailabilitySwitch)
+
+ // The AWS_K8S versions load and auto-select a default while HA is on
+ await waitFor(() =>
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139')
+ )
+
+ await user.click(highAvailabilitySwitch)
+
+ await waitFor(() =>
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent(
+ 'Select a Postgres version for your project'
+ )
+ )
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create new project' }))
+
+ await waitFor(() => expect(onRequest).toHaveBeenCalled())
+ const body = onRequest.mock.calls[0][0]
+ expect(body.high_availability).toBe(false)
+ expect(body.postgres_engine).toBeUndefined()
+ expect(body.release_channel).toBeUndefined()
+ expect(body.custom_supabase_internal_requests).toBeUndefined()
+ })
+
+ test('restores a manually selected Postgres version selection after HA toggle', async () => {
+ mockWizardEndpoints()
+ addAPIMock({
+ method: 'post',
+ path: '/platform/organizations/:slug/available-versions',
+ response: async ({ request }) => {
+ const { provider } = (await request.json()) as { provider: string }
+ return HttpResponse.json<{ available_versions: AvailableVersion[] }>(
+ provider === 'AWS_K8S'
+ ? { available_versions: DEFAULT_AVAILABLE_VERSIONS.available_versions.slice(0, 1) }
+ : DEFAULT_AVAILABLE_VERSIONS
+ )
+ },
+ })
+
+ await renderWizard({ flags: { newProjectInternalOnlyConfiguration: true } })
+
+ await fillProjectName('Manual Selection Restore Project')
+ await selectRegion(/Americas/)
+ fireEvent.click(await screen.findByRole('button', { name: 'Internal-only Configuration' }))
+
+ await waitFor(() =>
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('15.6.1.139')
+ )
+ await user.click(screen.getByLabelText('Postgres version'))
+ await user.click(await screen.findByRole('option', { name: /17\.9\.9\.999/ }))
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999')
+
+ const highAvailabilitySwitch = await screen.findByRole('switch', {
+ name: 'Enable high availability',
+ })
+ await user.click(highAvailabilitySwitch)
+ await user.click(highAvailabilitySwitch)
+
+ await waitFor(() =>
+ expect(screen.getByLabelText('Postgres version')).toHaveTextContent('17.9.9.999')
+ )
+ })
+
+ test('restores the standard Postgres configuration when high availability is disabled', async () => {
+ mockWizardEndpoints()
+ const onRequest = vi.fn()
+ mockCreateProject(onRequest)
+
+ await renderWizard()
+
+ await fillProjectName('Standard Project')
+ await generateAndWaitForStrongPassword()
+ const highAvailabilitySwitch = await screen.findByRole('switch')
+ await user.click(highAvailabilitySwitch)
+ await user.click(highAvailabilitySwitch)
+
+ fireEvent.click(screen.getByRole('button', { name: 'Create new project' }))
+
+ await waitFor(() => expect(onRequest).toHaveBeenCalled())
+ const body = onRequest.mock.calls[0][0]
+ expect(body.high_availability).toBe(false)
+ expect(body.cloud_provider).toBe('AWS')
+ expect(body.postgres_engine).toBeUndefined()
+ expect(body.custom_supabase_internal_requests).toBeUndefined()
})
})