mirror of
https://github.com/supabase/supabase.git
synced 2026-10-09 03:15: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>
234 lines
6.7 KiB
TypeScript
234 lines
6.7 KiB
TypeScript
import { describe, expect, test } from 'vitest'
|
|
|
|
import type { ConnectMode, ConnectState } from '../Connect.types'
|
|
import {
|
|
resolveContentPath,
|
|
shouldFetchDataApiConfig,
|
|
shouldShowDataApiDisabledWarning,
|
|
shouldShowIpv4AddonNotice,
|
|
shouldShowSelfHostedMcpNotice,
|
|
shouldShowSessionPoolerNotice,
|
|
} from '../ConnectStepsSection.utils'
|
|
|
|
const ALL_MODES: ConnectMode[] = ['framework', 'direct', 'orm', 'mcp', 'server']
|
|
const MCP_WITH_DATABASE_TOOLS = {
|
|
mode: 'mcp' as const,
|
|
mcpFeatures: ['database'],
|
|
}
|
|
|
|
describe('shouldFetchDataApiConfig', () => {
|
|
test('returns true for framework mode', () => {
|
|
expect(shouldFetchDataApiConfig({ mode: 'framework' })).toBe(true)
|
|
})
|
|
|
|
test.each(['direct', 'orm', 'server', 'mcp'] as const)('returns false for %s mode', (mode) => {
|
|
expect(shouldFetchDataApiConfig({ mode })).toBe(false)
|
|
})
|
|
|
|
test('returns false for mcp even when database tools are selected', () => {
|
|
expect(shouldFetchDataApiConfig(MCP_WITH_DATABASE_TOOLS)).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('shouldShowDataApiDisabledWarning', () => {
|
|
test.each(ALL_MODES)('returns false while pending for %s mode', (mode) => {
|
|
expect(
|
|
shouldShowDataApiDisabledWarning({
|
|
mode,
|
|
isDataApiEnabled: false,
|
|
isPending: true,
|
|
isError: false,
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
test('returns false when config query errored', () => {
|
|
expect(
|
|
shouldShowDataApiDisabledWarning({
|
|
mode: 'framework',
|
|
isDataApiEnabled: false,
|
|
isPending: false,
|
|
isError: true,
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
test('returns true for framework when Data API is disabled', () => {
|
|
expect(
|
|
shouldShowDataApiDisabledWarning({
|
|
mode: 'framework',
|
|
isDataApiEnabled: false,
|
|
isPending: false,
|
|
isError: false,
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
test.each(['direct', 'orm', 'server', 'mcp'] as const)(
|
|
'returns false for %s when Data API is disabled',
|
|
(mode) => {
|
|
expect(
|
|
shouldShowDataApiDisabledWarning({
|
|
mode,
|
|
isDataApiEnabled: false,
|
|
isPending: false,
|
|
isError: false,
|
|
})
|
|
).toBe(false)
|
|
}
|
|
)
|
|
|
|
test('returns false for mcp with database tools selected when Data API is disabled', () => {
|
|
expect(
|
|
shouldShowDataApiDisabledWarning({
|
|
...MCP_WITH_DATABASE_TOOLS,
|
|
isDataApiEnabled: false,
|
|
isPending: false,
|
|
isError: false,
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
test('returns false when framework mode has Data API enabled', () => {
|
|
expect(
|
|
shouldShowDataApiDisabledWarning({
|
|
mode: 'framework',
|
|
isDataApiEnabled: true,
|
|
isPending: false,
|
|
isError: false,
|
|
})
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('resolveContentPath', () => {
|
|
test('replaces multiple placeholders with state values', () => {
|
|
const state: ConnectState = {
|
|
mode: 'framework',
|
|
framework: 'nextjs',
|
|
frameworkVariant: 'app',
|
|
library: 'supabasejs',
|
|
}
|
|
expect(resolveContentPath('{{framework}}/{{frameworkVariant}}/{{library}}', state)).toBe(
|
|
'nextjs/app/supabasejs'
|
|
)
|
|
})
|
|
|
|
test('filters out segments that resolve to an empty/missing state value', () => {
|
|
const state: ConnectState = { mode: 'framework', framework: 'nextjs' }
|
|
expect(resolveContentPath('{{framework}}/{{frameworkVariant}}', state)).toBe('nextjs')
|
|
})
|
|
|
|
test('returns the template unchanged when it has no placeholders', () => {
|
|
const state: ConnectState = { mode: 'direct' }
|
|
expect(resolveContentPath('steps/install', state)).toBe('steps/install')
|
|
})
|
|
})
|
|
|
|
describe('shouldShowIpv4AddonNotice', () => {
|
|
const BASE = {
|
|
isPlatform: true,
|
|
mode: 'direct' as const,
|
|
connectionMethod: 'direct',
|
|
useSharedPooler: false,
|
|
hasIpv4Addon: false,
|
|
isHighAvailability: false,
|
|
}
|
|
|
|
test('returns true for a direct connection with no IPv4 addon', () => {
|
|
expect(shouldShowIpv4AddonNotice(BASE)).toBe(true)
|
|
})
|
|
|
|
test('returns true for the transaction pooler when not using the shared pooler', () => {
|
|
expect(
|
|
shouldShowIpv4AddonNotice({
|
|
...BASE,
|
|
connectionMethod: 'transaction',
|
|
useSharedPooler: false,
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
test('returns false for the transaction pooler when using the shared pooler (already IPv4)', () => {
|
|
expect(
|
|
shouldShowIpv4AddonNotice({ ...BASE, connectionMethod: 'transaction', useSharedPooler: true })
|
|
).toBe(false)
|
|
})
|
|
|
|
test('returns false for the session pooler (has its own notice)', () => {
|
|
expect(shouldShowIpv4AddonNotice({ ...BASE, connectionMethod: 'session' })).toBe(false)
|
|
})
|
|
|
|
test('returns false when the IPv4 addon is already enabled', () => {
|
|
expect(shouldShowIpv4AddonNotice({ ...BASE, hasIpv4Addon: true })).toBe(false)
|
|
})
|
|
|
|
test('returns false when not in direct mode', () => {
|
|
expect(shouldShowIpv4AddonNotice({ ...BASE, mode: 'framework' })).toBe(false)
|
|
})
|
|
|
|
test('returns false when self-hosted (not platform)', () => {
|
|
expect(shouldShowIpv4AddonNotice({ ...BASE, isPlatform: false })).toBe(false)
|
|
})
|
|
|
|
test('returns false for high-availability projects even with a direct connection and no addon', () => {
|
|
expect(shouldShowIpv4AddonNotice({ ...BASE, isHighAvailability: true })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('shouldShowSessionPoolerNotice', () => {
|
|
test('returns true for the session pooler on platform in direct mode', () => {
|
|
expect(
|
|
shouldShowSessionPoolerNotice({
|
|
isPlatform: true,
|
|
mode: 'direct',
|
|
connectionMethod: 'session',
|
|
})
|
|
).toBe(true)
|
|
})
|
|
|
|
test('returns false when self-hosted (not platform)', () => {
|
|
expect(
|
|
shouldShowSessionPoolerNotice({
|
|
isPlatform: false,
|
|
mode: 'direct',
|
|
connectionMethod: 'session',
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
test('returns false when not in direct mode', () => {
|
|
expect(
|
|
shouldShowSessionPoolerNotice({
|
|
isPlatform: true,
|
|
mode: 'framework',
|
|
connectionMethod: 'session',
|
|
})
|
|
).toBe(false)
|
|
})
|
|
|
|
test('returns false for a non-session connection method', () => {
|
|
expect(
|
|
shouldShowSessionPoolerNotice({
|
|
isPlatform: true,
|
|
mode: 'direct',
|
|
connectionMethod: 'transaction',
|
|
})
|
|
).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('shouldShowSelfHostedMcpNotice', () => {
|
|
test('returns true for mcp mode when self-hosted', () => {
|
|
expect(shouldShowSelfHostedMcpNotice({ isSelfHosted: true, mode: 'mcp' })).toBe(true)
|
|
})
|
|
|
|
test('returns false for mcp mode on platform', () => {
|
|
expect(shouldShowSelfHostedMcpNotice({ isSelfHosted: false, mode: 'mcp' })).toBe(false)
|
|
})
|
|
|
|
test('returns false when self-hosted but not in mcp mode', () => {
|
|
expect(shouldShowSelfHostedMcpNotice({ isSelfHosted: true, mode: 'direct' })).toBe(false)
|
|
})
|
|
})
|