mirror of
https://github.com/supabase/supabase.git
synced 2026-10-06 09:55:06 +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>
305 lines
9.7 KiB
TypeScript
305 lines
9.7 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
|
|
import {
|
|
appendConnectionStringParams,
|
|
buildConnectionParameters,
|
|
buildConnectionStringWithPassword,
|
|
buildDotnetConnectionString,
|
|
buildJdbcString,
|
|
buildPsqlCommand,
|
|
buildSafeConnectionString,
|
|
DEFAULT_PORT,
|
|
parseConnectionParams,
|
|
PASSWORD_PLACEHOLDER,
|
|
resolveConnectionString,
|
|
withRequiredSslmode,
|
|
} from '../ConnectionString.utils'
|
|
|
|
describe('parseConnectionParams', () => {
|
|
test('returns hidden defaults for an empty string', () => {
|
|
expect(parseConnectionParams('')).toEqual({
|
|
host: 'hidden',
|
|
port: DEFAULT_PORT,
|
|
user: 'hidden',
|
|
database: 'hidden',
|
|
search: '',
|
|
})
|
|
})
|
|
|
|
test('returns hidden defaults for an unparseable URL', () => {
|
|
expect(parseConnectionParams('not a url')).toEqual({
|
|
host: 'hidden',
|
|
port: DEFAULT_PORT,
|
|
user: 'hidden',
|
|
database: 'hidden',
|
|
search: '',
|
|
})
|
|
})
|
|
|
|
test('parses a platform-shaped connection string', () => {
|
|
const uri =
|
|
'postgresql://postgres.projref:[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres'
|
|
expect(parseConnectionParams(uri)).toEqual({
|
|
host: 'aws-0-eu-west-1.pooler.supabase.com',
|
|
port: '6543',
|
|
user: 'postgres.projref',
|
|
database: 'postgres',
|
|
search: '',
|
|
})
|
|
})
|
|
|
|
test('keeps the query string in search', () => {
|
|
const uri =
|
|
'postgresql://postgres:[YOUR-PASSWORD]@db.proj.supabase.co:5432/postgres?sslmode=require&sslnegotiation=direct'
|
|
expect(parseConnectionParams(uri).search).toBe('?sslmode=require&sslnegotiation=direct')
|
|
})
|
|
|
|
test('decodes percent-encoded bracket placeholders in the user info', () => {
|
|
// The URL parser percent-encodes the `[`/`]` in self-hosted's POOLER_TENANT_ID placeholder.
|
|
// parseConnectionParams must decode so the displayed user matches what we wrote.
|
|
const uri =
|
|
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:6543/postgres'
|
|
expect(parseConnectionParams(uri).user).toBe('postgres.[POOLER_TENANT_ID]')
|
|
})
|
|
})
|
|
|
|
describe('buildSafeConnectionString', () => {
|
|
test('returns empty string when input is empty', () => {
|
|
expect(buildSafeConnectionString('', parseConnectionParams(''))).toBe('')
|
|
})
|
|
|
|
test('rebuilds the URL with PASSWORD_PLACEHOLDER and the parsed params (round-trips brackets)', () => {
|
|
const uri =
|
|
'postgresql://postgres.[POOLER_TENANT_ID]:[YOUR-PASSWORD]@supabase.example.com:6543/postgres'
|
|
const params = parseConnectionParams(uri)
|
|
const safe = buildSafeConnectionString(uri, params)
|
|
expect(safe).toBe(
|
|
`postgresql://postgres.[POOLER_TENANT_ID]:${PASSWORD_PLACEHOLDER}@supabase.example.com:6543/postgres`
|
|
)
|
|
})
|
|
|
|
test('preserves search params from the original URL', () => {
|
|
const uri = 'postgresql://postgres.proj:[YOUR-PASSWORD]@host:5432/postgres?sslmode=require'
|
|
const params = parseConnectionParams(uri)
|
|
expect(buildSafeConnectionString(uri, params)).toContain('?sslmode=require')
|
|
})
|
|
})
|
|
|
|
describe('buildConnectionStringWithPassword', () => {
|
|
test('returns the original string when input or password is empty', () => {
|
|
const uri = `postgresql://postgres:${PASSWORD_PLACEHOLDER}@localhost:5432/postgres`
|
|
|
|
expect(buildConnectionStringWithPassword('', 'password')).toBe('')
|
|
expect(buildConnectionStringWithPassword(uri, '')).toBe(uri)
|
|
})
|
|
|
|
test('replaces every password placeholder with the encoded password', () => {
|
|
const uri = `postgresql://postgres:${PASSWORD_PLACEHOLDER}@localhost:5432/postgres?password=${PASSWORD_PLACEHOLDER}`
|
|
|
|
expect(buildConnectionStringWithPassword(uri, 'p@ss/word#1')).toBe(
|
|
'postgresql://postgres:p%40ss%2Fword%231@localhost:5432/postgres?password=p%40ss%2Fword%231'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('resolveConnectionString', () => {
|
|
const pooler = {
|
|
transactionShared: 'tx-shared',
|
|
sessionShared: 'session-shared',
|
|
transactionDedicated: 'tx-dedicated',
|
|
sessionDedicated: 'session-dedicated',
|
|
ipv4SupportedForDedicatedPooler: true,
|
|
direct: 'direct-uri',
|
|
}
|
|
|
|
test('returns empty string when pooler bag is undefined', () => {
|
|
expect(
|
|
resolveConnectionString({
|
|
connectionMethod: 'direct',
|
|
useSharedPooler: false,
|
|
connectionStringPooler: undefined,
|
|
})
|
|
).toBe('')
|
|
})
|
|
|
|
test('direct method returns the direct URI', () => {
|
|
expect(
|
|
resolveConnectionString({
|
|
connectionMethod: 'direct',
|
|
useSharedPooler: false,
|
|
connectionStringPooler: pooler,
|
|
})
|
|
).toBe('direct-uri')
|
|
})
|
|
|
|
test('session method returns sessionShared', () => {
|
|
expect(
|
|
resolveConnectionString({
|
|
connectionMethod: 'session',
|
|
useSharedPooler: false,
|
|
connectionStringPooler: pooler,
|
|
})
|
|
).toBe('session-shared')
|
|
})
|
|
|
|
test('transaction prefers dedicated when available and useSharedPooler is false', () => {
|
|
expect(
|
|
resolveConnectionString({
|
|
connectionMethod: 'transaction',
|
|
useSharedPooler: false,
|
|
connectionStringPooler: pooler,
|
|
})
|
|
).toBe('tx-dedicated')
|
|
})
|
|
|
|
test('transaction falls back to shared when useSharedPooler is true', () => {
|
|
expect(
|
|
resolveConnectionString({
|
|
connectionMethod: 'transaction',
|
|
useSharedPooler: true,
|
|
connectionStringPooler: pooler,
|
|
})
|
|
).toBe('tx-shared')
|
|
})
|
|
|
|
test('transaction falls back to shared when no dedicated pooler exists', () => {
|
|
expect(
|
|
resolveConnectionString({
|
|
connectionMethod: 'transaction',
|
|
useSharedPooler: false,
|
|
connectionStringPooler: { ...pooler, transactionDedicated: undefined },
|
|
})
|
|
).toBe('tx-shared')
|
|
})
|
|
})
|
|
|
|
describe('appendConnectionStringParams', () => {
|
|
test('joins with ? when the URI has no query string', () => {
|
|
expect(appendConnectionStringParams('postgresql://u@h:5432/db', 'pgbouncer=true')).toBe(
|
|
'postgresql://u@h:5432/db?pgbouncer=true'
|
|
)
|
|
})
|
|
|
|
test('joins with & when the URI already has a query string', () => {
|
|
expect(
|
|
appendConnectionStringParams('postgresql://u@h:5432/db?sslmode=require', 'pgbouncer=true')
|
|
).toBe('postgresql://u@h:5432/db?sslmode=require&pgbouncer=true')
|
|
})
|
|
|
|
test('returns the URI unchanged for empty inputs', () => {
|
|
expect(appendConnectionStringParams('', 'pgbouncer=true')).toBe('')
|
|
expect(appendConnectionStringParams('postgresql://u@h:5432/db', '')).toBe(
|
|
'postgresql://u@h:5432/db'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('buildPsqlCommand', () => {
|
|
const params = {
|
|
host: 'db.proj.supabase.co',
|
|
port: '5432',
|
|
user: 'postgres',
|
|
database: 'postgres',
|
|
search: '',
|
|
}
|
|
|
|
test('uses flag form when there is no query string', () => {
|
|
expect(buildPsqlCommand(params)).toBe(
|
|
'psql -h db.proj.supabase.co -p 5432 -d postgres -U postgres'
|
|
)
|
|
})
|
|
|
|
test('falls back to the URI form when the query string must be carried', () => {
|
|
expect(buildPsqlCommand({ ...params, search: '?sslmode=require&sslnegotiation=direct' })).toBe(
|
|
'psql "postgresql://postgres@db.proj.supabase.co:5432/postgres?sslmode=require&sslnegotiation=direct"'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('buildJdbcString', () => {
|
|
const params = {
|
|
host: 'db.proj.supabase.co',
|
|
port: '5432',
|
|
user: 'postgres',
|
|
database: 'postgres',
|
|
search: '',
|
|
}
|
|
|
|
test('builds the base string without extra params', () => {
|
|
expect(buildJdbcString(params)).toBe(
|
|
`jdbc:postgresql://db.proj.supabase.co:5432/postgres?user=postgres&password=${PASSWORD_PLACEHOLDER}`
|
|
)
|
|
})
|
|
|
|
test('appends the query string using pgJDBC casing for sslnegotiation', () => {
|
|
expect(buildJdbcString({ ...params, search: '?sslmode=require&sslnegotiation=direct' })).toBe(
|
|
`jdbc:postgresql://db.proj.supabase.co:5432/postgres?user=postgres&password=${PASSWORD_PLACEHOLDER}&sslmode=require&sslNegotiation=direct`
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('withRequiredSslmode', () => {
|
|
test('returns ?sslmode=require for an empty search', () => {
|
|
expect(withRequiredSslmode('')).toBe('?sslmode=require')
|
|
})
|
|
|
|
test('leaves a search that already sets sslmode unchanged', () => {
|
|
expect(withRequiredSslmode('?sslmode=require&sslnegotiation=direct')).toBe(
|
|
'?sslmode=require&sslnegotiation=direct'
|
|
)
|
|
})
|
|
|
|
test('appends sslmode=require to existing params without it', () => {
|
|
expect(withRequiredSslmode('?options=reference%3Dproj')).toBe(
|
|
'?options=reference%3Dproj&sslmode=require'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('buildDotnetConnectionString', () => {
|
|
const params = {
|
|
host: 'db.proj.supabase.co',
|
|
port: '5432',
|
|
user: 'postgres',
|
|
database: 'postgres',
|
|
search: '',
|
|
}
|
|
|
|
test('builds the base Npgsql string without SSL negotiation', () => {
|
|
expect(buildDotnetConnectionString(params)).toBe(
|
|
`Host=db.proj.supabase.co;Port=5432;Database=postgres;Username=postgres;Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true`
|
|
)
|
|
})
|
|
|
|
test('appends SSL Negotiation=Direct when the URI requires direct negotiation', () => {
|
|
expect(
|
|
buildDotnetConnectionString({
|
|
...params,
|
|
port: '5433',
|
|
search: '?sslmode=require&sslnegotiation=direct',
|
|
})
|
|
).toBe(
|
|
`Host=db.proj.supabase.co;Port=5433;Database=postgres;Username=postgres;Password=${PASSWORD_PLACEHOLDER};SSL Mode=Require;Trust Server Certificate=true;SSL Negotiation=Direct`
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('buildConnectionParameters', () => {
|
|
test('produces host/port/database/user rows in display order', () => {
|
|
expect(
|
|
buildConnectionParameters({
|
|
host: 'h',
|
|
port: '5432',
|
|
user: 'u',
|
|
database: 'd',
|
|
search: '',
|
|
})
|
|
).toEqual([
|
|
{ key: 'host', value: 'h' },
|
|
{ key: 'port', value: '5432' },
|
|
{ key: 'database', value: 'd' },
|
|
{ key: 'user', value: 'u' },
|
|
])
|
|
})
|
|
})
|