diff --git a/apps/cli-go/docs/supabase/db/pull.md b/apps/cli-go/docs/supabase/db/pull.md index ff35019715..fb0a521eeb 100644 --- a/apps/cli-go/docs/supabase/db/pull.md +++ b/apps/cli-go/docs/supabase/db/pull.md @@ -8,7 +8,7 @@ Requires your local project to be linked to a remote database by running `supaba Optionally, a new row can be inserted into the migration history table to reflect the current state of the remote database. -If no entries exist in the migration history table, the default diff engine uses `pg_dump` to capture all contents of the remote schemas you have created. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. +If no entries exist in the migration history table, the initial pull captures the remote schema. When pg-delta is the active diff engine (the default for projects created by a recent `supabase init`), pg-delta diffs against an empty shadow and produces the full migration without a pg_dump seed. When pg-delta is disabled (`[experimental.pgdelta] enabled = false`) or you pass `--diff-engine migra`, the initial pull uses `pg_dump` to capture remote schema contents before appending a migra diff. Otherwise, this command will only diff schema changes against the remote database, similar to running `db diff --linked`. Pass `--diff-engine pg-delta` to keep the migration-file `db pull` workflow while using pg-delta for the shadow diff step. On initial pull, pg-delta replaces `pg_dump` and produces the full migration from the shadow diff alone. Pass `--declarative` to switch to the declarative pg-delta export workflow instead. diff --git a/apps/cli-go/internal/db/pull/pgdelta_pull_debug.go b/apps/cli-go/internal/db/pull/pgdelta_pull_debug.go index 0cb35072a4..97ca9e49d7 100644 --- a/apps/cli-go/internal/db/pull/pgdelta_pull_debug.go +++ b/apps/cli-go/internal/db/pull/pgdelta_pull_debug.go @@ -111,3 +111,30 @@ func formatByteSize(size int) string { return fmt.Sprintf("%d B", size) } } + +func printInitialPgDeltaEmptyPullHint() { + fmt.Fprintln(os.Stderr, "Hint: initial pg-delta pulls introspect the remote catalog directly.") + fmt.Fprintln(os.Stderr, "Prefer a direct connection (db..supabase.co) over the pooler, or run with PGDELTA_DEBUG=1 for a debug bundle.") + fmt.Fprintln(os.Stderr, "You can also retry once with "+utils.Aqua("--diff-engine migra")+" to use pg_dump for the initial migration.") +} + +func handleInitialPgDeltaEmptyPull( + ctx context.Context, + config pgconn.Config, + options ...func(*pgx.ConnConfig), +) error { + // Always print the hint on empty initial pg-delta pull (when PGDELTA_DEBUG is off). + printInitialPgDeltaEmptyPullHint() + targetCatalog, err := exportTargetCatalog(ctx, utils.ToPostgresURL(config), "postgres", options...) + if err != nil { + // Catalog probe failed; keep the generic errInSync below. + return nil + } + summary := diff.SummarizeCatalogJSON(targetCatalog) + if summary.TotalObjects == 0 { + // Remote catalog is empty too → truly nothing to pull. + return nil + } + utils.CmdSuggestion = "pg-delta returned zero SQL statements even though the remote catalog contains objects. Prefer a direct database connection over the pooler, or run with PGDELTA_DEBUG=1." + return errors.New("No schema changes found: pg-delta could not introspect the remote schema") +} diff --git a/apps/cli-go/internal/db/pull/pull.go b/apps/cli-go/internal/db/pull/pull.go index 07503687a7..8d18edf77d 100644 --- a/apps/cli-go/internal/db/pull/pull.go +++ b/apps/cli-go/internal/db/pull/pull.go @@ -25,6 +25,7 @@ import ( "github.com/supabase/cli/internal/migration/new" "github.com/supabase/cli/internal/migration/repair" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/migration" ) @@ -158,6 +159,11 @@ func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fs } func diffRemoteSchema(ctx context.Context, schema []string, path string, config pgconn.Config, usePgDeltaDiff bool, differ diff.DiffFunc, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error { + initialPull := len(schema) == 0 + if usePgDeltaDiff && initialPull && len(flags.ProjectRef) > 0 { + // Pooler→direct rewrite for pg-delta TARGET only (see ResolveDirectDbConfigForPgDelta). + config = utils.ResolveDirectDbConfigForPgDelta(config, flags.ProjectRef, utils.CurrentProfile.ProjectHost) + } // Diff remote db (source) & shadow db (target) and write it as a new migration. result, err := diff.DiffDatabase(ctx, schema, config, os.Stderr, fsys, differ, usePgDeltaDiff, options...) if err != nil { @@ -181,6 +187,12 @@ func diffRemoteSchema(ctx context.Context, schema []string, path string, config return errors.Errorf("%w (debug bundle: %s)", errInSync, debugDir) } } + if usePgDeltaDiff && initialPull { + // Hint + optional introspection-specific error (see handleInitialPgDeltaEmptyPull). + if err := handleInitialPgDeltaEmptyPull(ctx, config, options...); err != nil { + return err + } + } return errors.New(errInSync) } if err := utils.MkdirIfNotExistFS(fsys, filepath.Dir(path)); err != nil { diff --git a/apps/cli-go/internal/utils/connect.go b/apps/cli-go/internal/utils/connect.go index e19aaaf523..3da4a65874 100644 --- a/apps/cli-go/internal/utils/connect.go +++ b/apps/cli-go/internal/utils/connect.go @@ -277,6 +277,40 @@ func ProjectRefFromDirectDbHost(host string) (string, bool) { return matches[2], true } +// IsPoolerDbHost reports whether host is a Supabase connection pooler endpoint. +func IsPoolerDbHost(host string) bool { + host = strings.ToLower(host) + return host == "pooler.supabase.com" || strings.HasSuffix(host, ".pooler.supabase.com") +} + +// ResolveDirectDbConfigForPgDelta rewrites a linked pooler connection to the direct +// Supabase database host so pg-delta can introspect the full remote catalog on an +// initial migration-style db pull. +// +// Inverse of PoolerFallbackConfig (container tried direct, retry pooler): the CLI may +// have picked pooler in NewDbConfigWithPassword when the host OS cannot dial direct, +// but pg-delta runs inside Docker and is given this config as its remote TARGET only. +func ResolveDirectDbConfigForPgDelta(config pgconn.Config, projectRef, projectHost string) pgconn.Config { + if _, ok := ProjectRefFromDirectDbHost(config.Host); ok { + return config + } + if !IsPoolerDbHost(config.Host) { + return config + } + direct := config + direct.Host = GetSupabaseDbHost(projectRef) + direct.Port = 5432 + direct.User = "postgres" + // Drop Supavisor tenant routing; not used on direct connections. + if len(direct.RuntimeParams) > 0 { + delete(direct.RuntimeParams, "options") + if len(direct.RuntimeParams) == 0 { + direct.RuntimeParams = nil + } + } + return direct +} + // WarnIPv6PoolerFallback prints a user-visible warning explaining that the direct // database connection could not be used because the current environment does not // support IPv6, and that the CLI is retrying through the IPv4 connection pooler. diff --git a/apps/cli-go/internal/utils/connect_test.go b/apps/cli-go/internal/utils/connect_test.go index a7b772d70f..31a15f74c3 100644 --- a/apps/cli-go/internal/utils/connect_test.go +++ b/apps/cli-go/internal/utils/connect_test.go @@ -365,6 +365,45 @@ func TestProjectRefFromDirectDbHost(t *testing.T) { }) } +func TestIsPoolerDbHost(t *testing.T) { + assert.True(t, IsPoolerDbHost("aws-0-us-east-1.pooler.supabase.com")) + assert.True(t, IsPoolerDbHost("pooler.supabase.com")) + assert.False(t, IsPoolerDbHost("db.test.supabase.co")) +} + +func TestResolveDirectDbConfigForPgDelta(t *testing.T) { + ref := apitest.RandomProjectRef() + t.Run("rewrites pooler config to direct host", func(t *testing.T) { + config := pgconn.Config{ + Host: "aws-0-us-east-1.pooler.supabase.com", + Port: 6543, + User: "postgres." + ref, + Password: "secret", + Database: "postgres", + RuntimeParams: map[string]string{ + "options": "reference=" + ref, + }, + } + got := ResolveDirectDbConfigForPgDelta(config, ref, "supabase.co") + assert.Equal(t, GetSupabaseDbHost(ref), got.Host) + assert.Equal(t, uint16(5432), got.Port) + assert.Equal(t, "postgres", got.User) + assert.Equal(t, "secret", got.Password) + assert.Nil(t, got.RuntimeParams) + }) + t.Run("leaves direct config unchanged", func(t *testing.T) { + config := pgconn.Config{ + Host: GetSupabaseDbHost(ref), + Port: 5432, + User: "postgres", + Password: "secret", + Database: "postgres", + } + got := ResolveDirectDbConfigForPgDelta(config, ref, "supabase.co") + assert.Equal(t, config, got) + }) +} + func TestWarnIPv6PoolerFallback(t *testing.T) { oldStderr := os.Stderr r, w, err := os.Pipe() diff --git a/apps/cli/src/legacy/commands/db/pull/pull.debug.ts b/apps/cli/src/legacy/commands/db/pull/pull.debug.ts index cf4d10ef3e..8de263a12a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.debug.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.debug.ts @@ -98,6 +98,22 @@ export function legacySummarizeCatalogJson(catalogJson: string): LegacyCatalogSu return { totalObjects: total, bySchema }; } +/** + * Stderr hint for empty initial pg-delta pulls. Port of Go's `printInitialPgDeltaEmptyPullHint`. + * Printed whenever pg-delta returns zero SQL on the first pull (unless PGDELTA_DEBUG handled it). + */ +export function legacyFormatInitialPgDeltaEmptyPullHint(): string { + return [ + "Hint: initial pg-delta pulls introspect the remote catalog directly.", + "Prefer a direct connection (db..supabase.co) over the pooler, or run with PGDELTA_DEBUG=1 for a debug bundle.", + `You can also retry once with ${legacyBold("--diff-engine migra")} to use pg_dump for the initial migration.`, + ].join("\n"); +} + +/** Message when pg-delta sees remote catalog objects but emits zero SQL. */ +export const LEGACY_PG_DELTA_INTROSPECTION_ERROR = + "No schema changes found: pg-delta could not introspect the remote schema"; + /** Port of Go's `formatCatalogSummary`. */ export function legacyFormatCatalogSummary(label: string, summary: LegacyCatalogSummary): string { if (summary.totalObjects === 0) return `${label} catalog: no objects detected`; diff --git a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts index e95fc934f2..a5fddd562a 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -29,7 +29,10 @@ import { } from "../../../shared/legacy-db-config.toml-read.ts"; import type { LegacyDbConnType } from "../../../shared/legacy-db-target-flags.ts"; import { legacyMakeDir } from "../../../shared/legacy-make-dir.ts"; -import { legacyToPostgresURL } from "../../../shared/legacy-postgres-url.ts"; +import { + legacyHostFromPostgresURL, + legacyToPostgresURL, +} from "../../../shared/legacy-postgres-url.ts"; import { legacySchemaToCsvField } from "../../../shared/legacy-schema-flags.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; @@ -49,6 +52,7 @@ import { legacyStreamPgDump } from "../shared/legacy-pg-dump.run.ts"; import { legacyEmitPoolerFallbackWarning, legacyIsDirectLinkedHost, + legacyResolveDirectDbConfigForPgDelta, legacyRunWithPoolerFallback, } from "../shared/legacy-pooler-fallback.ts"; import { legacyDumpSchemaScript } from "../shared/legacy-pg-dump.scripts.ts"; @@ -64,7 +68,12 @@ import { legacyExportCatalogPgDelta, legacyIsPgDeltaDebugEnabled, } from "../shared/legacy-pgdelta.ts"; -import { legacySaveEmptyPgDeltaPullDebug } from "./pull.debug.ts"; +import { + LEGACY_PG_DELTA_INTROSPECTION_ERROR, + legacyFormatInitialPgDeltaEmptyPullHint, + legacySaveEmptyPgDeltaPullDebug, + legacySummarizeCatalogJson, +} from "./pull.debug.ts"; import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { @@ -267,14 +276,16 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const withPoolerFallback = ( directTarget: string, attempt: (targetRef: string) => Effect.Effect, - ) => - attempt(directTarget).pipe( + ) => { + // Pooler fallback gates on the container TARGET host, not `resolved.conn.host`. + const targetHost = legacyHostFromPostgresURL(directTarget); + return attempt(directTarget).pipe( Effect.catch((error) => Effect.gen(function* () { if ( legacyIsDirectLinkedHost({ connType, - host: resolved.conn.host, + host: targetHost, isLocal: resolved.isLocal, projectHost: cliConfig.projectHost, }) && @@ -292,7 +303,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }) .pipe(Effect.orElseSucceed(() => Option.none())); if (Option.isSome(pooler)) { - yield* legacyEmitPoolerFallbackWarning(resolved.conn.host); + yield* legacyEmitPoolerFallbackWarning(targetHost); return yield* attempt(connToUrl(pooler.value)); } } @@ -300,6 +311,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }), ), ); + }; const usePgDeltaDiff = legacyResolvePullDiffEngine({ engineFlagChanged: Option.isSome(flags.diffEngine), @@ -447,6 +459,16 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy }), ); } + const initialPull = sync.kind === "missing"; + // Initial pg-delta pull only: if `--linked` resolved to a pooler host, rewrite + // the pg-delta TARGET to `db..` (see legacyResolveDirectDbConfigForPgDelta). + // The CLI postgres session (`resolved.conn`) is unchanged. + const pgDeltaTargetConn = + initialPull && usePgDeltaDiff && connType === "linked" && linkedRef !== undefined + ? legacyResolveDirectDbConfigForPgDelta(resolved.conn, linkedRef, cliConfig.projectHost) + : resolved.conn; + const pgDeltaTargetUrl = connToUrl(pgDeltaTargetConn); + const diffTargetUrl = initialPull && usePgDeltaDiff ? pgDeltaTargetUrl : targetUrl; // Initial pull, migra engine (Go's `run` → `assertRemoteInSync` returns // `errMissing`): seed the migration file with a pg_dump of the remote schema // (`dumpRemoteSchema`, `pull.go:144-158`), then run the migra diff below as a @@ -454,7 +476,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // which captures default privileges / managed schemas pg_dump can't emit. // pg-delta initial pulls skip the dump (`pull.go:126` `if !usePgDeltaDiff`): // they diff against an empty shadow, which already yields the full schema. - const seededFromDump = sync.kind === "missing" && !usePgDeltaDiff; + const seededFromDump = initialPull && !usePgDeltaDiff; // Tracks whether the pg_dump seed wrote any bytes, for Go's // `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff // is "in sync", a non-empty dump is a valid initial migration on its own. @@ -559,7 +581,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Native diff: shadow (baseline + local migrations) vs remote → migration SQL. // For the initial pull (no local migrations) the schema filter is ignored, // matching Go's `diffRemoteSchema(ctx, nil, …)`. - const diffSchema = sync.kind === "missing" ? [] : flags.schema; + const diffSchema = initialPull ? [] : flags.schema; // Go's `DiffDatabase` emits these to stderr before provisioning + diffing // (`internal/db/diff/diff.go:189,234-237`); the shadow seam doesn't, so the // pull handler emits them itself to match the migration-style `db pull` output. @@ -581,7 +603,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy // Use the declarative target override when present (Go substitutes it // for the diff target, `diff.go:196-197`); for remote pulls it's // undefined, so this is the direct target URL as before. - const target = shadow.targetUrlOverride ?? targetUrl; + const target = shadow.targetUrlOverride ?? diffTargetUrl; yield* output.raw( diffSchema.length > 0 ? `Diffing schemas: ${diffSchema.join(",")}\n` @@ -647,8 +669,8 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy if (diffOutcome.capture !== undefined) { const debugDir = yield* legacySaveEmptyPgDeltaPullDebug({ ctx, - conn: resolved.conn, - targetUrl, + conn: pgDeltaTargetConn, + targetUrl: pgDeltaTargetUrl, sourceCatalog: diffOutcome.capture.sourceCatalog, pgDeltaStderr: diffOutcome.capture.stderr, id: legacyFormatDebugId(yield* Clock.currentTimeMillis), @@ -673,6 +695,23 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy ); } } + if (initialPull && usePgDeltaDiff && !legacyIsPgDeltaDebugEnabled()) { + // Shown on every empty initial pg-delta pull (unless PGDELTA_DEBUG took the + // debug-bundle path above). Helps when pooler introspection silently fails. + yield* output.raw(`${legacyFormatInitialPgDeltaEmptyPullHint()}\n`, "stderr"); + // Probe the remote catalog: if objects exist but pg-delta returned no SQL, + // fail with a specific introspection error instead of generic "in sync". + const targetCatalog = yield* legacyExportCatalogPgDelta(ctx, { + targetRef: pgDeltaTargetUrl, + role: "postgres", + }).pipe(Effect.catch(() => Effect.succeed(""))); + if (legacySummarizeCatalogJson(targetCatalog).totalObjects > 0) { + return yield* Effect.fail( + new LegacyDbPullInSyncError({ message: LEGACY_PG_DELTA_INTROSPECTION_ERROR }), + ); + } + // Catalog also empty → truly nothing pullable; fall through to generic error. + } return yield* Effect.fail( new LegacyDbPullInSyncError({ message: "No schema changes found" }), ); diff --git a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts index 1e15768888..524a337271 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.integration.test.ts @@ -37,6 +37,7 @@ import { LegacyPgDeltaSslProbe } from "../../../shared/legacy-pgdelta-ssl-probe. import { LegacyDeclarativeSeam } from "../shared/legacy-pgdelta.seam.service.ts"; import type { LegacyDbPullFlags } from "./pull.command.ts"; import { legacyDbPull } from "./pull.handler.ts"; +import { LEGACY_PG_DELTA_INTROSPECTION_ERROR } from "./pull.debug.ts"; const EXPORT_JSON = JSON.stringify({ version: 1, @@ -74,6 +75,9 @@ interface SetupOpts { // `dumpFailFirstWith`, reproducing a direct attempt that emits preamble then // exits non-zero on an IPv6 drop. readonly dumpFailFirstPartialBytes?: string; + // Override the resolved linked/local connection host (defaults to direct db host). + readonly resolvedConnHost?: string; + readonly resolvedConnOptions?: string; // Raw argv seen by the handler (CliArgs). Only consulted when both // `--declarative` and `--use-pg-delta` are present, to replay pflag's // last-occurrence-wins ordering; defaults to empty. @@ -115,9 +119,12 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); let edgeRunCount = 0; + const edgeTargets: string[] = []; const edge = Layer.succeed(LegacyEdgeRuntimeScript, { run: (runOpts: LegacyEdgeRuntimeRunOpts) => { edgeRunCount += 1; + const target = runOpts.env["TARGET"]; + if (target !== undefined) edgeTargets.push(target); if (opts.edgeFailFirstWith !== undefined && edgeRunCount === 1) { return Effect.fail(new LegacyEdgeRuntimeScriptError({ message: opts.edgeFailFirstWith })); } @@ -182,14 +189,22 @@ function setup(workdir: string, opts: SetupOpts = {}) { conn: { // A direct `db..` host so the pooler-fallback gate // (Go's ProjectRefFromDirectDbHost) matches on the linked path. - host: connType === "local" ? "127.0.0.1" : "db.abcdefghijklmnopqrst.supabase.co", + host: + opts.resolvedConnHost ?? + (connType === "local" ? "127.0.0.1" : "db.abcdefghijklmnopqrst.supabase.co"), port: 5432, user: "postgres", password: "x", database: "postgres", + ...(opts.resolvedConnOptions !== undefined ? { options: opts.resolvedConnOptions } : {}), }, isLocal: connType === "local", - ref: opts.resolvedRef !== undefined ? Option.some(opts.resolvedRef) : Option.none(), + ref: + opts.resolvedRef !== undefined + ? Option.some(opts.resolvedRef) + : connType === "linked" + ? Option.some("abcdefghijklmnopqrst") + : Option.none(), }), resolvePoolerFallback: (resolveFlags) => { poolerFallbackCalls.push(resolveFlags); @@ -265,6 +280,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { get edgeRunCount() { return edgeRunCount; }, + edgeTargets, }; } @@ -552,6 +568,88 @@ describe("legacy db pull", () => { }).pipe(Effect.provide(s.layer)); }); + it.effect("an initial pull with pg-delta writes the diff as the migration", () => { + const s = setup(tmp.current, { + remoteVersions: [], + edgeStdout: "create table remote ();\n", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + expect(s.dumpCalls).toHaveLength(0); + expect(s.provisionCalls[0]?.usePgDelta).toBe(true); + const dir = join(tmp.current, "supabase", "migrations"); + const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); + expect(file).toBeDefined(); + expect(readFileSync(join(dir, file ?? ""), "utf8")).toContain("create table remote ();"); + expect(streamText(s.out, "stderr")).not.toContain("Dumping schema from remote database..."); + }).pipe(Effect.provide(s.layer)); + }); + + it.effect( + "an initial pg-delta pull rewrites a linked pooler host to the direct database host", + () => { + const s = setup(tmp.current, { + remoteVersions: [], + edgeStdout: "create table remote ();\n", + resolvedConnHost: "aws-0-us-east-1.pooler.supabase.com", + resolvedConnOptions: "reference=abcdefghijklmnopqrst", + yes: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + expect( + s.edgeTargets.some((target) => target.includes("db.abcdefghijklmnopqrst.supabase.co")), + ).toBe(true); + expect(s.edgeTargets.some((target) => target.includes("pooler"))).toBe(false); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "an initial pg-delta pull with a linked pooler session retries through IPv4 when direct introspection fails over IPv6", + () => { + const s = setup(tmp.current, { + remoteVersions: [], + resolvedConnHost: "aws-0-us-east-1.pooler.supabase.com", + resolvedConnOptions: "reference=abcdefghijklmnopqrst", + edgeFailFirstWith: "error diffing schema:\nfailed to connect: network is unreachable", + edgeStdout: "create table remote ();\n", + yes: true, + poolerAvailable: true, + }); + return Effect.gen(function* () { + yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })); + expect(streamText(s.out, "stderr")).toContain("does not support IPv6"); + expect(streamText(s.out, "stderr")).toContain("Retrying via the IPv4 connection pooler"); + expect(s.edgeRunCount).toBe(2); + expect(s.edgeTargets[0]).toContain("db.abcdefghijklmnopqrst.supabase.co"); + expect(s.edgeTargets[1]).toContain("pooler"); + expect(streamText(s.out, "stderr")).toContain("Schema written to"); + }).pipe(Effect.provide(s.layer)); + }, + ); + + it.effect( + "an initial pg-delta pull with remote catalog objects but empty diff reports introspection failure", + () => { + const catalog = JSON.stringify({ tables: [{ schema: "public", name: "t" }] }); + const s = setup(tmp.current, { + remoteVersions: [], + edgeStdout: "", + catalogStdout: catalog, + yes: true, + }); + return Effect.gen(function* () { + const error = yield* legacyDbPull(flags({ diffEngine: Option.some("pg-delta") })).pipe( + Effect.flip, + ); + expect(error.message).toBe(LEGACY_PG_DELTA_INTROSPECTION_ERROR); + expect(streamText(s.out, "stderr")).toContain("Hint: initial pg-delta pulls introspect"); + }).pipe(Effect.provide(s.layer)); + }, + ); + it.effect("an initial pull with an empty schema reports 'No schema changes found'", () => { // Go's `ensureMigrationWritten` (`pull.go:68,263-268`): an empty dump + empty diff // leaves the file empty → in sync. diff --git a/apps/cli/src/legacy/commands/db/shared/legacy-pooler-fallback.ts b/apps/cli/src/legacy/commands/db/shared/legacy-pooler-fallback.ts index 9278b05691..b27b0724aa 100644 --- a/apps/cli/src/legacy/commands/db/shared/legacy-pooler-fallback.ts +++ b/apps/cli/src/legacy/commands/db/shared/legacy-pooler-fallback.ts @@ -10,6 +10,10 @@ import { } from "../../../shared/legacy-pooler-fallback.ts"; export { legacyEmitPoolerFallbackWarning } from "../../../shared/legacy-pooler-fallback.ts"; +export { + legacyIsPoolerDbHost, + legacyResolveDirectDbConfigForPgDelta, +} from "../../../shared/legacy-pooler-fallback.ts"; /** The exit/stderr pair a dump attempt surfaces for pooler-fallback classification. */ interface LegacyPoolerFallbackResult { diff --git a/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts b/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts index 611d6630d5..86840a624e 100644 --- a/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts +++ b/apps/cli/src/legacy/shared/legacy-pooler-fallback.ts @@ -8,6 +8,45 @@ export function legacyIsDirectDbHost(host: string, projectHost: string): boolean return host.startsWith("db.") && host.endsWith(`.${projectHost}`); } +/** Mirrors Go's `IsPoolerDbHost` (`internal/utils/connect.go`). */ +export function legacyIsPoolerDbHost(host: string): boolean { + const normalized = host.toLowerCase(); + return normalized === "pooler.supabase.com" || normalized.endsWith(".pooler.supabase.com"); +} + +/** + * Rewrites a linked pooler connection to the direct Supabase database host for + * pg-delta catalog introspection on an initial migration-style pull. Mirrors Go's + * `ResolveDirectDbConfigForPgDelta`. + * + * This is the inverse of `RunWithPoolerFallback` (direct→pooler when the pg_dump + * container fails IPv6): here the CLI may have chosen pooler because the host OS + * cannot dial direct (`NewDbConfigWithPassword`), but pg-delta runs in Docker and + * often needs `db..` to read the full catalog. Only the pg-delta TARGET + * URL is rewritten; the CLI session keeps using whatever `resolve()` returned. + */ +export function legacyResolveDirectDbConfigForPgDelta( + conn: LegacyPgConnInput, + projectRef: string, + projectHost: string, +): LegacyPgConnInput { + if (legacyIsDirectDbHost(conn.host, projectHost)) { + return conn; + } + if (!legacyIsPoolerDbHost(conn.host)) { + // Custom `--db-url` or local targets: leave unchanged. + return conn; + } + // Drop pooler-only tenant routing (`options=reference=…`); direct uses `postgres`. + const { options: _options, ...rest } = conn; + return { + ...rest, + host: `db.${projectRef}.${projectHost}`, + port: 5432, + user: "postgres", + }; +} + export interface LegacyPoolerFallbackOptions { readonly run: Effect.Effect; readonly retry: (pooler: LegacyPgConnInput) => Effect.Effect; diff --git a/apps/cli/src/legacy/shared/legacy-pooler-fallback.unit.test.ts b/apps/cli/src/legacy/shared/legacy-pooler-fallback.unit.test.ts index 313b7db349..5e99a27f37 100644 --- a/apps/cli/src/legacy/shared/legacy-pooler-fallback.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-pooler-fallback.unit.test.ts @@ -3,7 +3,11 @@ import { Effect, Exit, Layer, Option } from "effect"; import { Output } from "../../shared/output/output.service.ts"; import type { LegacyPgConnInput } from "./legacy-db-connection.service.ts"; -import { legacyRunWithPoolerFallback } from "./legacy-pooler-fallback.ts"; +import { + legacyIsPoolerDbHost, + legacyResolveDirectDbConfigForPgDelta, + legacyRunWithPoolerFallback, +} from "./legacy-pooler-fallback.ts"; interface AttemptResult { readonly exitCode: number; @@ -71,6 +75,51 @@ function captureOutput() { }; } +describe("legacyIsPoolerDbHost", () => { + it("detects Supabase pooler hosts", () => { + expect(legacyIsPoolerDbHost("aws-0-us-east-1.pooler.supabase.com")).toBe(true); + expect(legacyIsPoolerDbHost("pooler.supabase.com")).toBe(true); + expect(legacyIsPoolerDbHost("db.abcdefghijklmnopqrst.supabase.co")).toBe(false); + }); +}); + +describe("legacyResolveDirectDbConfigForPgDelta", () => { + it("rewrites a linked pooler connection to the direct database host", () => { + const direct = legacyResolveDirectDbConfigForPgDelta( + { + host: "aws-0-us-east-1.pooler.supabase.com", + port: 6543, + user: "postgres.abcdefghijklmnopqrst", + password: "secret", + database: "postgres", + options: "reference=abcdefghijklmnopqrst", + }, + "abcdefghijklmnopqrst", + "supabase.co", + ); + expect(direct).toEqual({ + host: "db.abcdefghijklmnopqrst.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", + }); + }); + + it("leaves an already-direct connection unchanged", () => { + const conn = { + host: "db.abcdefghijklmnopqrst.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", + }; + expect(legacyResolveDirectDbConfigForPgDelta(conn, "abcdefghijklmnopqrst", "supabase.co")).toBe( + conn, + ); + }); +}); + describe("legacyRunWithPoolerFallback", () => { it.live("returns the retry outcome verbatim without re-classifying it", () => { const out = captureOutput(); diff --git a/apps/cli/src/legacy/shared/legacy-postgres-url.ts b/apps/cli/src/legacy/shared/legacy-postgres-url.ts index b8b6e58dd1..3290517573 100644 --- a/apps/cli/src/legacy/shared/legacy-postgres-url.ts +++ b/apps/cli/src/legacy/shared/legacy-postgres-url.ts @@ -88,3 +88,17 @@ export function legacyToPostgresURL(conn: LegacyPostgresUrlInput): string { conn.database, )}?connect_timeout=${timeout}${optionsParam}${extraParams}`; } + +/** Host from a `legacyToPostgresURL` string (Go keeps `pgconn.Config.Host` instead). */ +export function legacyHostFromPostgresURL(url: string): string { + const rest = url.replace(/^postgres(?:ql)?:\/\//u, ""); + const at = rest.lastIndexOf("@"); // last `@` — userinfo boundary (Go `net/url`) + const authority = at >= 0 ? rest.slice(at + 1) : rest; + const hostPort = authority.split("/")[0] ?? authority; + if (hostPort.startsWith("[")) { + const close = hostPort.indexOf("]"); + return close > 0 ? hostPort.slice(1, close) : hostPort; // `[ipv6]:port` + } + const colon = hostPort.lastIndexOf(":"); + return colon >= 0 ? hostPort.slice(0, colon) : hostPort; +} diff --git a/apps/cli/src/legacy/shared/legacy-postgres-url.unit.test.ts b/apps/cli/src/legacy/shared/legacy-postgres-url.unit.test.ts index ff89a4e7fa..6ad4ff28d2 100644 --- a/apps/cli/src/legacy/shared/legacy-postgres-url.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-postgres-url.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { legacyToPostgresURL } from "./legacy-postgres-url.ts"; +import { legacyHostFromPostgresURL, legacyToPostgresURL } from "./legacy-postgres-url.ts"; const base = { host: "127.0.0.1", @@ -89,3 +89,22 @@ describe("legacyToPostgresURL", () => { ); }); }); + +describe("legacyHostFromPostgresURL", () => { + it("extracts a hostname from a postgres URL", () => { + expect(legacyHostFromPostgresURL(legacyToPostgresURL(base))).toBe("127.0.0.1"); + expect( + legacyHostFromPostgresURL( + legacyToPostgresURL({ + ...base, + host: "db.abcdefghijklmnopqrst.supabase.co", + port: 5432, + }), + ), + ).toBe("db.abcdefghijklmnopqrst.supabase.co"); + }); + + it("extracts an IPv6 host from bracket notation", () => { + expect(legacyHostFromPostgresURL(legacyToPostgresURL({ ...base, host: "::1" }))).toBe("::1"); + }); +});