mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
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>
287 lines
11 KiB
TypeScript
287 lines
11 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
appendHighAvailabilitySslParams,
|
|
buildConnectionStringPooler,
|
|
getConnectionStrings,
|
|
getHighAvailabilityLoadBalancerConnectionInfo,
|
|
getSelfHostedDirectStrings,
|
|
getSelfHostedPoolerStrings,
|
|
HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
|
|
HIGH_AVAILABILITY_SSL_PARAMS,
|
|
} from '../DatabaseSettings.utils'
|
|
import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
|
|
|
|
const platform: DeploymentMode = { isPlatform: true, isCli: false, isSelfHosted: false }
|
|
const cli: DeploymentMode = { isPlatform: false, isCli: true, isSelfHosted: false }
|
|
const selfHosted: DeploymentMode = { isPlatform: false, isCli: false, isSelfHosted: true }
|
|
|
|
// Minimal ConnectionStrings stub — only `uri` matters for buildConnectionStringPooler.
|
|
const makeStrings = (poolerUri: string, directUri: string) =>
|
|
({
|
|
direct: {
|
|
uri: directUri,
|
|
psql: '',
|
|
golang: '',
|
|
jdbc: '',
|
|
dotnet: '',
|
|
nodejs: '',
|
|
php: '',
|
|
python: '',
|
|
sqlalchemy: '',
|
|
},
|
|
pooler: {
|
|
uri: poolerUri,
|
|
psql: '',
|
|
golang: '',
|
|
jdbc: '',
|
|
dotnet: '',
|
|
nodejs: '',
|
|
php: '',
|
|
python: '',
|
|
sqlalchemy: '',
|
|
},
|
|
}) as any
|
|
|
|
describe('getSelfHostedPoolerStrings', () => {
|
|
test('uses POOLER_TENANT_ID and YOUR-PASSWORD placeholders', () => {
|
|
const strings = getSelfHostedPoolerStrings('db.example.com', 6543)
|
|
expect(strings.uri).toBe(
|
|
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@db.example.com:6543/postgres'
|
|
)
|
|
})
|
|
|
|
test('threads host, port, and database name through every format', () => {
|
|
const strings = getSelfHostedPoolerStrings('db.example.com', 5432, 'mydb')
|
|
expect(strings.uri).toContain('db.example.com:5432/mydb')
|
|
expect(strings.psql).toContain('db.example.com:5432/mydb')
|
|
expect(strings.jdbc).toContain('db.example.com:5432/mydb')
|
|
expect(strings.golang).toContain('host=db.example.com')
|
|
expect(strings.golang).toContain('port=5432')
|
|
expect(strings.golang).toContain('dbname=mydb')
|
|
expect(strings.dotnet).toContain('Server=db.example.com')
|
|
expect(strings.dotnet).toContain('Port=5432')
|
|
expect(strings.dotnet).toContain('Database=mydb')
|
|
expect(strings.nodejs).toBe(`DATABASE_URL=${strings.uri}`)
|
|
})
|
|
|
|
test('defaults database name to postgres', () => {
|
|
const strings = getSelfHostedPoolerStrings('db.example.com', 6543)
|
|
expect(strings.uri.endsWith('/postgres')).toBe(true)
|
|
})
|
|
})
|
|
|
|
describe('getSelfHostedDirectStrings', () => {
|
|
test('uses plain postgres user (no tenant id)', () => {
|
|
const strings = getSelfHostedDirectStrings('db.example.com', 5432)
|
|
expect(strings.uri).toBe('postgresql://postgres:[YOUR-PASSWORD]@db.example.com:5432/postgres')
|
|
})
|
|
|
|
test('threads host, port, and database name through every format', () => {
|
|
const strings = getSelfHostedDirectStrings('db.example.com', 5432, 'mydb')
|
|
expect(strings.uri).toContain('postgres:[YOUR-PASSWORD]@db.example.com:5432/mydb')
|
|
expect(strings.jdbc).toContain('db.example.com:5432/mydb')
|
|
expect(strings.golang).toContain('user=postgres\n')
|
|
expect(strings.dotnet).toContain('User Id=postgres;')
|
|
})
|
|
})
|
|
|
|
describe('buildConnectionStringPooler', () => {
|
|
const sharedPlatform = makeStrings(
|
|
'postgresql://postgres.proj:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres',
|
|
'postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5432/postgres'
|
|
)
|
|
const dedicatedPlatform = makeStrings(
|
|
'postgresql://dedicated.proj:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres',
|
|
''
|
|
)
|
|
const connectionInfo = { db_host: 'db.example.com', db_port: 5432 }
|
|
|
|
test('platform: returns shared + dedicated pooler bag and direct URI', () => {
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: platform,
|
|
connectionInfo,
|
|
connectionStringsShared: sharedPlatform,
|
|
connectionStringsDedicated: dedicatedPlatform,
|
|
ipv4Addon: true,
|
|
isHighAvailability: false,
|
|
})
|
|
|
|
expect(result.transactionShared).toBe(sharedPlatform.pooler.uri)
|
|
expect(result.sessionShared).toBe(sharedPlatform.pooler.uri.replace('6543', '5432'))
|
|
expect(result.transactionDedicated).toBe(dedicatedPlatform.pooler.uri)
|
|
expect(result.sessionDedicated).toBe(dedicatedPlatform.pooler.uri.replace('6543', '5432'))
|
|
expect(result.ipv4SupportedForDedicatedPooler).toBe(true)
|
|
expect(result.direct).toBe(sharedPlatform.direct.uri)
|
|
})
|
|
|
|
test('platform without IPv4 addon flips ipv4SupportedForDedicatedPooler', () => {
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: platform,
|
|
connectionInfo,
|
|
connectionStringsShared: sharedPlatform,
|
|
ipv4Addon: false,
|
|
isHighAvailability: false,
|
|
})
|
|
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
|
|
expect(result.transactionDedicated).toBeUndefined()
|
|
expect(result.sessionDedicated).toBeUndefined()
|
|
})
|
|
|
|
test('CLI: collapses pooler URIs to the direct URI (no pooler in CLI)', () => {
|
|
const directUri = sharedPlatform.direct.uri
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: cli,
|
|
connectionInfo,
|
|
connectionStringsShared: sharedPlatform,
|
|
ipv4Addon: false,
|
|
isHighAvailability: false,
|
|
})
|
|
|
|
expect(result.direct).toBe(directUri)
|
|
expect(result.transactionShared).toBe(directUri)
|
|
expect(result.sessionShared).toBe(directUri)
|
|
expect(result.transactionDedicated).toBeUndefined()
|
|
expect(result.sessionDedicated).toBeUndefined()
|
|
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
|
|
})
|
|
|
|
test('self-hosted: uses Supavisor placeholders on 6543/dbPort, direct on dbPort', () => {
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: selfHosted,
|
|
connectionInfo: { db_host: 'supabase.example.com', db_port: 5432 },
|
|
connectionStringsShared: sharedPlatform,
|
|
ipv4Addon: true,
|
|
isHighAvailability: false,
|
|
})
|
|
|
|
expect(result.transactionShared).toBe(
|
|
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:6543/postgres'
|
|
)
|
|
expect(result.sessionShared).toBe(
|
|
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:5432/postgres'
|
|
)
|
|
expect(result.direct).toBe(
|
|
'postgresql://postgres:[YOUR-PASSWORD]@supabase.example.com:5432/postgres'
|
|
)
|
|
expect(result.transactionDedicated).toBeUndefined()
|
|
expect(result.sessionDedicated).toBeUndefined()
|
|
// ipv4Addon=true must NOT leak into self-hosted: there's no dedicated pooler here
|
|
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
|
|
})
|
|
|
|
test('self-hosted: falls back to port 5432 when db_port is unset', () => {
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: selfHosted,
|
|
connectionInfo: { db_host: 'supabase.example.com', db_port: 0 },
|
|
connectionStringsShared: sharedPlatform,
|
|
ipv4Addon: false,
|
|
isHighAvailability: false,
|
|
})
|
|
expect(result.sessionShared).toContain(':5432/postgres')
|
|
expect(result.direct).toContain(':5432/postgres')
|
|
})
|
|
|
|
test('platform high availability: collapses every slot to the direct URI with SSL params', () => {
|
|
const directUri = `${sharedPlatform.direct.uri}?${HIGH_AVAILABILITY_SSL_PARAMS}`
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: platform,
|
|
connectionInfo,
|
|
connectionStringsShared: sharedPlatform,
|
|
connectionStringsDedicated: dedicatedPlatform,
|
|
ipv4Addon: true,
|
|
isHighAvailability: true,
|
|
})
|
|
|
|
expect(result.direct).toBe(directUri)
|
|
expect(result.transactionShared).toBe(directUri)
|
|
expect(result.sessionShared).toBe(directUri)
|
|
// No pooler on Multigres: dedicated slots stay empty and the IPv4 flag is
|
|
// off even when a dedicated pooler config and the addon were passed in
|
|
expect(result.transactionDedicated).toBeUndefined()
|
|
expect(result.sessionDedicated).toBeUndefined()
|
|
expect(result.ipv4SupportedForDedicatedPooler).toBe(false)
|
|
})
|
|
|
|
test('platform without high availability appends no SSL params', () => {
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: platform,
|
|
connectionInfo,
|
|
connectionStringsShared: sharedPlatform,
|
|
connectionStringsDedicated: dedicatedPlatform,
|
|
ipv4Addon: true,
|
|
isHighAvailability: false,
|
|
})
|
|
|
|
expect(result.direct).toBe(sharedPlatform.direct.uri)
|
|
expect(result.transactionShared).toBe(sharedPlatform.pooler.uri)
|
|
expect(result.direct).not.toContain('sslnegotiation')
|
|
expect(result.transactionShared).not.toContain('sslnegotiation')
|
|
})
|
|
})
|
|
|
|
describe('appendHighAvailabilitySslParams', () => {
|
|
test('appends with ? when the URI has no query string', () => {
|
|
expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db')).toBe(
|
|
`postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}`
|
|
)
|
|
})
|
|
|
|
test('appends with & when the URI already has a query string', () => {
|
|
expect(appendHighAvailabilitySslParams('postgresql://u:p@host:5432/db?options=x')).toBe(
|
|
`postgresql://u:p@host:5432/db?options=x&${HIGH_AVAILABILITY_SSL_PARAMS}`
|
|
)
|
|
})
|
|
|
|
test('does not double-append when sslnegotiation is already present', () => {
|
|
const uri = `postgresql://u:p@host:5432/db?${HIGH_AVAILABILITY_SSL_PARAMS}`
|
|
expect(appendHighAvailabilitySslParams(uri)).toBe(uri)
|
|
})
|
|
|
|
test('leaves an empty string untouched', () => {
|
|
expect(appendHighAvailabilitySslParams('')).toBe('')
|
|
})
|
|
})
|
|
|
|
describe('getHighAvailabilityLoadBalancerConnectionInfo', () => {
|
|
test('swaps the port for the load balancer port and preserves the other fields', () => {
|
|
const connectionInfo = {
|
|
db_user: 'postgres',
|
|
db_port: 5432,
|
|
db_host: 'db.proj.supabase.co',
|
|
db_name: 'postgres',
|
|
}
|
|
|
|
expect(getHighAvailabilityLoadBalancerConnectionInfo(connectionInfo)).toEqual({
|
|
db_user: 'postgres',
|
|
db_port: HIGH_AVAILABILITY_LOAD_BALANCER_PORT,
|
|
db_host: 'db.proj.supabase.co',
|
|
db_name: 'postgres',
|
|
})
|
|
})
|
|
|
|
test('builds a read-only load balancer connection string on port 5433 with SSL params', () => {
|
|
const loadBalancerInfo = getHighAvailabilityLoadBalancerConnectionInfo({
|
|
db_user: 'postgres',
|
|
db_port: 5432,
|
|
db_host: 'db.proj.supabase.co',
|
|
db_name: 'postgres',
|
|
})
|
|
|
|
const result = buildConnectionStringPooler({
|
|
deploymentMode: platform,
|
|
connectionInfo: loadBalancerInfo,
|
|
connectionStringsShared: getConnectionStrings({
|
|
connectionInfo: loadBalancerInfo,
|
|
metadata: { projectRef: 'proj' },
|
|
}),
|
|
ipv4Addon: false,
|
|
isHighAvailability: true,
|
|
})
|
|
|
|
expect(result.direct).toBe(
|
|
`postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5433/postgres?${HIGH_AVAILABILITY_SSL_PARAMS}`
|
|
)
|
|
})
|
|
})
|