Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion bin/gstack-gbrain-supabase-provision
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ cmd_pooler_url() {
# Prefer the singular Session Pooler config when Supabase returns an
# array (response shape can vary by project state). Fall back to the
# first PRIMARY entry if no "session" pool_mode is present.
local db_user db_host db_port db_name
local db_user db_host db_port db_name pool_mode
local first_or_session
if printf '%s' "$resp" | jq -e 'type == "array"' >/dev/null 2>&1; then
first_or_session=$(printf '%s' "$resp" | jq '[.[] | select(.pool_mode == "session")][0] // .[0]')
Expand All @@ -351,11 +351,27 @@ cmd_pooler_url() {
db_host=$(printf '%s' "$first_or_session" | jq -r '.db_host // empty')
db_port=$(printf '%s' "$first_or_session" | jq -r '.db_port // empty')
db_name=$(printf '%s' "$first_or_session" | jq -r '.db_name // empty')
pool_mode=$(printf '%s' "$first_or_session" | jq -r '.pool_mode // empty')

if [ -z "$db_user" ] || [ -z "$db_host" ] || [ -z "$db_port" ] || [ -z "$db_name" ]; then
die "pooler-url: missing pooler config fields (db_user/db_host/db_port/db_name); re-poll or check project state"
fi

# Issue #1301: New Supabase projects' Management API returns a single
# transaction-mode pooler at port 6543, but the shared pooler tenant
# for fresh projects only listens on the session port 5432. Trusting
# db_port verbatim makes `gbrain init` hang to TCP timeout (transaction
# port unreachable) before falling into "tenant not found"-style errors
# that look like auth bugs. Rewrite transaction/6543 -> session/5432.
# Override with GSTACK_SUPABASE_TRUST_API_PORT=1 if a future API version
# starts returning a working transaction port and this rewrite is wrong.
if [ "${GSTACK_SUPABASE_TRUST_API_PORT:-0}" != "1" ] \
&& [ "$pool_mode" = "transaction" ] && [ "$db_port" = "6543" ]; then
echo "pooler-url: API returned transaction pooler (port 6543); shared pooler for new projects listens on session port 5432 — rewriting (set GSTACK_SUPABASE_TRUST_API_PORT=1 to disable)" >&2
db_port=5432
pool_mode="session"
fi

local url="postgresql://${db_user}:${DB_PASS}@${db_host}:${db_port}/${db_name}"

if $json_mode; then
Expand Down
83 changes: 83 additions & 0 deletions test/gbrain-supabase-provision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,89 @@ describe('pooler-url', () => {
expect(r.status).toBe(2);
expect(r.stderr).toContain('DB_PASS env var is required');
});

// --- Issue #1301: New Supabase projects' API returns transaction/6543 but
// the shared pooler tenant only listens on session/5432. Rewrite that
// single combination, leave every other shape alone. ---

test('rewrites single transaction/6543 response to session/5432 (issue #1301)', async () => {
mock = startMock({
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }),
});
const r = await runBin(['pooler-url', REF, '--json'], {
SUPABASE_ACCESS_TOKEN: 'sbp_test',
DB_PASS: 'pw',
SUPABASE_API_BASE: mock.url,
});
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres');
expect(r.stderr).toContain('rewriting');
});

test('leaves session/6543 alone (some regions genuinely serve session on 6543)', async () => {
mock = startMock({
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
jsonResp({ ...POOLER_OK, pool_mode: 'session', db_port: 6543 }),
});
const r = await runBin(['pooler-url', REF, '--json'], {
SUPABASE_ACCESS_TOKEN: 'sbp_test',
DB_PASS: 'pw',
SUPABASE_API_BASE: mock.url,
});
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres');
expect(r.stderr).not.toContain('rewriting');
});

test('leaves transaction/5432 alone (only the 6543 case is the known footgun)', async () => {
mock = startMock({
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 5432 }),
});
const r = await runBin(['pooler-url', REF, '--json'], {
SUPABASE_ACCESS_TOKEN: 'sbp_test',
DB_PASS: 'pw',
SUPABASE_API_BASE: mock.url,
});
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres');
expect(r.stderr).not.toContain('rewriting');
});

test('GSTACK_SUPABASE_TRUST_API_PORT=1 disables the rewrite', async () => {
mock = startMock({
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
jsonResp({ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 }),
});
const r = await runBin(['pooler-url', REF, '--json'], {
SUPABASE_ACCESS_TOKEN: 'sbp_test',
DB_PASS: 'pw',
SUPABASE_API_BASE: mock.url,
GSTACK_SUPABASE_TRUST_API_PORT: '1',
});
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).pooler_url).toContain(':6543/postgres');
expect(r.stderr).not.toContain('rewriting');
});

test('array response with explicit session entry on 5432 is unaffected (existing behavior)', async () => {
mock = startMock({
[`GET /v1/projects/${REF}/config/database/pooler`]: () =>
jsonResp([
{ ...POOLER_OK, pool_mode: 'transaction', db_port: 6543 },
{ ...POOLER_OK, pool_mode: 'session', db_port: 5432 },
]),
});
const r = await runBin(['pooler-url', REF, '--json'], {
SUPABASE_ACCESS_TOKEN: 'sbp_test',
DB_PASS: 'pw',
SUPABASE_API_BASE: mock.url,
});
expect(r.status).toBe(0);
expect(JSON.parse(r.stdout).pooler_url).toContain(':5432/postgres');
expect(r.stderr).not.toContain('rewriting');
});
});

describe('list-orphans (D20)', () => {
Expand Down
Loading