mirror of
https://github.com/supabase/supabase.git
synced 2026-09-22 13:37:53 +08:00
29493e02d0
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>
442 lines
14 KiB
TypeScript
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,
|
|
}
|
|
}
|