Skip to content

Commit 4b7f75f

Browse files
committed
Merge branch 'fix/supabase-session-mode-port' into 'main'
fix(monitoring): use session-mode port for Supabase pooler Closes #342 See merge request postgres-ai/postgresai!403
2 parents eb5d247 + fe68999 commit 4b7f75f

5 files changed

Lines changed: 254 additions & 5 deletions

File tree

cli/bin/postgres-ai.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ import {
6464
buildClientConfig,
6565
sslOptionFromConnString,
6666
warnIfLaxSslmode,
67+
warnIfTransactionPoolerPort,
6768
} from "../lib/instances";
6869

6970
// Node.js version check - require Node 18+
@@ -3231,6 +3232,7 @@ mon
32313232
let testClient: InstanceType<typeof Client> | null = null;
32323233
try {
32333234
warnIfLaxSslmode(connStr);
3235+
warnIfTransactionPoolerPort(connStr);
32343236
testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
32353237
await testClient.connect();
32363238
const result = await testClient.query("select version();");
@@ -3279,6 +3281,7 @@ mon
32793281
let testClient: InstanceType<typeof Client> | null = null;
32803282
try {
32813283
warnIfLaxSslmode(connStr);
3284+
warnIfTransactionPoolerPort(connStr);
32823285
testClient = new Client(buildClientConfig(connStr, { connectionTimeoutMillis: 10000 }));
32833286
await testClient.connect();
32843287
const result = await testClient.query("select version();");
@@ -4227,6 +4230,7 @@ targets
42274230
console.log(`Testing connection to monitoring target '${name}'...`);
42284231

42294232
warnIfLaxSslmode(instance.conn_str);
4233+
warnIfTransactionPoolerPort(instance.conn_str);
42304234
const client = new Client(buildClientConfig(instance.conn_str, { connectionTimeoutMillis: 10000 }));
42314235

42324236
try {

cli/lib/instances.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,49 @@ export function warnIfLaxSslmode(connStr: string): void {
188188
);
189189
}
190190

191+
/**
192+
* Supabase's pooler (Supavisor) serves transaction mode on 6543 and session
193+
* mode on 5432. Under transaction mode, client sessions share a pool of server
194+
* backends, so pgx's cached server-side prepared statements collide with
195+
* `prepared statement "stmtcache_<hash>" already exists` (42P05) and metric
196+
* collection stops without surfacing a hard failure.
197+
*
198+
* Only a pooler HOST on that exact port qualifies. A direct
199+
* `db.<ref>.supabase.co` host runs no pooler, so 6543 there is merely an
200+
* unusual port — warning about it would be wrong.
201+
*/
202+
const SUPABASE_POOLER_HOST_SUFFIX = "pooler.supabase.com";
203+
const SUPABASE_POOLER_TRANSACTION_PORT = "6543";
204+
const SUPABASE_POOLER_SESSION_PORT = "5432";
205+
206+
export function isTransactionPoolerUrl(connStr: string): boolean {
207+
try {
208+
const u = new URL(connStr);
209+
return (
210+
u.hostname.toLowerCase().endsWith(SUPABASE_POOLER_HOST_SUFFIX) &&
211+
u.port === SUPABASE_POOLER_TRANSACTION_PORT
212+
);
213+
} catch {
214+
return false;
215+
}
216+
}
217+
218+
/**
219+
* Print a stderr warning when the connection string points at the pooler's
220+
* transaction-mode port. Sits alongside `warnIfLaxSslmode` at the same
221+
* Client-construction sites so hand-written connection strings get the same
222+
* treatment the Supabase provisioning path applies automatically.
223+
*/
224+
export function warnIfTransactionPoolerPort(connStr: string): void {
225+
if (!isTransactionPoolerUrl(connStr)) return;
226+
console.error(
227+
`⚠ port ${SUPABASE_POOLER_TRANSACTION_PORT} is the pooler's TRANSACTION mode: ` +
228+
`pooled backends share prepared statements, so collection fails with ` +
229+
`42P05 (prepared statement already exists) and metrics stop silently. ` +
230+
`Use port ${SUPABASE_POOLER_SESSION_PORT} on the same host for session mode.`,
231+
);
232+
}
233+
191234
/**
192235
* Build a `pg.Client` config from a connection string that ACTUALLY honors
193236
* libpq sslmode semantics.

cli/lib/supabase.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,44 @@
1010

1111
const SUPABASE_API_BASE = "https://api.supabase.com";
1212

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+
1351
export type SupabaseConfig = {
1452
/** Supabase project reference (e.g., "abc123xyz") */
1553
projectRef: string;
@@ -340,6 +378,10 @@ export class SupabaseClient {
340378
* Note: The username will be automatically suffixed with `.<projectRef>` if not
341379
* already present, as required by Supabase pooler connections.
342380
*
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+
*
343385
* @param config Supabase configuration with projectRef and accessToken
344386
* @param username Username to include in the URL (e.g., monitoring user).
345387
* Will be transformed to `<username>.<projectRef>` format.
@@ -384,14 +426,17 @@ export async function fetchPoolerDatabaseUrl(
384426
const pooler = data[0];
385427
// Build URL from components if available
386428
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}`;
388431
}
389432
// Fallback: try to extract from connection_string if present
390433
if (typeof pooler.connection_string === "string") {
391434
try {
392435
const connUrl = new URL(pooler.connection_string);
393436
// 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+
: "";
395440
return `postgresql://${encodedUsername}@${connUrl.hostname}${portPart}${connUrl.pathname}`;
396441
} catch {
397442
return null;

cli/test/monitoring.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import {
1212
buildClientConfig,
1313
sslOptionFromConnString,
1414
warnIfLaxSslmode,
15+
warnIfTransactionPoolerPort,
16+
isTransactionPoolerUrl,
1517
isLaxSslmode,
1618
extractSslmode,
1719
InstancesParseError,
@@ -706,6 +708,81 @@ describe("warnIfLaxSslmode — UX warning for lax sslmode", () => {
706708
});
707709
});
708710

711+
describe("isTransactionPoolerUrl — detects Supabase transaction-mode pooler", () => {
712+
test("true for a pooler host on the transaction port", () => {
713+
expect(
714+
isTransactionPoolerUrl(
715+
"postgresql://u.ref:p@aws-1-eu-west-1.pooler.supabase.com:6543/postgres",
716+
),
717+
).toBe(true);
718+
});
719+
720+
test("false for the same pooler host on the session port", () => {
721+
expect(
722+
isTransactionPoolerUrl(
723+
"postgresql://u.ref:p@aws-1-eu-west-1.pooler.supabase.com:5432/postgres",
724+
),
725+
).toBe(false);
726+
});
727+
728+
test("false for a direct host, even on 6543", () => {
729+
// A direct host runs no pooler; 6543 there is just an unusual port and
730+
// rewriting or warning about it would be wrong.
731+
expect(
732+
isTransactionPoolerUrl(
733+
"postgresql://postgres:p@db.abcdefghij.supabase.co:6543/postgres",
734+
),
735+
).toBe(false);
736+
});
737+
738+
test("false for an unrelated host on 6543", () => {
739+
expect(
740+
isTransactionPoolerUrl("postgresql://u:p@db.example.com:6543/postgres"),
741+
).toBe(false);
742+
});
743+
744+
test("false for an unparseable connection string", () => {
745+
expect(isTransactionPoolerUrl("not-a-url")).toBe(false);
746+
});
747+
});
748+
749+
describe("warnIfTransactionPoolerPort — UX warning for transaction pooling", () => {
750+
let stderrSpy: ReturnType<typeof spyOn>;
751+
752+
beforeEach(() => {
753+
stderrSpy = spyOn(console, "error").mockImplementation(() => {});
754+
});
755+
756+
afterEach(() => {
757+
stderrSpy.mockRestore();
758+
});
759+
760+
test("warns, naming both ports and the failure it causes", () => {
761+
warnIfTransactionPoolerPort(
762+
"postgresql://u.ref:p@aws-1-eu-west-1.pooler.supabase.com:6543/postgres",
763+
);
764+
expect(stderrSpy).toHaveBeenCalledTimes(1);
765+
const msg = String(stderrSpy.mock.calls[0][0]);
766+
expect(msg).toContain("6543");
767+
expect(msg).toContain("5432");
768+
expect(msg).toContain("42P05");
769+
});
770+
771+
test("does NOT warn on the session-mode port", () => {
772+
warnIfTransactionPoolerPort(
773+
"postgresql://u.ref:p@aws-1-eu-west-1.pooler.supabase.com:5432/postgres",
774+
);
775+
expect(stderrSpy).not.toHaveBeenCalled();
776+
});
777+
778+
test("does NOT warn on a direct host", () => {
779+
warnIfTransactionPoolerPort(
780+
"postgresql://postgres:p@db.abcdefghij.supabase.co:5432/postgres",
781+
);
782+
expect(stderrSpy).not.toHaveBeenCalled();
783+
});
784+
});
785+
709786
describe("buildClientConfig — silences pg-connection-string deprecation warning", () => {
710787
// pg-connection-string v2.x prints `process.emitWarning("SECURITY WARNING:
711788
// ... 'prefer'/'require'/'verify-ca' ...")` whenever a recognised lax

cli/test/supabase.test.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ describe("Supabase module", () => {
166166
"postgres_ai_mon"
167167
);
168168
expect(url).toBe(
169-
"postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:6543/postgres"
169+
"postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:5432/postgres"
170170
);
171171
});
172172

@@ -191,7 +191,7 @@ describe("Supabase module", () => {
191191
"postgres_ai_mon.xhaqmsvczjkkvkgdyast"
192192
);
193193
expect(url).toBe(
194-
"postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:6543/postgres"
194+
"postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:5432/postgres"
195195
);
196196
});
197197

@@ -216,7 +216,7 @@ describe("Supabase module", () => {
216216
"postgres_ai_mon"
217217
);
218218
expect(url).toBe(
219-
"postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:6543/postgres"
219+
"postgresql://postgres_ai_mon.xhaqmsvczjkkvkgdyast@aws-1-eu-west-1.pooler.supabase.com:5432/postgres"
220220
);
221221
});
222222

@@ -265,6 +265,86 @@ describe("Supabase module", () => {
265265
expect(url).toBeNull();
266266
});
267267

268+
test("rewrites the transaction-mode pooler port to session mode", async () => {
269+
// The Supabase Management API reports the TRANSACTION-mode pooler port
270+
// (6543). pgwatch connects with pgx, which uses server-side prepared
271+
// statements; under transaction pooling those collide across the shared
272+
// backends with `prepared statement "stmtcache_<hash>" already exists`
273+
// (42P05) and metric collection silently stops.
274+
globalThis.fetch = mock(() =>
275+
Promise.resolve(
276+
new Response(
277+
JSON.stringify([
278+
{
279+
db_host: "aws-1-eu-west-1.pooler.supabase.com",
280+
db_port: 6543,
281+
db_name: "postgres",
282+
},
283+
]),
284+
{ status: 200 }
285+
)
286+
)
287+
) as unknown as typeof fetch;
288+
289+
const url = await fetchPoolerDatabaseUrl(
290+
{ projectRef: "xhaqmsvczjkkvkgdyast", accessToken: "token" },
291+
"postgres_ai_mon"
292+
);
293+
expect(new URL(url!).port).toBe("5432");
294+
// Same host — the pooler serves session mode on 5432. Switching to the
295+
// direct `db.<ref>.supabase.co` host would require IPv6.
296+
expect(new URL(url!).hostname).toBe("aws-1-eu-west-1.pooler.supabase.com");
297+
});
298+
299+
test("leaves a session-mode pooler port untouched", async () => {
300+
globalThis.fetch = mock(() =>
301+
Promise.resolve(
302+
new Response(
303+
JSON.stringify([
304+
{
305+
db_host: "aws-1-eu-west-1.pooler.supabase.com",
306+
db_port: 5432,
307+
db_name: "postgres",
308+
},
309+
]),
310+
{ status: 200 }
311+
)
312+
)
313+
) as unknown as typeof fetch;
314+
315+
const url = await fetchPoolerDatabaseUrl(
316+
{ projectRef: "xhaqmsvczjkkvkgdyast", accessToken: "token" },
317+
"postgres_ai_mon"
318+
);
319+
expect(new URL(url!).port).toBe("5432");
320+
});
321+
322+
test("leaves a direct (non-pooler) host untouched", async () => {
323+
// A direct host never runs a transaction pooler, so its port is
324+
// authoritative and must be passed through verbatim.
325+
globalThis.fetch = mock(() =>
326+
Promise.resolve(
327+
new Response(
328+
JSON.stringify([
329+
{
330+
db_host: "db.xhaqmsvczjkkvkgdyast.supabase.co",
331+
db_port: 6543,
332+
db_name: "postgres",
333+
},
334+
]),
335+
{ status: 200 }
336+
)
337+
)
338+
) as unknown as typeof fetch;
339+
340+
const url = await fetchPoolerDatabaseUrl(
341+
{ projectRef: "xhaqmsvczjkkvkgdyast", accessToken: "token" },
342+
"postgres_ai_mon"
343+
);
344+
expect(new URL(url!).hostname).toBe("db.xhaqmsvczjkkvkgdyast.supabase.co");
345+
expect(new URL(url!).port).toBe("6543");
346+
});
347+
268348
test("returns null when fetch throws network error", async () => {
269349
globalThis.fetch = mock(() =>
270350
Promise.reject(new Error("Network error"))

0 commit comments

Comments
 (0)