diff --git a/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx b/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx index 45b75de7498..66040bf3b64 100644 --- a/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/ConnectConfigSection.tsx @@ -44,8 +44,6 @@ export function ConnectConfigSection({ }: ConnectConfigSectionProps) { if (activeFields.length === 0) return null - const formLayoutClassName = 'md:[&>div:first-child]:!w-1/3 xl:[&>div:first-child]:!w-2/5' - return (
{activeFields.map((field) => { diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts index 57d7fdb0672..34f3ea26daa 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.test.ts @@ -471,7 +471,7 @@ describe('connect.resolver:getDefaultState', () => { steps: [], } - const state = getDefaultState(schema) + const state = getDefaultState({ schema }) expect(state.mode).toBe('framework') }) @@ -501,7 +501,7 @@ describe('connect.resolver:getDefaultState', () => { steps: [], } - const state = getDefaultState(schema) + const state = getDefaultState({ schema }) expect(state.framework).toBe('nextjs') expect(state.library).toBe('supabasejs') expect(state.mcpReadonly).toBe(false) @@ -514,7 +514,7 @@ describe('connect.resolver:getDefaultState', () => { steps: [], } - const state = getDefaultState(schema) + const state = getDefaultState({ schema }) expect(state.mode).toBe('direct') }) }) diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts index 2baeb7940c1..7874640a1f2 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.resolver.ts @@ -196,7 +196,7 @@ function resolveFieldOptions(field: { options?: unknown }, state: ConnectState): /** * Gets default state for the schema, using first mode and default field values. */ -export function getDefaultState(schema: ConnectSchema): ConnectState { +export function getDefaultState({ schema }: { schema: ConnectSchema }): ConnectState { const defaultMode = schema.modes[0]?.id ?? 'direct' const state: ConnectState = { mode: defaultMode } diff --git a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts index 32f0f6f2460..90d16a9e1ce 100644 --- a/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts +++ b/apps/studio/components/interfaces/ConnectSheet/connect.schema.ts @@ -180,7 +180,7 @@ export const connectSchema: ConnectSchema = { id: 'direct', label: 'Direct', description: 'Connection string', - fields: ['connectionMethod', 'useSharedPooler', 'connectionType'], + fields: ['connectionSource', 'connectionMethod', 'useSharedPooler', 'connectionType'], }, { id: 'orm', @@ -233,6 +233,13 @@ export const connectSchema: ConnectSchema = { }, // Direct connection fields + connectionSource: { + id: 'connectionSource', + type: 'select', + label: 'Source', + options: { source: 'connectionSources' }, + defaultValue: undefined, + }, connectionMethod: { id: 'connectionMethod', type: 'radio-list', diff --git a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx index 261fdb58997..698bcf37e94 100644 --- a/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx +++ b/apps/studio/components/interfaces/ConnectSheet/content/steps/direct-connection/content.tsx @@ -1,7 +1,10 @@ +import { useParams } from 'common' import { useMemo } from 'react' import { CodeBlock } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' +import { getConnectionStrings } from '../../../DatabaseSettings.utils' +import { getAddons } from '@/components/interfaces/Billing/Subscription/Subscription.utils' import { type ConnectionStringMethod, type DatabaseConnectionType, @@ -15,6 +18,12 @@ import { PASSWORD_PLACEHOLDER, resolveConnectionString, } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' +import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query' +import { useSupavisorConfigurationQuery } from '@/data/database/supavisor-configuration-query' +import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query' +import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query' +import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements' +import { pluckObjectFields } from '@/lib/helpers' const buildPsqlCommand = (params: { host: string; port: string; database: string; user: string }) => `psql -h ${params.host} -p ${params.port} -d ${params.database} -U ${params.user}` @@ -22,15 +31,93 @@ const buildPsqlCommand = (params: { host: string; port: string; database: string const buildJdbcString = (params: { host: string; port: string; database: string; user: string }) => `jdbc:postgresql://${params.host}:${params.port}/${params.database}?user=${params.user}&password=${PASSWORD_PLACEHOLDER}` +/** + * [Joshen] ConnectStepsSection does something similar but since only this page needs to consider connection strings + * from all databases (including read replicas), am opting to separate the logic for retrieving connection strings here + * + * We can however, consider to shift this logic into ConnectStepsSection, such that we can consider read replicas for + * the other tabs like "Framework" and "ORM" too. However, leaving them out for now and only updating "Direct" + */ +const useConnectionStringDatabases = () => { + const { ref: projectRef } = useParams() + const { hasAccess: allowPgBouncerSelection } = useCheckEntitlements('dedicated_pooler') + + const { data: databases = [] } = useReadReplicasQuery({ projectRef }) + const { data: pgbouncerConfig } = usePgbouncerConfigQuery({ projectRef }) + const { data: supavisorConfig } = useSupavisorConfigurationQuery({ projectRef }) + const { data: addons } = useProjectAddonsQuery({ projectRef }) + const { ipv4: ipv4Addon } = getAddons(addons?.selected_addons ?? []) + + const DB_FIELDS = ['db_host', 'db_name', 'db_port', 'db_user', 'inserted_at'] + const emptyState = { db_user: '', db_host: '', db_port: '', db_name: '' } + + return Object.fromEntries( + databases.map((db) => { + const connectionInfo = pluckObjectFields(db || emptyState, DB_FIELDS) + const poolingConfigurationShared = supavisorConfig?.find( + (x) => x.identifier === db.identifier + ) + const poolingConfigurationDedicated = allowPgBouncerSelection ? pgbouncerConfig : undefined + + const connectionStringsShared = getConnectionStrings({ + connectionInfo, + poolingInfo: { + connectionString: poolingConfigurationShared?.connection_string ?? '', + db_host: poolingConfigurationShared?.db_host ?? '', + db_name: poolingConfigurationShared?.db_name ?? '', + db_port: poolingConfigurationShared?.db_port ?? 0, + db_user: poolingConfigurationShared?.db_user ?? '', + }, + metadata: { projectRef: db.identifier }, + }) + + const connectionStringsDedicated = + poolingConfigurationDedicated !== undefined + ? getConnectionStrings({ + connectionInfo, + poolingInfo: { + connectionString: poolingConfigurationDedicated.connection_string.replace( + projectRef ?? '_', + db.identifier + ), + db_host: poolingConfigurationDedicated.db_host, + db_name: poolingConfigurationDedicated.db_name, + db_port: poolingConfigurationDedicated.db_port, + db_user: poolingConfigurationDedicated.db_user, + }, + metadata: { projectRef: db.identifier }, + }) + : undefined + + return [ + db.identifier, + { + 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, + }, + ] + }) + ) +} + /** * Step component for direct database connections. * Uses state to determine which connection string to show. */ -function DirectConnectionContent({ state, connectionStringPooler }: StepContentProps) { +function DirectConnectionContent({ state }: StepContentProps) { + const connectionSource = state.connectionSource const connectionType = (state.connectionType as DatabaseConnectionType) ?? 'uri' const connectionMethod = (state.connectionMethod as ConnectionStringMethod) ?? 'direct' const useSharedPooler = Boolean(state.useSharedPooler) + const connectionStrings = useConnectionStringDatabases() + const connectionStringPooler = + connectionStrings[connectionSource as keyof typeof connectionStrings] + // Determine which connection string to use const resolvedConnectionString = useMemo( () => diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts index a882d8c77de..79de26f040d 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.test.ts @@ -1,8 +1,16 @@ import { act, renderHook } from '@testing-library/react' -import { describe, expect, test } from 'vitest' +import { describe, expect, test, vi } from 'vitest' import { useConnectState } from './useConnectState' +vi.mock('common', () => ({ + useParams: () => ({ ref: 'test-ref' }), +})) + +vi.mock('@/data/read-replicas/replicas-query', () => ({ + useReadReplicasQuery: () => ({ data: [] }), +})) + describe('useConnectState', () => { // ============================================================================ // Initial State Tests diff --git a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts index b330928fede..5e55f1e1ad7 100644 --- a/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts +++ b/apps/studio/components/interfaces/ConnectSheet/useConnectState.ts @@ -1,3 +1,4 @@ +import { useParams } from 'common' import { useCallback, useMemo, useState } from 'react' import { FEATURE_GROUPS_PLATFORM, MCP_CLIENTS } from 'ui-patterns/McpUrlBuilder' @@ -24,6 +25,8 @@ import type { ResolvedStep, } from './Connect.types' import { resolveFrameworkLibraryKey } from './Connect.utils' +import { Database, useReadReplicasQuery } from '@/data/read-replicas/replicas-query' +import { formatDatabaseID, formatDatabaseRegion } from '@/data/read-replicas/replicas.utils' // ============================================================================ // Data Source Helpers @@ -33,7 +36,15 @@ import { resolveFrameworkLibraryKey } from './Connect.utils' * Get field options from a data source reference. * This maps source names to actual data. */ -function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOption[] { +function getFieldOptionsFromSource({ + source, + state, + databases, +}: { + source: string + state: ConnectState + databases: Database[] +}): FieldOption[] { switch (source) { case 'frameworks': return [...FRAMEWORKS, ...MOBILES].map((f) => ({ @@ -98,6 +109,16 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp description: m.description, })) + case 'connectionSources': + return databases.map((db) => { + const region = formatDatabaseRegion(db?.region ?? '') + const id = formatDatabaseID(db.identifier ?? '') + const label = db.identifier.includes('-rr-') + ? `Read Replica (${region} - ${id}}` + : 'Primary Database' + return { value: db.identifier, label } + }) + case 'connectionTypes': return DATABASE_CONNECTION_TYPES.map((t) => ({ value: t.id, @@ -133,7 +154,15 @@ function getFieldOptionsFromSource(source: string, state: ConnectState): FieldOp /** * Resolve field options, handling both static options and data source references. */ -function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState): FieldOption[] { +function resolveFieldOptionsWithSource({ + field, + state, + databases, +}: { + field: ResolvedField + state: ConnectState + databases: Database[] +}): FieldOption[] { // If already resolved (from conditional resolution) if (field.resolvedOptions.length > 0) { return field.resolvedOptions @@ -142,7 +171,7 @@ function resolveFieldOptionsWithSource(field: ResolvedField, state: ConnectState // Check if it's a source reference const options = connectSchema.fields[field.id]?.options if (options && typeof options === 'object' && 'source' in options) { - return getFieldOptionsFromSource(options.source as string, state) + return getFieldOptionsFromSource({ source: options.source as string, state, databases }) } return [] @@ -163,8 +192,11 @@ export interface UseConnectStateReturn { } export function useConnectState(initialState?: Partial): UseConnectStateReturn { + const { ref: projectRef } = useParams() + const { data: databases = [] } = useReadReplicasQuery({ projectRef }) + const [state, setState] = useState(() => { - const defaults = getDefaultState(connectSchema) + const defaults = getDefaultState({ schema: connectSchema }) // Set initial framework if mode is framework if (defaults.mode === 'framework' && !defaults.framework) { @@ -246,41 +278,45 @@ export function useConnectState(initialState?: Partial): UseConnec }) }, []) - const setMode = useCallback((mode: ConnectMode) => { - setState((prev) => { - const next: ConnectState = { ...prev, mode } + const setMode = useCallback( + (mode: ConnectMode) => { + setState((prev) => { + const next: ConnectState = { ...prev, mode } - // Initialize mode-specific defaults - if (mode === 'framework' && !next.framework) { - const firstFramework = FRAMEWORKS[0] - next.framework = firstFramework?.key ?? '' - if (firstFramework?.children?.length > 1) { - next.frameworkVariant = firstFramework.children[0]?.key ?? '' + // Initialize mode-specific defaults + if (mode === 'framework' && !next.framework) { + const firstFramework = FRAMEWORKS[0] + next.framework = firstFramework?.key ?? '' + if (firstFramework?.children?.length > 1) { + next.frameworkVariant = firstFramework.children[0]?.key ?? '' + } + const libraryKey = resolveFrameworkLibraryKey({ + framework: next.framework, + frameworkVariant: next.frameworkVariant, + library: next.library, + }) + if (libraryKey) next.library = libraryKey } - const libraryKey = resolveFrameworkLibraryKey({ - framework: next.framework, - frameworkVariant: next.frameworkVariant, - library: next.library, - }) - if (libraryKey) next.library = libraryKey - } - if (mode === 'direct') { - next.connectionMethod = next.connectionMethod ?? 'direct' - next.connectionType = next.connectionType ?? 'uri' - } + if (mode === 'direct') { + next.connectionMethod = next.connectionMethod ?? 'direct' + next.connectionType = next.connectionType ?? 'uri' + next.connectionSource = projectRef ?? '_' + } - if (mode === 'orm' && !next.orm) { - next.orm = ORMS[0]?.key ?? '' - } + if (mode === 'orm' && !next.orm) { + next.orm = ORMS[0]?.key ?? '' + } - if (mode === 'mcp' && !next.mcpClient) { - next.mcpClient = MCP_CLIENTS[0]?.key ?? '' - } + if (mode === 'mcp' && !next.mcpClient) { + next.mcpClient = MCP_CLIENTS[0]?.key ?? '' + } - return next - }) - }, []) + return next + }) + }, + [projectRef] + ) const activeFields = useMemo(() => getActiveFields(connectSchema, state), [state]) @@ -290,9 +326,9 @@ export function useConnectState(initialState?: Partial): UseConnec (fieldId: string): FieldOption[] => { const field = activeFields.find((f) => f.id === fieldId) if (!field) return [] - return resolveFieldOptionsWithSource(field, state) + return resolveFieldOptionsWithSource({ field, state, databases }) }, - [activeFields, state] + [activeFields, state, databases] ) return {