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>
325 lines
11 KiB
TypeScript
325 lines
11 KiB
TypeScript
import type { ConnectionStringPooler, DeploymentMode } from './Connect.types'
|
|
import { appendConnectionStringParams } from './ConnectionString.utils'
|
|
|
|
/**
|
|
* Multigres (high-availability) projects only accept TLS connections with
|
|
* direct SSL negotiation — without these params clients fail with
|
|
* "server closed the connection unexpectedly".
|
|
*/
|
|
export const HIGH_AVAILABILITY_SSL_PARAMS = 'sslmode=require&sslnegotiation=direct'
|
|
|
|
/**
|
|
* No-op when the URI already carries `sslnegotiation`, so the params are never
|
|
* double-appended.
|
|
*/
|
|
export const appendHighAvailabilitySslParams = (uri: string) =>
|
|
uri.includes('sslnegotiation=')
|
|
? uri
|
|
: appendConnectionStringParams(uri, HIGH_AVAILABILITY_SSL_PARAMS)
|
|
|
|
/**
|
|
* The Multigres read-only load balancer listens on this port on the same host
|
|
* as the primary database.
|
|
*/
|
|
export const HIGH_AVAILABILITY_LOAD_BALANCER_PORT = 5433
|
|
|
|
export const getHighAvailabilityLoadBalancerConnectionInfo = <
|
|
T extends { db_port: number | string },
|
|
>(
|
|
connectionInfo: T
|
|
): T => ({ ...connectionInfo, db_port: HIGH_AVAILABILITY_LOAD_BALANCER_PORT })
|
|
|
|
type ConnectionStrings = {
|
|
psql: string
|
|
uri: string
|
|
golang: string
|
|
jdbc: string
|
|
dotnet: string
|
|
nodejs: string
|
|
php: string
|
|
python: string
|
|
sqlalchemy: string
|
|
}
|
|
|
|
/**
|
|
* Self-hosted Supavisor pooler strings. User/password are placeholders that
|
|
* the operator fills in — `POOLER_TENANT_ID` and the postgres password are
|
|
* defined in the docker-compose env.
|
|
*/
|
|
export const getSelfHostedPoolerStrings = (
|
|
dbHost: string,
|
|
port: number | string,
|
|
dbName: string = 'postgres'
|
|
): ConnectionStrings => {
|
|
const user = 'postgres.[POOLER_TENANT_ID]'
|
|
const password = '[YOUR-PASSWORD]'
|
|
|
|
const uri = `postgresql://${user}:${password}@${dbHost}:${port}/${dbName}`
|
|
const psql = `psql 'postgresql://${user}:${password}@${dbHost}:${port}/${dbName}'`
|
|
const golang = `user=${user}\npassword=${password}\nhost=${dbHost}\nport=${port}\ndbname=${dbName}`
|
|
const jdbc = `jdbc:postgresql://${dbHost}:${port}/${dbName}?user=${user}&password=${password}`
|
|
const dotnet = `{
|
|
"ConnectionStrings": {
|
|
"DefaultConnection": "User Id=${user};Password=${password};Server=${dbHost};Port=${port};Database=${dbName}"
|
|
}
|
|
}`
|
|
const nodejs = `DATABASE_URL=${uri}`
|
|
|
|
return {
|
|
psql,
|
|
uri,
|
|
golang,
|
|
jdbc,
|
|
dotnet,
|
|
nodejs,
|
|
php: golang,
|
|
python: golang,
|
|
sqlalchemy: golang,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Self-hosted direct postgres connection strings. Requires the operator to
|
|
* have exposed postgres on the host — by default docker-compose does not.
|
|
*/
|
|
export const getSelfHostedDirectStrings = (
|
|
dbHost: string,
|
|
port: number | string,
|
|
dbName: string = 'postgres'
|
|
): ConnectionStrings => {
|
|
const user = 'postgres'
|
|
const password = '[YOUR-PASSWORD]'
|
|
|
|
const uri = `postgresql://${user}:${password}@${dbHost}:${port}/${dbName}`
|
|
const psql = `psql 'postgresql://${user}:${password}@${dbHost}:${port}/${dbName}'`
|
|
const golang = `user=${user}\npassword=${password}\nhost=${dbHost}\nport=${port}\ndbname=${dbName}`
|
|
const jdbc = `jdbc:postgresql://${dbHost}:${port}/${dbName}?user=${user}&password=${password}`
|
|
const dotnet = `{
|
|
"ConnectionStrings": {
|
|
"DefaultConnection": "User Id=${user};Password=${password};Server=${dbHost};Port=${port};Database=${dbName}"
|
|
}
|
|
}`
|
|
const nodejs = `DATABASE_URL=${uri}`
|
|
|
|
return {
|
|
psql,
|
|
uri,
|
|
golang,
|
|
jdbc,
|
|
dotnet,
|
|
nodejs,
|
|
php: golang,
|
|
python: golang,
|
|
sqlalchemy: golang,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Returns `{ direct, pooler }`. `.direct` depends only on `connectionInfo`, so
|
|
* when callers invoke this twice (once per pooler flavor) as
|
|
* `connectionStringsShared` / `connectionStringsDedicated`, both `.direct`
|
|
* fields are identical — the `Shared`/`Dedicated` suffix only describes which
|
|
* pooler URI you get from `.pooler`.
|
|
*/
|
|
export const getConnectionStrings = ({
|
|
connectionInfo,
|
|
poolingInfo,
|
|
metadata,
|
|
}: {
|
|
connectionInfo: {
|
|
db_user: string
|
|
db_port: number
|
|
db_host: string
|
|
db_name: string
|
|
}
|
|
poolingInfo?: {
|
|
connectionString: string
|
|
db_user: string
|
|
db_port: number
|
|
db_host: string
|
|
db_name: string
|
|
}
|
|
metadata: {
|
|
projectRef?: string
|
|
pgVersion?: string
|
|
}
|
|
}): {
|
|
direct: ConnectionStrings
|
|
pooler: ConnectionStrings
|
|
} => {
|
|
const isMd5 = poolingInfo?.connectionString.includes('options=reference')
|
|
const { projectRef } = metadata
|
|
const password = '[YOUR-PASSWORD]'
|
|
|
|
// Direct connection variables
|
|
const directUser = connectionInfo.db_user
|
|
const directPort = connectionInfo.db_port
|
|
const directHost = connectionInfo.db_host
|
|
const directName = connectionInfo.db_name
|
|
|
|
// Pooler connection variables
|
|
const poolerUser = poolingInfo?.db_user
|
|
const poolerPort = poolingInfo?.db_port
|
|
const poolerHost = poolingInfo?.db_host
|
|
const poolerName = poolingInfo?.db_name
|
|
|
|
// Direct connection strings
|
|
const directPsqlString = isMd5
|
|
? `psql "postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}"`
|
|
: `psql -h ${directHost} -p ${directPort} -d ${directName} -U ${directUser}`
|
|
|
|
const directUriString = `postgresql://${directUser}:${password}@${directHost}:${directPort}/${directName}`
|
|
|
|
const directGolangString = `DATABASE_URL=${directUriString}`
|
|
|
|
const directJdbcString = `jdbc:postgresql://${directHost}:${directPort}/${directName}?user=${directUser}&password=${password}`
|
|
|
|
// User Id=${directUser};Password=${password};Server=${directHost};Port=${directPort};Database=${directName}`
|
|
const directDotNetString = `{
|
|
"ConnectionStrings": {
|
|
"DefaultConnection": "Host=${directHost};Database=${directName};Username=${directUser};Password=${password};SSL Mode=Require;Trust Server Certificate=true"
|
|
}
|
|
}`
|
|
|
|
// `User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}`
|
|
const poolerDotNetString = `{
|
|
"ConnectionStrings": {
|
|
"DefaultConnection": "User Id=${poolerUser};Password=${password};Server=${poolerHost};Port=${poolerPort};Database=${poolerName}${isMd5 ? `;Options='reference=${projectRef}'` : ''}"
|
|
}
|
|
}`
|
|
|
|
const directNodejsString = `DATABASE_URL=${directUriString}`
|
|
|
|
// Pooler connection strings
|
|
const poolerPsqlString = isMd5
|
|
? `psql "postgresql://${poolerUser}:${password}@${poolerHost}:${poolerPort}/${poolerName}?options=reference%3D${projectRef}"`
|
|
: `psql -h ${poolerHost} -p ${poolerPort} -d ${poolerName} -U ${poolerUser}`
|
|
|
|
const poolerUriString = poolingInfo?.connectionString ?? ''
|
|
|
|
const nodejsPoolerUriString = `DATABASE_URL=${poolingInfo?.connectionString ?? ''}`
|
|
|
|
const poolerGolangString = `user=${poolerUser}
|
|
password=${password}
|
|
host=${poolerHost}
|
|
port=${poolerPort}
|
|
dbname=${poolerName}${isMd5 ? `options=reference=${projectRef}` : ''}`
|
|
|
|
const poolerJdbcString = `jdbc:postgresql://${poolerHost}:${poolerPort}/${poolerName}?user=${poolerUser}${isMd5 ? `&options=reference%3D${projectRef}` : ''}&password=${password}`
|
|
|
|
const sqlalchemyString = `user=${directUser}
|
|
password=${password}
|
|
host=${directHost}
|
|
port=${directPort}
|
|
dbname=${directName}`
|
|
|
|
const poolerSqlalchemyString = `user=${poolerUser}
|
|
password=${password}
|
|
host=${poolerHost}
|
|
port=${poolerPort}
|
|
dbname=${poolerName}`
|
|
|
|
return {
|
|
direct: {
|
|
psql: directPsqlString,
|
|
uri: directUriString,
|
|
golang: directGolangString,
|
|
jdbc: directJdbcString,
|
|
dotnet: directDotNetString,
|
|
nodejs: directNodejsString,
|
|
php: directGolangString,
|
|
python: directGolangString,
|
|
sqlalchemy: sqlalchemyString,
|
|
},
|
|
pooler: {
|
|
psql: poolerPsqlString,
|
|
uri: poolerUriString,
|
|
golang: poolerGolangString,
|
|
jdbc: poolerJdbcString,
|
|
dotnet: poolerDotNetString,
|
|
nodejs: nodejsPoolerUriString,
|
|
php: poolerGolangString,
|
|
python: poolerGolangString,
|
|
sqlalchemy: poolerSqlalchemyString,
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shapes the ConnectionStringPooler "bag" consumed by every connection-string
|
|
* step. On platform we keep the existing shared/dedicated pooler layout; on
|
|
* self-hosted we substitute Supavisor placeholder strings on the standard
|
|
* ports; on CLI we collapse to direct since no pooler is exposed.
|
|
*/
|
|
export const buildConnectionStringPooler = ({
|
|
deploymentMode,
|
|
connectionInfo,
|
|
connectionStringsShared,
|
|
connectionStringsDedicated,
|
|
ipv4Addon,
|
|
isHighAvailability,
|
|
}: {
|
|
deploymentMode: DeploymentMode
|
|
connectionInfo: { db_host: string; db_port: number | string }
|
|
connectionStringsShared: { direct: ConnectionStrings; pooler: ConnectionStrings }
|
|
connectionStringsDedicated?: { direct: ConnectionStrings; pooler: ConnectionStrings }
|
|
ipv4Addon: boolean
|
|
isHighAvailability: boolean
|
|
}): ConnectionStringPooler => {
|
|
if (deploymentMode.isSelfHosted) {
|
|
const dbHost = connectionInfo.db_host
|
|
const dbPort = connectionInfo.db_port || 5432
|
|
const sessionPool = getSelfHostedPoolerStrings(dbHost, dbPort)
|
|
const transactionPool = getSelfHostedPoolerStrings(dbHost, 6543)
|
|
const directConn = getSelfHostedDirectStrings(dbHost, dbPort)
|
|
return {
|
|
transactionShared: transactionPool.uri,
|
|
sessionShared: sessionPool.uri,
|
|
transactionDedicated: undefined,
|
|
sessionDedicated: undefined,
|
|
ipv4SupportedForDedicatedPooler: false,
|
|
direct: directConn.uri,
|
|
}
|
|
}
|
|
|
|
if (deploymentMode.isCli) {
|
|
// CLI exposes postgres directly; no pooler is available, so any code path
|
|
// that reaches for a pooler URI falls back to the direct connection.
|
|
const directUri = connectionStringsShared.direct.uri
|
|
return {
|
|
transactionShared: directUri,
|
|
sessionShared: directUri,
|
|
transactionDedicated: undefined,
|
|
sessionDedicated: undefined,
|
|
ipv4SupportedForDedicatedPooler: false,
|
|
direct: directUri,
|
|
}
|
|
}
|
|
|
|
if (isHighAvailability) {
|
|
// Multigres has no pooler (neither Supavisor nor PgBouncer), so every slot
|
|
// falls back to the direct connection.
|
|
const directUri = appendHighAvailabilitySslParams(connectionStringsShared.direct.uri)
|
|
return {
|
|
transactionShared: directUri,
|
|
sessionShared: directUri,
|
|
transactionDedicated: undefined,
|
|
sessionDedicated: undefined,
|
|
ipv4SupportedForDedicatedPooler: false,
|
|
direct: directUri,
|
|
}
|
|
}
|
|
|
|
// Port-swap 6543→5432 derives session from transaction. For shared this is a
|
|
// real Supavisor session connection; for dedicated it lands on direct Postgres
|
|
// (PgBouncer has no session mode).
|
|
return {
|
|
transactionShared: connectionStringsShared.pooler.uri,
|
|
sessionShared: connectionStringsShared.pooler.uri.replace('6543', '5432'),
|
|
transactionDedicated: connectionStringsDedicated?.pooler.uri,
|
|
sessionDedicated: connectionStringsDedicated?.pooler.uri.replace('6543', '5432'),
|
|
ipv4SupportedForDedicatedPooler: ipv4Addon,
|
|
direct: connectionStringsShared.direct.uri,
|
|
}
|
|
}
|