diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index 4ba33c1f79..a0e5c21509 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -252,7 +252,17 @@ describe("db push", () => { expect(result.stderr).toContain("connect"); }); - testParity(["db", "push", "--local"]); + // Known parity divergence: Go never emits the Docker-start hint for --local (CLI-1995). + testParity(["db", "push", "--local"], { + normalize: { + stderr: { + stripPatterns: [ + /\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g, // Expected from Go + /\nMake sure Docker is running, then run: supabase start/g, // Expected from TS + ], + }, + }, + }); }); describe("db push:linked", () => { diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index 45b02ef3bc..12069b0231 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -24,6 +24,8 @@ const CONNECT_REFUSED_STDERR_STRIP: readonly RegExp[] = [ /(?<=failed to connect to postgres:).*/g, // Go's SetConnectSuggestion: Network-Restrictions hint + dashboard URL line. /\nMake sure your local IP is allowed in Network Restrictions and Network Bans\.\n[^\n]*/g, + // TS's local-only Docker hint (CLI-1995); Go never emits this for --local. + /\nMake sure Docker is running, then run: supabase start/g, // TS's generic --debug suggestion. /\nTry rerunning the command with --debug to troubleshoot the error\./g, ]; diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.ts index 442c1bee73..d888f8993c 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.ts @@ -64,6 +64,26 @@ export function legacyIsIPv6ConnectivityError(message: string): boolean { export const LEGACY_SUGGEST_ENV_VAR = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD"; +/** + * TS-only addition — Go's `SetConnectSuggestion` has no local/remote distinction, + * so a refused `--local` connection (Docker/Postgres not running) got the same + * remote-only "Network Restrictions" dashboard hint as an actual network-restricted + * connection, which is a dead end locally. Shown instead of that hint when + * `ctx.isLocal` is true — see `legacyConnectSuggestion`. + */ +export const LEGACY_SUGGEST_LOCAL_STACK = "Make sure Docker is running, then run: supabase start"; + +/** + * Go's `SetConnectSuggestion` remote-only "Network Restrictions" hint + * (`internal/utils/connect.go:319-321`), shown for a connection refused/blocked + * by IP allow-listing. Shared by both the always-remote `Address not in tenant + * allow_list` branch and the non-local `ECONNREFUSED`/`connection refused` + * branch in `legacyConnectSuggestion`. + */ +function legacySuggestNetworkRestrictions(dashboardUrl: string): string { + return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${dashboardUrl}/project/_/database/settings`; +} + /** Context the connect-suggestion needs but cannot derive from the error alone. */ export interface LegacyConnectSuggestionContext { /** Active profile's dashboard URL (Go's `CurrentProfile.DashboardURL`). */ @@ -353,16 +373,20 @@ function legacyHasIPv6DialCause(error: unknown, depth = 0): boolean { */ export function legacyConnectSuggestion( error: unknown, - ctx: LegacyConnectSuggestionContext, + ctx: LegacyConnectSuggestionContext & { readonly isLocal: boolean }, ): string | undefined { const text = legacyCollectConnectErrorText(error); - // connect: connection refused / Address not in tenant allow_list → network restrictions. - if ( - text.includes("ECONNREFUSED") || - text.includes("connection refused") || - text.includes("Address not in tenant allow_list") - ) { - return `Make sure your local IP is allowed in Network Restrictions and Network Bans.\n${ctx.dashboardUrl}/project/_/database/settings`; + // connect: connection refused - "Address not in tenant allow_list" only ever comes from the remote pooler + // rejecting the caller's IP, so it always means network restrictions. + if (text.includes("Address not in tenant allow_list")) { + return legacySuggestNetworkRestrictions(ctx.dashboardUrl); + } + // connect: connection refused — don't send the user to the + // dashboard's Network Restrictions page for a --local connection. + if (text.includes("ECONNREFUSED") || text.includes("connection refused")) { + return ctx.isLocal + ? LEGACY_SUGGEST_LOCAL_STACK + : legacySuggestNetworkRestrictions(ctx.dashboardUrl); } // Wrong password (Go: "SCRAM exchange: Wrong password" / "failed SASL auth"; // node-postgres surfaces the server's `28P01` "password authentication failed"). diff --git a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts index dec18f832b..627c52575c 100644 --- a/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-connect-errors.unit.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { LEGACY_SUGGEST_ENV_VAR, + LEGACY_SUGGEST_LOCAL_STACK, legacyConnectFailureMessage, legacyConnectSuggestion, legacyIpv6Suggestion, @@ -276,6 +277,7 @@ describe("legacyConnectSuggestion", () => { const ctx = { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", + isLocal: false, } as const; // The @effect/sql SqlError wraps the node driver error on `.cause`; a multi-address @@ -292,6 +294,13 @@ describe("legacyConnectSuggestion", () => { ); }); + it("maps a refused local connection to the local stack hint", () => { + const err = sqlError(systemError("connect ECONNREFUSED 127.0.0.1:54322", "ECONNREFUSED")); + expect(legacyConnectSuggestion(err, { ...ctx, isLocal: true })).toBe( + LEGACY_SUGGEST_LOCAL_STACK, + ); + }); + it("maps an AggregateError of refused dials to the network-restrictions hint", () => { const err = sqlError( Object.assign(new AggregateError([], "all attempts failed"), { diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts index c6c29bbc96..0af60b46c9 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.integration.test.ts @@ -10,7 +10,7 @@ import * as net from "node:net"; import { describe, expect, it } from "@effect/vitest"; import { Effect } from "effect"; -import { LEGACY_SUGGEST_ENV_VAR } from "./legacy-connect-errors.ts"; +import { LEGACY_SUGGEST_ENV_VAR, LEGACY_SUGGEST_LOCAL_STACK } from "./legacy-connect-errors.ts"; import type { LegacyDbConnectError, LegacyDbExecError } from "./legacy-db-connection.errors.ts"; import { type LegacyPgConnInput, LegacyDbConnection } from "./legacy-db-connection.service.ts"; import { legacyDbConnectionSqlPgLayer } from "./legacy-db-connection.sql-pg.layer.ts"; @@ -26,9 +26,13 @@ const SUGGESTION_CONTEXT = { // host/user/database — never the password). const SENTINEL_PASSWORD = "s3cr3t-pw-do-not-leak"; -/** Connect through the real layer and flip the expected failure into the value. */ +/** + * Connect through the real layer and flip the expected failure into the value. + * `isLocal` defaults to `true`, pass `false` to drive the remote/`--linked` suggestion branch instead. + */ const connectFailure = ( cfg: Partial & { readonly port: number }, + isLocal = true, ): Effect.Effect => Effect.gen(function* () { const conn = yield* LegacyDbConnection; @@ -42,7 +46,7 @@ const connectFailure = ( suggestionContext: SUGGESTION_CONTEXT, ...cfg, }, - { isLocal: true, dnsResolver: "native" }, + { isLocal, dnsResolver: "native" }, ) .pipe( Effect.scoped, @@ -176,11 +180,11 @@ const fakeQueryServer = ( describe("legacyDbConnectionSqlPgLayer connect failures", () => { it.live( - "surfaces host, user, database, and the driver cause when the connection is refused", + "surfaces host, user, database, and the driver cause when a remote (--linked) connection is refused", () => Effect.gen(function* () { const port = yield* Effect.promise(acquireClosedPort); - const error = yield* connectFailure({ port }); + const error = yield* connectFailure({ port }, false); expect(error._tag).toBe("LegacyDbConnectError"); expect(error.message).toBe( "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + @@ -194,6 +198,14 @@ describe("legacyDbConnectionSqlPgLayer connect failures", () => { }), ); + it.live("surfaces the local-stack hint when a local connection is refused", () => + Effect.gen(function* () { + const port = yield* Effect.promise(acquireClosedPort); + const error = yield* connectFailure({ port }); + expect(error.suggestion).toBe(LEGACY_SUGGEST_LOCAL_STACK); + }), + ); + it.live( "reproduces pgconn's server-error rendering for an auth failure and suggests SUPABASE_DB_PASSWORD", () => diff --git a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts index e779973395..a05da9fd73 100644 --- a/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts +++ b/apps/cli/src/legacy/shared/legacy-db-connection.sql-pg.layer.ts @@ -661,7 +661,7 @@ const connect = ( const suggestion = cfg.suggestionContext === undefined ? undefined - : legacyConnectSuggestion(error, cfg.suggestionContext); + : legacyConnectSuggestion(error, { ...cfg.suggestionContext, isLocal }); return new LegacyDbConnectError({ message: `failed to connect to postgres: ${legacyConnectFailureMessage(cfg, error)}`, ...(suggestion === undefined ? {} : { suggestion }),