Files
supabase/apps/studio/components/interfaces/ConnectSheet/useConnectionStringDatabases.ts
Alaister Young 29493e02d0 [FE-4010] feat(studio): add read-only replica connection option for HA projects (#49485)
For Multigres (HA) projects you can't connect to read replicas directly
— reads go through a read-only load balancer on the primary's host at
port 5433. Since #44695 stripped the pooler UI, HA projects showed no
source option at all in the Connect dialog and still prompted for the
IPv4 add-on. This surfaces it as a first-class, clearly-labeled
read-only source. In the UI it's labeled `Replica (read-only)` rather
than "load balancer" — the primary goes through the same gateway, so
"load balancer" would be confusing from a product perspective
(internally the `load-balancer` source identifier and
`HIGH_AVAILABILITY_LOAD_BALANCER_PORT` constant keep their names).

<img width="883" height="342" alt="Screenshot 2026-08-24 at 11 32 26 PM"
src="https://github.com/user-attachments/assets/3716f6dd-0325-4b9d-adbc-9ece9244de62"
/>

**Added:**
- Source select for HA projects in the Direct tab: `Primary database` +
`Replica (read-only)` (individual replica rows are filtered out —
they're only reachable via the load balancer)
- Replica (load balancer) connection strings on all 9 connection types:
primary host, port `5433`, with the Multigres-required
`sslmode=require&sslnegotiation=direct` params (JDBC gets the
`sslNegotiation` spelling, .NET gets `SSL Negotiation=Direct`)
- `Read-only` badge on the connection code block + note pointing writes
at the primary
- Programmatic labels for the ConnectSheet select/switch/multi-select
fields (the Source combobox previously had no accessible name)

**Changed:**
- The generated-file step (Node.js/Golang/.NET/Python/SQLAlchemy) is now
source-aware — it previously ignored the Source selection entirely (also
affected read replicas on normal projects) and silently rendered the
primary's connection info
- .NET template now emits `Port=` (Npgsql defaults to 5432 when omitted)
and the install step actually installs Npgsql (pinned 9.0.5 — `SSL
Negotiation` requires 9+)
- SQLAlchemy `DATABASE_URL` merges `sslmode=require` into the string's
existing query params instead of a hardcoded suffix that could drop TLS
- Source option labels normalized to sentence case (`Primary database`,
`Read replica (…)`)
- `MultipleCodeBlock` (ui-patterns) accepts an optional `className`
- HA coercion in `useConnectState` extended: a stale replica
`connectionSource` restored from URL/localStorage falls back to the
primary

**Removed:**
- IPv4 add-on admonition for HA projects (the forced-direct method was
tripping it; the add-on doesn't apply to Multigres)

Out of scope (needs platform work): SQL editor / Data API / other
`DatabaseSelector` surfaces — executing against the load balancer
requires a platform-issued connection string, and the load-balancers API
only returns a REST endpoint today. The `5433` port is a client-side
constant (`HIGH_AVAILABILITY_LOAD_BALANCER_PORT`) until the API exposes
it.

## To test

On an HA (Multigres) project:
- Open Connect → Direct: Source shows exactly `Primary database` and
`Replica (read-only)`; selecting the replica shows
`…@<primary-host>:5433/postgres?sslmode=require&sslnegotiation=direct`,
a `Read-only` badge, and the read-only note
- Cycle all 9 connection types with the replica selected — every snippet
carries port 5433 (`.NET` includes `Port=5433;…;SSL
Negotiation=Direct`), badge/note persist
- No "Enable IPv4 add-on" admonition anywhere in the Direct tab
- Switch tabs / hard-reload: source resets to primary with no stale
badge/string combos

On a normal project:
- Direct tab unchanged: no `Replica (read-only)` option, pooler badges
and IPv4 admonitions behave as before, `.NET` now shows `Port=5432` and
no `SSL Negotiation`

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

- **New Features**
- Added read-only load-balancer connection options for high-availability
projects.
- Added .NET and SQLAlchemy connection examples with required SSL
settings.
- Added clear read-only labels and notices explaining write
restrictions.
- **Bug Fixes**
  - Suppressed IPv4 add-on notices for high-availability connections.
  - Improved connection-source selection and restored-setting handling.
  - Improved connection form identification and accessibility.
- **Style**
  - Added customizable styling support for multi-code-block displays.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-08-28 10:43:55 +01:00

138 lines
5.6 KiB
TypeScript

import { useParams } from 'common'
import { useMemo } from 'react'
import { CONNECTION_SOURCE_LOAD_BALANCER } from './Connect.constants'
import type { DeploymentMode } from './Connect.types'
import {
buildConnectionStringPooler,
getConnectionStrings,
getHighAvailabilityLoadBalancerConnectionInfo,
} from './DatabaseSettings.utils'
import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils'
import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query'
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
import { pluckObjectFields } from '@/lib/helpers'
/**
* [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings
* from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here
*
* We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for
* the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct"
*/
export const useConnectionStringDatabases = (deploymentMode: DeploymentMode) => {
const { ref: projectRef } = useParams()
const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler')
const isHighAvailability = useIsHighAvailability()
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
// Multigres has no pooler, so the pooler config endpoints don't apply
const { data: pgbouncerConfig } = usePgbouncerConfigQuery(
{ projectRef },
{ enabled: !isHighAvailability }
)
const { data: supavisorConfig } = useSupavisorConfigurationQuery(
{ projectRef },
{ enabled: !isHighAvailability }
)
const { data: addons } = useProjectAddonsQuery({ projectRef })
const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? [])
// Memoized so the per-database pooler bag (consumed by resolveConnectionString
// downstream) keeps a stable identity across renders. Without this the inner
// pluckObjectFields/getConnectionStrings calls would mint fresh objects every
// render and ripple through the resolveConnectionString useMemo below.
return useMemo(() => {
const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at']
const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' }
const connectionStringsByIdentifier = Object.fromEntries(
databases.map((db) => {
const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS)
const poolingConfigurationShared = supavisorConfig?.find(
(x) => x.identifier === db.identifier
)
const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined
const connectionStringsShared = getConnectionStrings({
connectionInfo,
poolingInfo: {
connectionString: poolingConfigurationShared?.connection_string ?? '',
db_host: poolingConfigurationShared?.db_host ?? '',
db_name: poolingConfigurationShared?.db_name ?? '',
db_port: poolingConfigurationShared?.db_port ?? 0,
db_user: poolingConfigurationShared?.db_user ?? '',
},
metadata: { projectRef: db.identifier },
})
const connectionStringsDedicated =
poolingConfigurationDedicated !== undefined
? getConnectionStrings({
connectionInfo,
poolingInfo: {
connectionString: poolingConfigurationDedicated.connection_string.replace(
projectRef ?? '_',
db.identifier
),
db_host: poolingConfigurationDedicated.db_host,
db_name: poolingConfigurationDedicated.db_name,
db_port: poolingConfigurationDedicated.db_port,
db_user: poolingConfigurationDedicated.db_user,
},
metadata: { projectRef: db.identifier },
})
: undefined
return [
db.identifier,
buildConnectionStringPooler({
deploymentMode,
connectionInfo,
connectionStringsShared,
connectionStringsDedicated,
ipv4Addon: !!ipv4Addon,
isHighAvailability,
}),
]
})
)
// The Multigres load balancer is not a database row — it shares the
// primary's host on a dedicated read-only port.
const primaryDatabase = isHighAvailability
? databases.find((db) => db.identifier === projectRef)
: undefined
if (primaryDatabase) {
const loadBalancerInfo = getHighAvailabilityLoadBalancerConnectionInfo(
pluckObjectFields(primaryDatabase, DB_FIELDS)
)
connectionStringsByIdentifier[CONNECTION_SOURCE_LOAD_BALANCER] = buildConnectionStringPooler({
deploymentMode,
connectionInfo: loadBalancerInfo,
connectionStringsShared: getConnectionStrings({
connectionInfo: loadBalancerInfo,
metadata: { projectRef },
}),
ipv4Addon: false,
isHighAvailability,
})
}
return connectionStringsByIdentifier
}, [
databases,
pgbouncerConfig,
supavisorConfig,
allowPgBouncerSelection,
ipv4Addon,
projectRef,
deploymentMode,
isHighAvailability,
])
}