From 6243efe74f9efc02412b87b9f1e64a7720adea32 Mon Sep 17 00:00:00 2001 From: Parth Mittal Date: Thu, 9 Jul 2026 01:19:56 +0530 Subject: [PATCH] fix(cli): rewrite pooler to direct for initial pg-delta db pull Initial db pull with pg-delta skips pg_dump and introspects the remote catalog via the diff TARGET. When link resolved to the pooler, pg-delta often saw an empty catalog and reported "No schema changes found". Rewrite the pg-delta TARGET to db.. on initial linked pulls, probe the remote catalog when the diff is empty, and gate IPv6 pooler fallback on the container target host in the legacy handler. --- apps/cli-go/docs/supabase/db/pull.md | 2 +- .../internal/db/pull/pgdelta_pull_debug.go | 27 +++++ apps/cli-go/internal/db/pull/pull.go | 12 +++ apps/cli-go/internal/utils/connect.go | 34 ++++++ apps/cli-go/internal/utils/connect_test.go | 39 +++++++ .../src/legacy/commands/db/pull/pull.debug.ts | 16 +++ .../legacy/commands/db/pull/pull.handler.ts | 61 +++++++++-- .../commands/db/pull/pull.integration.test.ts | 102 +++++++++++++++++- .../db/shared/legacy-pooler-fallback.ts | 4 + .../legacy/shared/legacy-pooler-fallback.ts | 39 +++++++ .../legacy-pooler-fallback.unit.test.ts | 51 ++++++++- .../src/legacy/shared/legacy-postgres-url.ts | 14 +++ .../shared/legacy-postgres-url.unit.test.ts | 21 +++- 13 files changed, 406 insertions(+), 16 deletions(-) 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"); + }); +});