Files
supabase/apps/studio/components/interfaces/ConnectSheet/DatabaseSettings.utils.ts
Alaister Young fc5e03f9e3 [FE-4019] fix(studio): direct-only connection strings with SSL params for Multigres (#48433)
Multigres (high-availability) projects only accept TLS connections with
direct SSL negotiation, and they don't support connection pooling at all
— neither Supavisor nor the dedicated PgBouncer pooler exists for them.
Studio previously showed pooler connection strings that would fail with
"server closed the connection unexpectedly". This PR makes every
connection-string surface direct-only for HA projects and appends
`?sslmode=require&sslnegotiation=direct` to the examples. Non-HA
projects are unchanged.

Addresses
[FE-4019](https://linear.app/supabase/issue/FE-4019/append-ssl-params-to-multigres-connection-string-examples-in-ui)

**Changed:**

- `buildConnectionStringPooler` gets an HA branch that collapses every
slot in the bag to the direct connection string with the SSL params
appended (mirroring the existing CLI branch, which also has no pooler) —
dedicated slots come back `undefined` and
`ipv4SupportedForDedicatedPooler` is forced off. Since HA never reaches
the pooler layout anymore, the earlier per-URI SSL-append logic on
pooler strings is removed
- `useConnectState` coerces `connectionMethod` to `direct` and
`useSharedPooler` to `false` for HA projects. The Connect sheet restores
the last-used method from localStorage shared across projects, so a
"Transaction pooler" selection made on a regular project could otherwise
leak pooler-flavored notices, badges, and telemetry into an HA project
- Prisma and Drizzle ORM tabs get an HA branch:
`DATABASE_URL`/`DIRECT_URL` both use the direct connection, no
`?pgbouncer=true` appended, with a comment explaining Multigres doesn't
support pooling. The 5-arm nested ternaries in both files are flattened
into `getEnvCode` helpers that switch on a shared
`resolveOrmConnectionScenario` helper (`OrmConnection.utils.ts`), so the
deployment-mode/HA branching lives in one tested place and each file
keeps only its own formatting
- The PgBouncer and Supavisor config queries are disabled (`enabled:
!isHighAvailability`) in the Connect sheet — those endpoints serve
pooler config that doesn't exist on Multigres
- `parseConnectionParams` keeps the URI's query string in a new `search`
field so formats rebuilt from parsed parts can carry it
- psql switches from the `-h/-p/-d/-U` flag form to the quoted-URI form
when query params are present (flags can't express them; psql still
prompts for the password)
- JDBC appends the params using pgJDBC's casing (`sslNegotiation`,
supported since 42.7.4)
- Prisma's `?pgbouncer=true` appends are query-aware (join with `&` when
the URI already has a query string) via a new
`appendConnectionStringParams` helper
- The project home "Direct connection string" copy item also appends the
params for HA projects

**Added:**

- Unit tests for the HA collapse behavior (all slots direct, dedicated
config and IPv4 add-on ignored, no SSL params on non-HA output), the
`useConnectState` coercion, the psql/JDBC builders (moved from
`content.tsx` into `ConnectionString.utils.ts` so they're testable), and
`resolveOrmConnectionScenario` (every deployment-mode/HA/pooler branch)

**Known gaps (left out deliberately):**

- The grid ExportDialog psql/pg_dump commands, the .NET
`appsettings.json` (Npgsql only supports direct negotiation from v9 via
`SSL Negotiation=Direct`), and the SQLAlchemy keyword-style `.env` are
flag/keyword forms that can't carry the URI params — these would still
fail against Multigres and need a follow-up
- Settings > Database's Connection Pooling section and the pooler logs
page have no HA gating yet — they'd still render pooler config UI for a
Multigres project and should be hidden in a follow-up

## To test

On a **Multigres (HA) project** (staging only supports `us-east-1` for
Multigres):

- Open the Connect sheet → Direct tab: there's no connection-method
picker, and the connection string is the direct one ending with
`?sslmode=require&sslnegotiation=direct` for the URI, PHP, and psql
(quoted-URI form) types; JDBC includes
`&sslmode=require&sslNegotiation=direct`
- ORM tab → Prisma: both `DATABASE_URL` and `DIRECT_URL` are the direct
connection string with the SSL params, no `pgbouncer=true`, with a
"Multigres does not support connection pooling" comment. Drizzle
likewise shows the direct string only
- Framework tabs (e.g. Next.js): every `DATABASE_URL` carries the direct
string with the params exactly once
- Open the network tab: no requests to `/config/pgbouncer` or
`/config/supavisor` while using the Connect sheet
- To check the localStorage coercion: on a **regular** project pick
"Transaction pooler" in the Connect sheet, then open the sheet on the
Multigres project — no pooler badge/notices, string is still direct
- Copy the URI, substitute your password, and `psql "<string>"` — it
should connect
- Project home → Copy dropdown → "Direct connection string" includes the
params

On a **regular (non-Multigres) project** — confirm nothing changed:

- Connect sheet: direct/session/transaction strings for all connection
types (URI, psql flag form, JDBC, PHP) look the same as before, no SSL
params appended
- Prisma/Drizzle tabs render identically (`?pgbouncer=true` still
appended with `?`, dedicated-pooler alternatives still shown per IPv4
add-on state)
- Project home copy dropdown is unchanged


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

* **New Features**
* Enhanced connection-string generation for high-availability projects,
including required SSL settings for direct connections.
* Preserved URI query parameters in PostgreSQL, `psql`, JDBC, and
generated environment configurations.
* Improved ORM environment templates with clearer handling for pooler
and high-availability connection scenarios.

* **Bug Fixes**
* High-availability projects now consistently use direct connections
instead of pooler options.
* Connection strings and generated templates update correctly when
availability settings change.

* **Tests**
* Expanded coverage for query parameters, high-availability behavior,
and connection scenarios.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
2026-07-31 14:34:31 +08:00

313 lines
10 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)
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,
}
}