diff --git a/apps/studio/lib/warehouse.test.ts b/apps/studio/lib/warehouse.test.ts index 23ed77cf495..86e1e36d8ab 100644 --- a/apps/studio/lib/warehouse.test.ts +++ b/apps/studio/lib/warehouse.test.ts @@ -45,6 +45,51 @@ describe('parseWarehouseCatalogUrl', () => { }) }) + test('preserves the hostname and decodes the IPv6 hostaddr', () => { + const connection = parseWarehouseCatalogUrl( + 'postgres://postgres:pwd@db.example.supabase.co:5432/postgres?sslmode=require&hostaddr=2001%3Adb8%3A%3A1' + ) + + expect(connection).toMatchObject({ + host: 'db.example.supabase.co', + hostaddr: '2001:db8::1', + }) + expect(connection).not.toBeNull() + if (connection === null) return + + const script = getDuckLakeSetupScript({ credentials: CREDENTIALS, connection }) + expect(script).toContain("HOST 'db.example.supabase.co',") + expect(script).toContain("HOSTADDR '2001:db8::1',") + }) + + test('removes URI brackets from legacy IPv6 hosts', () => { + const connection = parseWarehouseCatalogUrl( + 'postgres://postgres:pwd@[2001:db8::1]:5432/postgres' + ) + + expect(connection?.host).toBe('2001:db8::1') + expect(connection).not.toBeNull() + if (connection === null) return + + const script = getDuckLakeSetupScript({ credentials: CREDENTIALS, connection }) + expect(script).toContain("HOST '2001:db8::1',") + expect(script).not.toContain('HOSTADDR') + expect(script).not.toContain('[2001:db8::1]') + }) + + test('preserves IPv4 catalog hosts', () => { + expect(parseWarehouseCatalogUrl('postgres://postgres:pwd@192.0.2.1:5432/postgres')?.host).toBe( + '192.0.2.1' + ) + }) + + test('ignores an empty hostaddr', () => { + const connection = parseWarehouseCatalogUrl( + 'postgres://postgres:pwd@db.example.supabase.co/postgres?hostaddr=' + ) + expect(connection?.hostaddr).toBeUndefined() + }) + test('decodes percent-encoded credentials', () => { const parsed = parseWarehouseCatalogUrl( 'postgres://user%40name:p%40ss%3Aword@db.example.supabase.co:5432/postgres' @@ -86,6 +131,7 @@ describe('getDuckLakeSetupScript', () => { expect(script).toContain(`REGION '${CREDENTIALS.s3_region}'`) expect(script).toContain(`ENDPOINT '${CREDENTIALS.s3_endpoint}'`) expect(script).toContain(`HOST '${CONNECTION.host}'`) + expect(script).not.toContain('HOSTADDR') expect(script).toContain(`PORT ${CONNECTION.port}`) expect(script).toContain(`DATABASE '${CONNECTION.database}'`) expect(script).toContain(`USER '${CONNECTION.user}'`) diff --git a/apps/studio/lib/warehouse.ts b/apps/studio/lib/warehouse.ts index 1e95b41fa01..b7e44358dd1 100644 --- a/apps/studio/lib/warehouse.ts +++ b/apps/studio/lib/warehouse.ts @@ -1,3 +1,5 @@ +import { literal } from '@supabase/pg-meta' + import { PASSWORD_PLACEHOLDER } from '@/components/interfaces/ConnectSheet/ConnectionString.utils' import { IS_STAGING_OR_LOCAL } from '@/lib/constants' @@ -40,6 +42,7 @@ export const DUCKLAKE_METADATA_PASSWORD_ENV_VAR = 'DUCKLAKE_METADATA_PASSWORD' export interface WarehouseCatalogConnection { host: string + hostaddr?: string port: string database: string user: string @@ -56,8 +59,11 @@ export function parseWarehouseCatalogUrl(catalogUrl: string): WarehouseCatalogCo const url = new URL(catalogUrl) if (!url.hostname) return null + const hostaddr = url.searchParams.get('hostaddr') + return { - host: url.hostname, + host: url.hostname.replace(/^\[|\]$/g, ''), + ...(hostaddr ? { hostaddr } : {}), port: url.port || '5432', database: url.pathname.replace(/^\//, '') || 'postgres', user: decodeURIComponent(url.username) || 'postgres', @@ -104,7 +110,7 @@ CREATE OR REPLACE SECRET ducklake_s3 ( -- Postgres credentials for the DuckLake metadata catalog CREATE OR REPLACE SECRET ducklake_metadata ( TYPE postgres, - HOST '${connection.host}', + HOST ${literal(connection.host)},${connection.hostaddr ? `\n HOSTADDR ${literal(connection.hostaddr)},` : ''} PORT ${connection.port}, DATABASE '${connection.database}', USER '${connection.user}',