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

155 lines
5.0 KiB
TypeScript

import type { ConnectionStringMethod } from './Connect.constants'
import type { ConnectionStringPooler } from './Connect.types'
export const DEFAULT_PORT = '5432'
export const PASSWORD_PLACEHOLDER = '[YOUR-PASSWORD]'
/** Appends query params to a connection string, joining with `?` or `&` as needed */
export const appendConnectionStringParams = (uri: string, params: string) =>
!uri || !params ? uri : `${uri}${uri.includes('?') ? '&' : '?'}${params}`
export type ConnectionParams = {
host: string
port: string
user: string
database: string
/** Raw query string including the leading `?`, or '' when the URI has none */
search: string
}
export const resolveConnectionString = ({
connectionMethod,
useSharedPooler,
connectionStringPooler,
}: {
connectionMethod: ConnectionStringMethod
useSharedPooler: boolean
connectionStringPooler: ConnectionStringPooler | undefined
}) => {
if (!connectionStringPooler) return ''
if (connectionMethod === 'direct') {
return connectionStringPooler.direct ?? ''
}
if (connectionMethod === 'session') {
return connectionStringPooler.sessionShared ?? ''
}
if (useSharedPooler || !connectionStringPooler.transactionDedicated) {
return connectionStringPooler.transactionShared ?? ''
}
return connectionStringPooler.transactionDedicated ?? ''
}
export const parseConnectionParams = (connectionString: string): ConnectionParams => {
if (!connectionString) {
return {
host: 'hidden',
port: DEFAULT_PORT,
user: 'hidden',
database: 'hidden',
search: '',
}
}
try {
const parsed = new URL(connectionString)
// The URL parser percent-encodes characters that aren't valid in user-info
// (e.g. brackets in the self-hosted `postgres.[POOLER_TENANT_ID]` placeholder).
// Decode so the displayed string matches the literal we wrote.
const decode = (value: string) => {
try {
return decodeURIComponent(value)
} catch {
return value
}
}
return {
host: parsed.hostname || 'hidden',
port: parsed.port || DEFAULT_PORT,
user: parsed.username ? decode(parsed.username) : 'hidden',
database: parsed.pathname?.replace(/^\//, '') || 'hidden',
search: parsed.search,
}
} catch (error) {
return {
host: 'hidden',
port: DEFAULT_PORT,
user: 'hidden',
database: 'hidden',
search: '',
}
}
}
export const buildSafeConnectionString = (
connectionString: string,
params: ConnectionParams
): string => {
if (!connectionString) return ''
return `postgresql://${params.user}:${PASSWORD_PLACEHOLDER}@${params.host}:${params.port}/${params.database}${params.search}`
}
export const buildPsqlCommand = (params: ConnectionParams) =>
params.search
? // Query params (e.g. sslmode) can't be expressed as psql flags, so fall
// back to the URI form — psql prompts for the password.
`psql "postgresql://${params.user}@${params.host}:${params.port}/${params.database}${params.search}"`
: `psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}`
export const buildJdbcString = (params: ConnectionParams) => {
// pgJDBC (42.7.4+) spells libpq's `sslnegotiation` as `sslNegotiation`
const extraParams = params.search
? `&${params.search.slice(1).replace('sslnegotiation=', 'sslNegotiation=')}`
: ''
return `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}${extraParams}`
}
/**
* Ensures a connection string's query params carry `sslmode=require` without
* dropping params the URI already has (e.g. `options=reference%3D...` or
* `sslnegotiation=direct`).
*/
export const withRequiredSslmode = (search: string) => {
if (!search) return '?sslmode=require'
if (search.includes('sslmode=')) return search
return `${search}&sslmode=require`
}
export const buildDotnetConnectionString = (params: ConnectionParams) => {
// Multigres only accepts direct SSL negotiation; Npgsql (9+) spells it
// `SSL Negotiation=Direct` and throws on the parameter in older versions,
// so only emit it when the resolved URI carries the param.
const sslNegotiation = params.search.includes('sslnegotiation=direct')
? ';SSL Negotiation=Direct'
: ''
return `Host=${params.host};Port=${params.port};Database=${params.database};Username=${params.user};Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true${sslNegotiation}`
}
export const buildConnectionStringWithPassword = (
connectionString: string,
password: string
): string => {
if (!connectionString || !password) return connectionString
const encodedPassword = (() => {
try {
return encodeURIComponent(password)
} catch {
return password
}
})()
return connectionString.split(PASSWORD_PLACEHOLDER).join(encodedPassword)
}
export const buildConnectionParameters = (params: ConnectionParams) => [
{ key: 'host', value: params.host },
{ key: 'port', value: params.port },
{ key: 'database', value: params.database },
{ key: 'user', value: params.user },
]