|
10 | 10 |
|
11 | 11 | const SUPABASE_API_BASE = "https://api.supabase.com"; |
12 | 12 |
|
| 13 | +/** |
| 14 | + * Supabase's connection pooler (Supavisor) listens on two ports: |
| 15 | + * 6543 — transaction mode: client sessions are multiplexed onto a shared |
| 16 | + * pool of server backends, so anything with per-session server state |
| 17 | + * is unsafe. |
| 18 | + * 5432 — session mode: one client gets one backend for the life of the |
| 19 | + * connection, so server-side state behaves as on a direct connection. |
| 20 | + * |
| 21 | + * pgwatch connects with pgx, which caches server-side PREPARED STATEMENTS by |
| 22 | + * default. In transaction mode those names collide across the shared backends: |
| 23 | + * |
| 24 | + * ERROR: prepared statement "stmtcache_<hash>" already exists (SQLSTATE 42P05) |
| 25 | + * |
| 26 | + * Collection then fails and the series just stop, with no hard failure |
| 27 | + * anywhere the operator would look. Monitoring must therefore use session |
| 28 | + * mode. The pooler host is still the right target: the direct |
| 29 | + * `db.<ref>.supabase.co` host is IPv6-only, while the pooler is reachable over |
| 30 | + * IPv4 and serves session mode on 5432. |
| 31 | + */ |
| 32 | +const SUPABASE_POOLER_TRANSACTION_PORT = 6543; |
| 33 | +const SUPABASE_POOLER_SESSION_PORT = 5432; |
| 34 | +const SUPABASE_POOLER_HOST_SUFFIX = "pooler.supabase.com"; |
| 35 | + |
| 36 | +/** |
| 37 | + * Map a pooler endpoint onto its session-mode port. |
| 38 | + * |
| 39 | + * Only the exact transaction-mode port on a pooler host is rewritten. A direct |
| 40 | + * host runs no pooler, so its port is authoritative and passes through |
| 41 | + * verbatim; so does any other port, which we have no basis to second-guess. |
| 42 | + */ |
| 43 | +function sessionModePort(host: string, port: number | string): number | string { |
| 44 | + const isPoolerHost = host.toLowerCase().endsWith(SUPABASE_POOLER_HOST_SUFFIX); |
| 45 | + if (isPoolerHost && Number(port) === SUPABASE_POOLER_TRANSACTION_PORT) { |
| 46 | + return SUPABASE_POOLER_SESSION_PORT; |
| 47 | + } |
| 48 | + return port; |
| 49 | +} |
| 50 | + |
13 | 51 | export type SupabaseConfig = { |
14 | 52 | /** Supabase project reference (e.g., "abc123xyz") */ |
15 | 53 | projectRef: string; |
@@ -340,6 +378,10 @@ export class SupabaseClient { |
340 | 378 | * Note: The username will be automatically suffixed with `.<projectRef>` if not |
341 | 379 | * already present, as required by Supabase pooler connections. |
342 | 380 | * |
| 381 | + * The API reports the pooler's transaction-mode port; this returns the |
| 382 | + * session-mode port instead, because pgwatch's pgx prepared statements are |
| 383 | + * unsafe under transaction pooling. See `sessionModePort` above. |
| 384 | + * |
343 | 385 | * @param config Supabase configuration with projectRef and accessToken |
344 | 386 | * @param username Username to include in the URL (e.g., monitoring user). |
345 | 387 | * Will be transformed to `<username>.<projectRef>` format. |
@@ -384,14 +426,17 @@ export async function fetchPoolerDatabaseUrl( |
384 | 426 | const pooler = data[0]; |
385 | 427 | // Build URL from components if available |
386 | 428 | if (pooler.db_host && pooler.db_port && pooler.db_name) { |
387 | | - return `postgresql://${encodedUsername}@${pooler.db_host}:${pooler.db_port}/${pooler.db_name}`; |
| 429 | + const port = sessionModePort(pooler.db_host, pooler.db_port); |
| 430 | + return `postgresql://${encodedUsername}@${pooler.db_host}:${port}/${pooler.db_name}`; |
388 | 431 | } |
389 | 432 | // Fallback: try to extract from connection_string if present |
390 | 433 | if (typeof pooler.connection_string === "string") { |
391 | 434 | try { |
392 | 435 | const connUrl = new URL(pooler.connection_string); |
393 | 436 | // Use provided username; handle empty port for default ports (e.g., 5432) |
394 | | - const portPart = connUrl.port ? `:${connUrl.port}` : ""; |
| 437 | + const portPart = connUrl.port |
| 438 | + ? `:${sessionModePort(connUrl.hostname, connUrl.port)}` |
| 439 | + : ""; |
395 | 440 | return `postgresql://${encodedUsername}@${connUrl.hostname}${portPart}${connUrl.pathname}`; |
396 | 441 | } catch { |
397 | 442 | return null; |
|
0 commit comments