Files
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

442 lines
14 KiB
TypeScript

import { useParams } from 'common'
import { useCallback, useMemo, useState } from 'react'
import { MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder'
import {
CONNECTION_SOURCE_LOAD_BALANCER,
connectionStringMethodOptions,
DATABASE_CONNECTION_TYPES,
FRAMEWORKS,
MOBILES,
ORMS,
} from './Connect.constants'
import {
getActiveFields,
getDefaultState,
resetDependentFields,
resolveSteps,
} from './connect.resolver'
import {
connectSchema,
getDefaultMcpFeatures,
getSupportedMcpFeatureGroups,
normalizeMcpFeatures,
} from './connect.schema'
import type {
ConnectMode,
ConnectSchema,
ConnectState,
DeploymentMode,
FieldOption,
ResolvedField,
ResolvedStep,
} from './Connect.types'
import { resolveFrameworkLibraryKey } from './Connect.utils'
import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { useIsHighAvailability } from '@/hooks/misc/useSelectedProject'
// ============================================================================
// Data Source Helpers
// ============================================================================
/**
* Get field options from a data source reference.
* This maps source names to actual data.
*/
function getFieldOptionsFromSource({
source,
state,
databases,
deploymentMode,
isHighAvailability,
projectRef,
}: {
source: string
state: ConnectState
databases: Database[]
deploymentMode: DeploymentMode
isHighAvailability: boolean
projectRef?: string
}): FieldOption[] {
switch (source) {
case 'frameworks':
return [...FRAMEWORKS, ...MOBILES].map((f) => ({
value: f.key,
label: f.label,
icon: f.icon,
}))
case 'frameworkVariants': {
// Get variants for the selected framework
const allFrameworks = [...FRAMEWORKS, ...MOBILES]
const selected = allFrameworks.find((f) => f.key === state.framework)
if (!selected?.children?.length) return []
// Only return if there are multiple children (variants)
if (selected.children.length <= 1) return []
return selected.children.map((c) => ({
value: c.key,
label: c.label,
icon: c.icon,
}))
}
case 'libraries': {
// Get libraries for the selected framework and variant
const allFrameworks = [...FRAMEWORKS, ...MOBILES]
const selectedFramework = allFrameworks.find((f) => f.key === state.framework)
if (!selectedFramework) return []
// If framework has variants, look in the variant
if (selectedFramework.children?.length > 1 && state.frameworkVariant) {
const variant = selectedFramework.children.find((c) => c.key === state.frameworkVariant)
if (variant?.children?.length) {
return variant.children.map((c) => ({
value: c.key,
label: c.label,
icon: c.icon,
}))
}
}
// Otherwise look directly in framework children
if (selectedFramework.children?.length === 1) {
const child = selectedFramework.children[0]
if (child.children?.length) {
return child.children.map((c) => ({
value: c.key,
label: c.label,
icon: c.icon,
}))
}
// The child itself is the library
return [{ value: child.key, label: child.label, icon: child.icon }]
}
return []
}
case 'connectionMethods': {
const all = Object.values(connectionStringMethodOptions)
const allowed: string[] = deploymentMode.isCli
? ['direct']
: deploymentMode.isSelfHosted
? ['session', 'transaction', 'direct']
: ['direct', 'transaction', 'session']
const filtered = allowed
.map((value) => all.find((m) => m.value === value))
.filter((m): m is (typeof all)[number] => !!m)
return filtered.map((m) => ({
value: m.value,
label: m.label,
description:
deploymentMode.isSelfHosted && m.value === 'direct'
? 'Manually configurable for self-hosted Supabase.'
: deploymentMode.isSelfHosted && m.value === 'session'
? 'Supavisor (default pooler for self-hosted Supabase).'
: m.description,
}))
}
case 'connectionSources': {
const options = databases.map((db) => {
const region = formatDatabaseRegion(db?.region ?? '')
const id = formatDatabaseID(db.identifier ?? '')
const label = db.identifier.includes('-rr-')
? `Read replica (${region} - ${id})`
: 'Primary database'
return { value: db.identifier, label }
})
if (!isHighAvailability) return options
// Multigres replicas are only reachable through the read-only load
// balancer, so the sources are the primary and the load balancer.
return [
...options.filter((option) => option.value === projectRef),
{ value: CONNECTION_SOURCE_LOAD_BALANCER, label: 'Replica (read-only)' },
]
}
case 'connectionTypes':
return DATABASE_CONNECTION_TYPES.map((t) => ({
value: t.id,
label: t.label,
}))
case 'orms':
return ORMS.map((o) => ({
value: o.key,
label: o.label,
icon: o.icon,
}))
case 'mcpClients':
return MCP_CLIENTS.map((c) => ({
value: c.key,
label: c.label,
icon: c.icon,
}))
case 'mcpFeatures':
return getSupportedMcpFeatureGroups(deploymentMode.isPlatform).map((f) => ({
value: f.id,
label: f.name,
description: f.description,
}))
default:
return []
}
}
/**
* Resolve field options, handling both static options and data source references.
*/
function resolveFieldOptionsWithSource({
field,
state,
databases,
deploymentMode,
isHighAvailability,
projectRef,
}: {
field: ResolvedField
state: ConnectState
databases: Database[]
deploymentMode: DeploymentMode
isHighAvailability: boolean
projectRef?: string
}): FieldOption[] {
// If already resolved (from conditional resolution)
if (field.resolvedOptions.length > 0) {
return field.resolvedOptions
}
// Check if it's a source reference
const options = connectSchema.fields[field.id]?.options
if (options && typeof options === 'object' && 'source' in options) {
return getFieldOptionsFromSource({
source: options.source as string,
state,
databases,
deploymentMode,
isHighAvailability,
projectRef,
})
}
return []
}
// ============================================================================
// Hook
// ============================================================================
export interface UseConnectStateReturn {
state: ConnectState
updateField: (fieldId: string, value: string | boolean | string[]) => void
setMode: (mode: ConnectMode) => void
activeFields: ResolvedField[]
resolvedSteps: ResolvedStep[]
getFieldOptions: (fieldId: string) => FieldOption[]
schema: ConnectSchema
}
export function useConnectState(initialState?: Partial<ConnectState>): UseConnectStateReturn {
const { ref: projectRef } = useParams()
const { data: databases = [] } = useReadReplicasQuery({ projectRef })
const { hasAccess: hasDedicatedPooler } = useCheckEntitlements('dedicated_pooler')
const isHighAvailability = useIsHighAvailability()
const deploymentMode = useDeploymentMode()
const [state, setState] = useState<ConnectState>(() => {
const defaults = getDefaultState({ schema: connectSchema })
// Set initial framework if mode is framework
if (defaults.mode === 'framework' && !defaults.framework) {
const firstFramework = FRAMEWORKS[0]
defaults.framework = firstFramework?.key ?? ''
// Set initial variant if framework has variants
if (firstFramework?.children?.length > 1) {
defaults.frameworkVariant = firstFramework.children[0]?.key ?? ''
}
// Set initial library
const libraryKey = resolveFrameworkLibraryKey({
framework: defaults.framework,
frameworkVariant: defaults.frameworkVariant,
library: defaults.library,
})
if (libraryKey) defaults.library = libraryKey
}
// Set initial ORM if mode is orm
if (defaults.mode === 'orm' && !defaults.orm) {
defaults.orm = ORMS[0]?.key ?? ''
}
// Set initial MCP client if mode is mcp
if (defaults.mode === 'mcp' && !defaults.mcpClient) {
defaults.mcpClient = MCP_CLIENTS[0]?.key ?? ''
}
return { ...defaults, ...initialState } as ConnectState
})
const updateField = useCallback((fieldId: string, value: string | boolean | string[]) => {
setState((prev) => {
const next = { ...prev, [fieldId]: value }
// Handle cascading updates for framework selection
if (fieldId === 'framework') {
const allFrameworks = [...FRAMEWORKS, ...MOBILES]
const selected = allFrameworks.find((f) => f.key === value)
// Reset variant if framework changed
if (selected?.children && selected.children.length > 1) {
next.frameworkVariant = selected.children[0]?.key ?? ''
} else {
delete next.frameworkVariant
}
// Reset library
const libraryKey = resolveFrameworkLibraryKey({
framework: next.framework,
frameworkVariant: next.frameworkVariant,
})
if (libraryKey) {
next.library = libraryKey
} else {
delete next.library
}
}
// Handle cascading updates for variant selection
if (fieldId === 'frameworkVariant') {
const libraryKey = resolveFrameworkLibraryKey({
framework: prev.framework,
frameworkVariant: String(value),
})
if (libraryKey) next.library = libraryKey
}
// Reset useSharedPooler when connectionMethod changes to 'direct'
if (fieldId === 'connectionMethod' && value === 'direct') {
next.useSharedPooler = false
}
return resetDependentFields(next, fieldId, connectSchema)
})
}, [])
const setMode = useCallback(
(mode: ConnectMode) => {
setState((prev) => {
const next: ConnectState = { ...prev, mode }
// Initialize mode-specific defaults
if (mode === 'framework' && !next.framework) {
const firstFramework = FRAMEWORKS[0]
next.framework = firstFramework?.key ?? ''
if (firstFramework?.children?.length > 1) {
next.frameworkVariant = firstFramework.children[0]?.key ?? ''
}
const libraryKey = resolveFrameworkLibraryKey({
framework: next.framework,
frameworkVariant: next.frameworkVariant,
})
if (libraryKey) next.library = libraryKey
}
if (mode === 'direct') {
const defaultMethod = deploymentMode.isSelfHosted ? 'session' : 'direct'
next.connectionMethod = next.connectionMethod ?? defaultMethod
next.connectionType = next.connectionType ?? 'uri'
next.connectionSource = projectRef ?? '_'
}
if (mode === 'orm' && !next.orm) {
next.orm = ORMS[0]?.key ?? ''
}
if (mode === 'mcp') {
if (!next.mcpClient) {
next.mcpClient = MCP_CLIENTS[0]?.key ?? ''
}
if (next.mcpFeatures === undefined) {
next.mcpFeatures = getDefaultMcpFeatures(deploymentMode.isPlatform)
} else if (Array.isArray(next.mcpFeatures)) {
next.mcpFeatures = normalizeMcpFeatures(next.mcpFeatures, deploymentMode.isPlatform)
}
}
return next
})
},
[projectRef, deploymentMode.isSelfHosted, deploymentMode.isPlatform]
)
// Multigres has no pooler, so pooler-flavored selections restored from the
// URL or localStorage (shared across projects) must never leak into an HA
// project — every consumer sees the direct connection method. Likewise a
// stale connection source (e.g. a replica identifier restored from the URL)
// falls back to the primary, since HA sources are only the primary and the
// load balancer.
const resolvedState = useMemo(() => {
if (!isHighAvailability) return state
const next: ConnectState = { ...state, connectionMethod: 'direct', useSharedPooler: false }
const hasValidSource =
state.connectionSource === undefined ||
state.connectionSource === projectRef ||
state.connectionSource === CONNECTION_SOURCE_LOAD_BALANCER
if (!hasValidSource) next.connectionSource = projectRef ?? '_'
return next
}, [state, isHighAvailability, projectRef])
const activeFields = useMemo(() => {
let fields = getActiveFields(connectSchema, resolvedState)
if (!hasDedicatedPooler || !deploymentMode.isPlatform) {
// useSharedPooler is a platform-only toggle (CLI has no pooler; self-hosted
// already uses Supavisor shared)
fields = fields.filter((f) => f.id !== 'useSharedPooler')
}
if (isHighAvailability) {
fields = fields
.filter((f) => f.id !== 'connectionMethod' && f.id !== 'useSharedPooler')
.map((f) => (f.id === 'connectionType' ? { ...f, label: 'Connection Type' } : f))
}
return fields
}, [resolvedState, hasDedicatedPooler, isHighAvailability, deploymentMode.isPlatform])
const resolvedSteps = useMemo(() => resolveSteps(connectSchema, resolvedState), [resolvedState])
const getFieldOptions = useCallback(
(fieldId: string): FieldOption[] => {
const field = activeFields.find((f) => f.id === fieldId)
if (!field) return []
return resolveFieldOptionsWithSource({
field,
state: resolvedState,
databases,
deploymentMode,
isHighAvailability,
projectRef,
})
},
[activeFields, resolvedState, databases, deploymentMode, isHighAvailability, projectRef]
)
return {
state: resolvedState,
updateField,
setMode,
activeFields,
resolvedSteps,
getFieldOptions,
schema: connectSchema,
}
}