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 d6faf116cf..d6e589fba4 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -365,5 +365,19 @@ describe("test db", () => { expect(result.stderr).toContain("connect"); }); - testParity(["test", "db", "--local"]); + // No testParity for `test db --local`: with no local Postgres listening in the + // harness, the only reachable path is the connection-failure path, and its + // stderr diverges by driver in ways that aren't cosmetic and can't be + // normalized away. Both now emit Go's leading diagnostic to stderr: + // Connecting to local database... + // but the connect-error body and trailing hint still differ by driver. Go (pgx): + // failed to connect to postgres: failed to connect to `host=… user=… database=…`: dial error (dial tcp …: connect: connection refused) + // Make sure your local IP is allowed in Network Restrictions and Network Bans. + // http://…/project/_/database/settings + // The TS port (@effect/sql-pg) prints the effect SqlError and the --debug hint: + // failed to connect to postgres: effect/sql/SqlError: PgClient: Failed to connect + // Try rerunning the command with --debug to troubleshoot the error. + // The meaningful contract (non-zero exit + a connect error on stderr) is + // covered by the behaviour test above. A real connect-path parity test would + // need a live local database in the harness. }); diff --git a/apps/cli-e2e/src/tests/domains.e2e.test.ts b/apps/cli-e2e/src/tests/domains.e2e.test.ts index b3ba65739e..3f826d8577 100644 --- a/apps/cli-e2e/src/tests/domains.e2e.test.ts +++ b/apps/cli-e2e/src/tests/domains.e2e.test.ts @@ -3,6 +3,10 @@ import { testBehaviour, testParity } from "./test-context"; import { isRecording, PROJECT_REF } from "./env"; const CONFIGURED_CNAME = "www.urgsimurksi.xyz"; +const GO_CUSTOM_HOSTNAME_MACHINE_STATUS_PATTERNS = [ + /^Custom hostname setup completed\. Project is now accessible at [^\n]+\.\n?/gm, + /^Custom hostname configuration complete, and ready for activation\.\n\nPlease ensure that your custom domain is set up as a CNAME record to your Supabase subdomain:\n[^\n]+ CNAME -> [^\n]+\n?/gm, +]; describe("domains", () => { describe.todo("domains:create — requires mocking of 1.1.1.1 for DNS queries"); @@ -162,7 +166,9 @@ describe("domains", () => { expect(result.stderr).toContain("502"); }); - testParity(["domains", "get", "--project-ref", PROJECT_REF, "--output", "json"]); + testParity(["domains", "get", "--project-ref", PROJECT_REF, "--output", "json"], { + normalize: { stderr: { stripPatterns: GO_CUSTOM_HOSTNAME_MACHINE_STATUS_PATTERNS } }, + }); }); describe("domains:reverify", () => { diff --git a/apps/cli-e2e/src/tests/test-context.ts b/apps/cli-e2e/src/tests/test-context.ts index 1c0773fbe8..26a40cda7e 100644 --- a/apps/cli-e2e/src/tests/test-context.ts +++ b/apps/cli-e2e/src/tests/test-context.ts @@ -9,6 +9,31 @@ import { } from "@supabase/cli-test-helpers"; import { ACCESS_TOKEN, isRecording, TARGET } from "./env.ts"; +type ParityNormalizeOptions = NonNullable[0]["normalize"]>; +type ParityChannelNormalizeOptions = Extract< + ParityNormalizeOptions, + { stdout?: unknown; stderr?: unknown } +>; + +function isChannelNormalizeOptions( + options: ParityNormalizeOptions | undefined, +): options is ParityChannelNormalizeOptions { + return options !== undefined && ("stdout" in options || "stderr" in options); +} + +function withNormalizeVersions( + normalize: ParityNormalizeOptions | undefined, + versions: boolean | undefined, +): ParityNormalizeOptions | undefined { + if (versions === undefined) return normalize; + if (normalize === undefined) return { versions }; + if (!isChannelNormalizeOptions(normalize)) return { ...normalize, versions }; + return { + stdout: { ...normalize.stdout, versions }, + stderr: { ...normalize.stderr, versions }, + }; +} + function slugify(name: string): string { return name .toLowerCase() @@ -146,6 +171,7 @@ export function testParity( workspaceSetup?: (dir: string) => void; sortStdoutRows?: boolean; normalizeVersions?: boolean; + normalize?: ParityNormalizeOptions; }, ): void { const label = opts?.failureType @@ -164,13 +190,14 @@ export function testParity( } try { + const normalize = withNormalizeVersions(opts?.normalize, opts?.normalizeVersions); await runParity( { apiUrl: serverUrl, accessToken: ACCESS_TOKEN, workspaceSetup: opts?.workspaceSetup, sortStdoutRows: opts?.sortStdoutRows, - normalize: { versions: opts?.normalizeVersions }, + normalize, }, cmd, ); diff --git a/apps/cli-go/cmd/db.go b/apps/cli-go/cmd/db.go index adb526c4f3..da22dee063 100644 --- a/apps/cli-go/cmd/db.go +++ b/apps/cli-go/cmd/db.go @@ -84,6 +84,7 @@ var ( usePgAdmin bool usePgSchema bool usePgDelta bool + useDeclarative bool pullDiffEngine = utils.EnumFlag{ Allowed: []string{"migra", "pg-delta"}, Value: "migra", @@ -106,7 +107,7 @@ var ( return diff.RunExplicit(cmd.Context(), diffFrom, diffTo, schema, outputPath, afero.NewOsFs()) } } - useDelta := shouldUsePgDelta() + useDelta := resolveDiffEngine(cmd.Flags().Changed("use-migra"), usePgAdmin, usePgSchema, shouldUsePgDelta()) if usePgAdmin { return diff.RunPgAdmin(cmd.Context(), schema, file, flags.DbConfig, afero.NewOsFs()) } @@ -176,12 +177,19 @@ var ( if len(args) > 0 { name = args[0] } + // Declarative export is opt-in via --declarative. Enabling pg-delta in config + // does not switch db pull to declarative output; it keeps the migration-file + // workflow and only defaults the shadow diff engine below. + useDeclarativePgDelta := useDeclarative + usePgDeltaDiff := resolvePullDiffEngine( + cmd.Flags().Changed("diff-engine"), + pullDiffEngine.Value, + shouldUsePgDelta(), + ) pullDiffer := diff.DiffSchemaMigra - usePgDeltaDiff := pullDiffEngine.Value == "pg-delta" if usePgDeltaDiff { pullDiffer = diff.DiffPgDelta } - useDeclarativePgDelta := shouldUseDeclarativePgDeltaPull(usePgDeltaDiff) return pull.Run(cmd.Context(), schema, flags.DbConfig, name, useDeclarativePgDelta, usePgDeltaDiff, pullDiffer, afero.NewOsFs()) }, PostRun: func(cmd *cobra.Command, args []string) { @@ -210,8 +218,15 @@ var ( Use: "commit", Short: "Commit remote changes as a new migration", RunE: func(cmd *cobra.Command, args []string) error { - useDelta := shouldUsePgDelta() - return pull.Run(cmd.Context(), schema, flags.DbConfig, "remote_commit", useDelta, false, diff.DiffSchemaMigra, afero.NewOsFs()) + // remote commit always writes a timestamped migration file. When pg-delta is + // enabled it only swaps the shadow diff engine; it never switches to the + // declarative export path. + usePgDeltaDiff := shouldUsePgDelta() + pullDiffer := diff.DiffSchemaMigra + if usePgDeltaDiff { + pullDiffer = diff.DiffPgDelta + } + return pull.Run(cmd.Context(), schema, flags.DbConfig, "remote_commit", false, usePgDeltaDiff, pullDiffer, afero.NewOsFs()) }, } @@ -361,11 +376,28 @@ func shouldUsePgDelta() bool { return utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA") } -func shouldUseDeclarativePgDeltaPull(usePgDeltaDiff bool) bool { - if usePgDeltaDiff { +// resolveDiffEngine reports whether `db diff` should run in pg-delta mode. The config / +// env default (pgDeltaDefault) applies unless an explicit non-pg-delta engine is selected: +// --use-migra, --use-pgadmin, or --use-pg-schema is an authoritative rollback that clears +// pg-delta mode so diff.Run skips pg-delta-specific declarative shadow setup and the +// PGDELTA_DEBUG capture path. --use-migra defaults to true, so only an explicit pass +// (useMigraChanged) counts as opting out. +func resolveDiffEngine(useMigraChanged, usePgAdmin, usePgSchema, pgDeltaDefault bool) bool { + if useMigraChanged || usePgAdmin || usePgSchema { return false } - return shouldUsePgDelta() + return pgDeltaDefault +} + +// resolvePullDiffEngine selects whether migration-style db pull uses pg-delta for the +// shadow diff step. An explicit --diff-engine flag always wins, so --diff-engine migra is +// an authoritative rollback even when pg-delta is enabled in config; otherwise the default +// follows whether pg-delta is the active engine (config / env). +func resolvePullDiffEngine(engineFlagChanged bool, engine string, pgDeltaDefault bool) bool { + if engineFlagChanged { + return engine == "pg-delta" + } + return pgDeltaDefault } func init() { @@ -427,15 +459,18 @@ func init() { dbCmd.AddCommand(dbPushCmd) // Build pull command pullFlags := dbPullCmd.Flags() - // This flag activates declarative pull output through pg-delta instead of the - // legacy migration SQL pull path. - pullFlags.BoolVar(&usePgDelta, "use-pg-delta", false, "Use pg-delta to pull declarative schema.") + // --declarative switches pull output from a timestamped migration to declarative + // schema files exported through pg-delta. --use-pg-delta is the deprecated alias. + pullFlags.BoolVar(&useDeclarative, "declarative", false, "Pull schema as declarative files using pg-delta instead of creating a migration.") + pullFlags.BoolVar(&useDeclarative, "use-pg-delta", false, "Use pg-delta to pull declarative schema.") + cobra.CheckErr(pullFlags.MarkDeprecated("use-pg-delta", "use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.")) pullFlags.Var(&pullDiffEngine, "diff-engine", "Diff engine to use for migration-style db pull.") pullFlags.StringSliceVarP(&schema, "schema", "s", []string{}, "Comma separated list of schema to include.") pullFlags.String("db-url", "", "Pulls from the database specified by the connection string (must be percent-encoded).") pullFlags.Bool("linked", true, "Pulls from the linked project.") pullFlags.Bool("local", false, "Pulls from the local database.") dbPullCmd.MarkFlagsMutuallyExclusive("db-url", "linked", "local") + dbPullCmd.MarkFlagsMutuallyExclusive("declarative", "diff-engine") dbPullCmd.MarkFlagsMutuallyExclusive("use-pg-delta", "diff-engine") pullFlags.StringVarP(&dbPassword, "password", "p", "", "Password to your remote Postgres database.") cobra.CheckErr(viper.BindPFlag("DB_PASSWORD", pullFlags.Lookup("password"))) diff --git a/apps/cli-go/cmd/db_pull_routing_test.go b/apps/cli-go/cmd/db_pull_routing_test.go deleted file mode 100644 index c85c92200f..0000000000 --- a/apps/cli-go/cmd/db_pull_routing_test.go +++ /dev/null @@ -1,38 +0,0 @@ -package cmd - -import ( - "testing" - - "github.com/spf13/viper" - "github.com/stretchr/testify/assert" -) - -func TestShouldUseDeclarativePgDeltaPull(t *testing.T) { - t.Run("diff-engine pg-delta keeps the migration-file workflow", func(t *testing.T) { - usePgDelta = false - t.Cleanup(func() { usePgDelta = false }) - assert.False(t, shouldUseDeclarativePgDeltaPull(true)) - }) - - t.Run("no flag and no config means not declarative", func(t *testing.T) { - usePgDelta = false - t.Cleanup(func() { usePgDelta = false }) - assert.False(t, shouldUseDeclarativePgDeltaPull(false)) - }) - - t.Run("experimental config enables declarative", func(t *testing.T) { - usePgDelta = false - viper.Set("EXPERIMENTAL_PG_DELTA", true) - t.Cleanup(func() { - usePgDelta = false - viper.Set("EXPERIMENTAL_PG_DELTA", false) - }) - assert.True(t, shouldUseDeclarativePgDeltaPull(false)) - }) - - t.Run("use-pg-delta flag forces declarative", func(t *testing.T) { - usePgDelta = true - t.Cleanup(func() { usePgDelta = false }) - assert.True(t, shouldUseDeclarativePgDeltaPull(false)) - }) -} diff --git a/apps/cli-go/cmd/db_test.go b/apps/cli-go/cmd/db_test.go new file mode 100644 index 0000000000..654278d059 --- /dev/null +++ b/apps/cli-go/cmd/db_test.go @@ -0,0 +1,47 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolvePullDiffEngine(t *testing.T) { + t.Run("defaults to pg-delta when enabled in config", func(t *testing.T) { + assert.True(t, resolvePullDiffEngine(false, "migra", true)) + }) + + t.Run("defaults to migra when pg-delta is not active", func(t *testing.T) { + assert.False(t, resolvePullDiffEngine(false, "migra", false)) + }) + + t.Run("explicit --diff-engine migra overrides config default", func(t *testing.T) { + assert.False(t, resolvePullDiffEngine(true, "migra", true)) + }) + + t.Run("explicit --diff-engine pg-delta wins when config disabled", func(t *testing.T) { + assert.True(t, resolvePullDiffEngine(true, "pg-delta", false)) + }) +} + +func TestResolveDiffEngine(t *testing.T) { + t.Run("uses pg-delta when enabled in config and no engine flag set", func(t *testing.T) { + assert.True(t, resolveDiffEngine(false, false, false, true)) + }) + + t.Run("uses migra when pg-delta is not active", func(t *testing.T) { + assert.False(t, resolveDiffEngine(false, false, false, false)) + }) + + t.Run("explicit --use-migra clears config-driven pg-delta", func(t *testing.T) { + assert.False(t, resolveDiffEngine(true, false, false, true)) + }) + + t.Run("explicit --use-pg-schema clears config-driven pg-delta", func(t *testing.T) { + assert.False(t, resolveDiffEngine(false, false, true, true)) + }) + + t.Run("explicit --use-pgadmin clears config-driven pg-delta", func(t *testing.T) { + assert.False(t, resolveDiffEngine(false, true, false, true)) + }) +} diff --git a/apps/cli-go/cmd/init.go b/apps/cli-go/cmd/init.go index 170d4dc867..0708a3b846 100644 --- a/apps/cli-go/cmd/init.go +++ b/apps/cli-go/cmd/init.go @@ -16,7 +16,7 @@ var ( initInteractive bool createVscodeSettings bool createIntellijSettings bool - initParams = utils.InitParams{} + initParams = utils.InitParams{UsePgDelta: true} initCmd = &cobra.Command{ GroupID: groupLocalDev, diff --git a/apps/cli-go/docs/supabase/db/diff.md b/apps/cli-go/docs/supabase/db/diff.md index a046707ec2..822c752485 100644 --- a/apps/cli-go/docs/supabase/db/diff.md +++ b/apps/cli-go/docs/supabase/db/diff.md @@ -8,6 +8,8 @@ Runs [djrobstep/migra](https://github.com/djrobstep/migra) in a container to com By default, all schemas in the target database are diffed. Use the `--schema public,extensions` flag to restrict diffing to a subset of schemas. +Projects created by a recent `supabase init` default to the pg-delta diff engine (`[experimental.pgdelta] enabled = true` in `config.toml`). Existing projects are unaffected and keep using migra unless they opt in. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--use-migra` for a single run. + While the diff command is able to capture most schema changes, there are cases where it is known to fail. Currently, this could happen if you schema contains: - Changes to publication diff --git a/apps/cli-go/docs/supabase/db/pull.md b/apps/cli-go/docs/supabase/db/pull.md index 93f964b9a9..ff35019715 100644 --- a/apps/cli-go/docs/supabase/db/pull.md +++ b/apps/cli-go/docs/supabase/db/pull.md @@ -10,9 +10,9 @@ Optionally, a new row can be inserted into the migration history table to reflec 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`. -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 `--use-pg-delta` to switch to the declarative pg-delta export workflow instead. +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. -When `[experimental.pgdelta] enabled = true` is set in `config.toml`, `db pull` defaults to the declarative export path. Explicit `--diff-engine pg-delta` still selects the migration-file workflow. +When `[experimental.pgdelta] enabled = true` (the default for projects created by a recent `supabase init`), the migration-file `db pull` workflow uses pg-delta for the shadow diff step by default; it does not switch to declarative output. Existing projects without the section are unaffected and keep using migra. To fall back to the legacy migra engine, set `enabled = false` under `[experimental.pgdelta]`, or pass `--diff-engine migra` for a single run. When pulling from a remote database with `--db-url`, prefer a direct connection (`db..supabase.co:5432`) over the connection pooler so pg-delta can introspect the full catalog reliably. diff --git a/apps/cli-go/internal/bootstrap/bootstrap.go b/apps/cli-go/internal/bootstrap/bootstrap.go index 8a07e25301..81fe310869 100644 --- a/apps/cli-go/internal/bootstrap/bootstrap.go +++ b/apps/cli-go/internal/bootstrap/bootstrap.go @@ -60,7 +60,7 @@ func Run(ctx context.Context, starter StarterTemplate, fsys afero.Fs, options .. if err := downloadSample(ctx, client, starter.Url, fsys); err != nil { return err } - } else if err := initBlank.Run(ctx, fsys, false, utils.InitParams{Overwrite: true}); err != nil { + } else if err := initBlank.Run(ctx, fsys, false, utils.InitParams{Overwrite: true, UsePgDelta: true}); err != nil { return err } // 1. Login diff --git a/apps/cli-go/internal/db/dump/dump.go b/apps/cli-go/internal/db/dump/dump.go index 7b40ddccce..982b473f35 100644 --- a/apps/cli-go/internal/db/dump/dump.go +++ b/apps/cli-go/internal/db/dump/dump.go @@ -20,10 +20,8 @@ import ( func Run(ctx context.Context, path string, config pgconn.Config, dataOnly, roleOnly, dryRun bool, fsys afero.Fs, opts ...migration.DumpOptionFunc) error { // Initialize output stream outStream := (io.Writer)(os.Stdout) - exec := DockerExec if dryRun { fmt.Fprintln(os.Stderr, "DRY RUN: *only* printing the pg_dump script to console.") - exec = noExec } else if len(path) > 0 { f, err := fsys.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) if err != nil { @@ -36,15 +34,25 @@ func Run(ctx context.Context, path string, config pgconn.Config, dataOnly, roleO if utils.IsLocalDatabase(config) { db = "local" } - if dataOnly { - fmt.Fprintf(os.Stderr, "Dumping data from %s database...\n", db) - return migration.DumpData(ctx, config, outStream, exec, opts...) - } else if roleOnly { - fmt.Fprintf(os.Stderr, "Dumping roles from %s database...\n", db) - return migration.DumpRole(ctx, config, outStream, exec, opts...) + return RunWithPoolerFallback(ctx, config, outStream, dryRun, func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error { + if dataOnly { + fmt.Fprintf(os.Stderr, "Dumping data from %s database...\n", db) + return migration.DumpData(ctx, config, out, exec, opts...) + } else if roleOnly { + fmt.Fprintf(os.Stderr, "Dumping roles from %s database...\n", db) + return migration.DumpRole(ctx, config, out, exec, opts...) + } + fmt.Fprintf(os.Stderr, "Dumping schemas from %s database...\n", db) + return migration.DumpSchema(ctx, config, out, exec, opts...) + }) +} + +// captureExec wraps DockerExec so the container's stderr is teed into errBuf +// (in addition to the user's terminal) for post-failure classification. +func captureExec(errBuf *strings.Builder) migration.ExecFunc { + return func(ctx context.Context, script string, env []string, w io.Writer) error { + return dockerExec(ctx, script, env, w, io.MultiWriter(os.Stderr, errBuf)) } - fmt.Fprintf(os.Stderr, "Dumping schemas from %s database...\n", db) - return migration.DumpSchema(ctx, config, outStream, exec, opts...) } func noExec(ctx context.Context, script string, env []string, w io.Writer) error { @@ -69,6 +77,10 @@ func noExec(ctx context.Context, script string, env []string, w io.Writer) error } func DockerExec(ctx context.Context, script string, env []string, w io.Writer) error { + return dockerExec(ctx, script, env, w, os.Stderr) +} + +func dockerExec(ctx context.Context, script string, env []string, w, errW io.Writer) error { return utils.DockerRunOnceWithConfig( ctx, container.Config{ @@ -82,6 +94,6 @@ func DockerExec(ctx context.Context, script string, env []string, w io.Writer) e network.NetworkingConfig{}, "", w, - os.Stderr, + errW, ) } diff --git a/apps/cli-go/internal/db/dump/dump_test.go b/apps/cli-go/internal/db/dump/dump_test.go index bfdccb1289..05d7748ed7 100644 --- a/apps/cli-go/internal/db/dump/dump_test.go +++ b/apps/cli-go/internal/db/dump/dump_test.go @@ -1,8 +1,12 @@ package dump import ( + "bytes" "context" + "errors" + "io" "net/http" + "os" "testing" "github.com/h2non/gock" @@ -12,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" "github.com/supabase/cli/pkg/migration" ) @@ -61,6 +66,104 @@ func TestDumpCommand(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) + t.Run("suggests ipv4 pooler on ipv6 dump failure", func(t *testing.T) { + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + // Setup in-memory fs + fsys := afero.NewMemMapFs() + // Setup mock docker + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + apitest.MockDockerStart(utils.Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerErrorLogs(utils.Docker, containerId, 1, + `pg_dump: error: could not translate host name "db.test.supabase.co" to address: No address associated with hostname`)) + // Run test + err := Run(context.Background(), "", dbConfig, false, false, false, fsys) + // Check error + assert.ErrorContains(t, err, "error running container: exit 1") + assert.Contains(t, utils.CmdSuggestion, "Your network does not support IPv6") + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("suggests ipv4 pooler when pg_dump cannot assign ipv6 address", func(t *testing.T) { + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + fsys := afero.NewMemMapFs() + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + apitest.MockDockerStart(utils.Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerErrorLogs(utils.Docker, containerId, 1, + `pg_dump: error: connection to server at "db.test.supabase.co" (2600:1f1c:c19:4901:963f:d22e:683a:381c), port 5432 failed: Cannot assign requested address`)) + err := Run(context.Background(), "", dbConfig, false, false, false, fsys) + assert.ErrorContains(t, err, "error running container: exit 1") + assert.Contains(t, utils.CmdSuggestion, "Your network does not support IPv6") + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("retries via ipv4 pooler on ipv6 dump failure", func(t *testing.T) { + utils.CmdSuggestion = "" + t.Cleanup(func() { utils.CmdSuggestion = "" }) + // Auto-retry only applies to the linked path, not explicit --db-url. + flags.PoolerFallbackEligible = true + t.Cleanup(func() { flags.PoolerFallbackEligible = false }) + // Stub pooler resolution so the retry path does not touch the network. + orig := resolvePoolerFallback + resolvePoolerFallback = func(ctx context.Context, projectRef string) (pgconn.Config, error) { + return pgconn.Config{ + Host: "aws-0-us-east-1.pooler.supabase.com", + Port: 5432, + User: "postgres." + projectRef, + Password: "secret", + Database: "postgres", + }, nil + } + t.Cleanup(func() { resolvePoolerFallback = orig }) + // Capture stderr to assert the user-visible fallback warning. + oldStderr := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + stderr := make(chan string, 1) + go func() { + var buf bytes.Buffer + _, _ = io.Copy(&buf, r) + stderr <- buf.String() + }() + // Setup in-memory fs + fsys := afero.NewMemMapFs() + // Setup mock docker + require.NoError(t, apitest.MockDocker(utils.Docker)) + defer gock.OffAll() + // First container run fails because the direct host is unreachable over IPv6. + apitest.MockDockerStart(utils.Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerErrorLogs(utils.Docker, containerId, 1, + `pg_dump: error: could not translate host name "db.bvkmtbubamprwkclmslb.supabase.co" to address: No address associated with hostname`)) + // Retry through the pooler succeeds. + apitest.MockDockerStart(utils.Docker, imageUrl, containerId) + require.NoError(t, apitest.MockDockerLogs(utils.Docker, containerId, "hello world")) + // Run test + directConfig := pgconn.Config{ + Host: "db.bvkmtbubamprwkclmslb.supabase.co", + Port: 5432, + User: "postgres", + Password: "password", + Database: "postgres", + } + err = Run(context.Background(), "schema.sql", directConfig, false, false, false, fsys) + require.NoError(t, w.Close()) + os.Stderr = oldStderr + // Check error + require.NoError(t, err) + assert.Empty(t, utils.CmdSuggestion) + assert.Empty(t, apitest.ListUnmatchedRequests()) + // Validate the retry wrote the full dump after truncating the failed attempt. + contents, err := afero.ReadFile(fsys, "schema.sql") + require.NoError(t, err) + assert.Equal(t, []byte("hello world"), contents) + // Validate the user saw the fallback warning. + assert.Contains(t, <-stderr, "Retrying via the IPv4 connection pooler") + }) + t.Run("throws error on missing docker", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() @@ -92,3 +195,56 @@ func TestDumpCommand(t *testing.T) { assert.Empty(t, apitest.ListUnmatchedRequests()) }) } + +func TestPoolerFallbackConfig(t *testing.T) { + ipv6Err := errors.New(`could not translate host name "db.bvkmtbubamprwkclmslb.supabase.co" to address: No address associated with hostname`) + directConfig := pgconn.Config{Host: "db.bvkmtbubamprwkclmslb.supabase.co", Port: 5432} + + stubResolver := func(cfg pgconn.Config, err error) func() { + orig := resolvePoolerFallback + resolvePoolerFallback = func(context.Context, string) (pgconn.Config, error) { return cfg, err } + return func() { resolvePoolerFallback = orig } + } + withEligible := func(v bool) func() { + orig := flags.PoolerFallbackEligible + flags.PoolerFallbackEligible = v + return func() { flags.PoolerFallbackEligible = orig } + } + + t.Run("resolves pooler for eligible linked ipv6 failure", func(t *testing.T) { + t.Cleanup(withEligible(true)) + pooler := pgconn.Config{Host: "aws-0-us-east-1.pooler.supabase.com", Port: 5432} + t.Cleanup(stubResolver(pooler, nil)) + got, ok := PoolerFallbackConfig(context.Background(), directConfig, ipv6Err) + assert.True(t, ok) + assert.Equal(t, pooler.Host, got.Host) + }) + + t.Run("never reroutes explicit --db-url targets", func(t *testing.T) { + t.Cleanup(withEligible(false)) + t.Cleanup(stubResolver(pgconn.Config{}, errors.New("resolver must not be called"))) + _, ok := PoolerFallbackConfig(context.Background(), directConfig, ipv6Err) + assert.False(t, ok) + }) + + t.Run("ignores non-ipv6 failures", func(t *testing.T) { + t.Cleanup(withEligible(true)) + t.Cleanup(stubResolver(pgconn.Config{}, errors.New("resolver must not be called"))) + _, ok := PoolerFallbackConfig(context.Background(), directConfig, errors.New("permission denied for table")) + assert.False(t, ok) + }) + + t.Run("ignores non-direct hosts", func(t *testing.T) { + t.Cleanup(withEligible(true)) + t.Cleanup(stubResolver(pgconn.Config{}, errors.New("resolver must not be called"))) + _, ok := PoolerFallbackConfig(context.Background(), pgconn.Config{Host: "aws-0-us-east-1.pooler.supabase.com"}, ipv6Err) + assert.False(t, ok) + }) + + t.Run("returns false when pooler resolution fails", func(t *testing.T) { + t.Cleanup(withEligible(true)) + t.Cleanup(stubResolver(pgconn.Config{}, errors.New("no pooler"))) + _, ok := PoolerFallbackConfig(context.Background(), directConfig, ipv6Err) + assert.False(t, ok) + }) +} diff --git a/apps/cli-go/internal/db/dump/pooler_fallback.go b/apps/cli-go/internal/db/dump/pooler_fallback.go new file mode 100644 index 0000000000..15bd6f67a6 --- /dev/null +++ b/apps/cli-go/internal/db/dump/pooler_fallback.go @@ -0,0 +1,113 @@ +package dump + +import ( + "context" + "io" + "strings" + + "github.com/go-errors/errors" + "github.com/jackc/pgconn" + "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/internal/utils/flags" + "github.com/supabase/cli/pkg/migration" +) + +// resolvePoolerFallback resolves IPv4 transaction pooler credentials for a direct +// host that failed over IPv6. It is indirected through a variable so tests can +// stub the network call. +var resolvePoolerFallback = flags.ResolvePoolerConfigForFallback + +// RunWithPoolerFallback runs a Docker-backed pg_dump style operation and, when it +// fails because the Supabase direct database host is unreachable over IPv6, +// transparently retries once through the project's IPv4 transaction pooler. +// +// This is the common failure on Docker Desktop for macOS: the host can reach the +// IPv6-only direct database, but the pg_dump container cannot, so the operation +// fails even though direct connection config was selected. +// +// The run closure receives the connection config to use and an ExecFunc that tees +// the container's stderr for failure classification. out receives the dump output +// and is reset between attempts when it supports truncation. +func RunWithPoolerFallback( + ctx context.Context, + config pgconn.Config, + out io.Writer, + dryRun bool, + run func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error, +) error { + if dryRun { + return run(ctx, config, out, noExec) + } + var errBuf strings.Builder + err := run(ctx, config, out, captureExec(&errBuf)) + if err == nil { + return nil + } + // The container exit code hides why pg_dump failed; its stderr carries the + // connection detail, so classify that to decide whether to retry via pooler. + connErr := errors.New(errBuf.String()) + if poolerConfig, ok := PoolerFallbackConfig(ctx, config, connErr); ok { + resetOutput(out) + errBuf.Reset() + if retryErr := run(ctx, poolerConfig, out, captureExec(&errBuf)); retryErr != nil { + utils.SetConnectSuggestion(errors.New(errBuf.String())) + return retryErr + } + return nil + } + // Could not auto-recover: classify the failure into an actionable suggestion. + utils.SetConnectSuggestion(connErr) + if utils.IsIPv6ConnectivityError(connErr) { + // Enrich the hint with the project's actual transaction pooler URL so the + // user gets a copy-pasteable --db-url. + utils.SuggestIPv6Pooler(ctx, config.Host) + } + return err +} + +// PoolerFallbackConfig decides whether a failed remote container operation should +// be retried through the project's IPv4 transaction pooler, returning the pooler +// config to retry with. It returns ok=false unless every condition holds: +// - pooler fallback is eligible (the connection came from --linked, never an +// explicit --db-url/--local target), +// - the failure is an IPv6 connectivity error, +// - the host is a direct Supabase database host (db..supabase.co), and +// - the pooler config resolves. +// +// classifyErr must carry the underlying connection failure text — the teed +// container stderr for pg_dump, or the returned error for the diff/declarative +// paths, which already embed their container stderr. It emits the user-facing +// fallback warning when it returns ok, so callers can simply retry with the +// returned config. +func PoolerFallbackConfig(ctx context.Context, config pgconn.Config, classifyErr error) (pgconn.Config, bool) { + if !flags.PoolerFallbackEligible || !utils.IsIPv6ConnectivityError(classifyErr) { + return pgconn.Config{}, false + } + projectRef, ok := utils.ProjectRefFromDirectDbHost(config.Host) + if !ok { + return pgconn.Config{}, false + } + poolerConfig, err := resolvePoolerFallback(ctx, projectRef) + if err != nil { + return pgconn.Config{}, false + } + utils.WarnIPv6PoolerFallback(config.Host) + return poolerConfig, true +} + +// resetOutput rewinds the dump output between retry attempts so a failed first +// attempt does not leave partial content. It handles the in-memory buffer, +// on-disk file, and stdout cases; truncation errors (e.g. on stdout) are ignored. +func resetOutput(out io.Writer) { + switch w := out.(type) { + case interface{ Reset() }: + w.Reset() + case interface { + Truncate(int64) error + Seek(int64, int) (int64, error) + }: + if err := w.Truncate(0); err == nil { + _, _ = w.Seek(0, io.SeekStart) + } + } +} diff --git a/apps/cli-go/internal/db/pull/pull.go b/apps/cli-go/internal/db/pull/pull.go index e536c0e990..3905c7ff9c 100644 --- a/apps/cli-go/internal/db/pull/pull.go +++ b/apps/cli-go/internal/db/pull/pull.go @@ -5,6 +5,7 @@ import ( "context" _ "embed" "fmt" + "io" "math" "os" "path/filepath" @@ -48,10 +49,12 @@ func Run(ctx context.Context, schema []string, config pgconn.Config, name string } if viper.GetBool("EXPERIMENTAL") { var buf bytes.Buffer - if err := migration.DumpRole(ctx, config, &buf, dump.DockerExec); err != nil { - return err - } - if err := migration.DumpSchema(ctx, config, &buf, dump.DockerExec); err != nil { + if err := dump.RunWithPoolerFallback(ctx, config, &buf, false, func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error { + if err := migration.DumpRole(ctx, config, out, exec); err != nil { + return err + } + return migration.DumpSchema(ctx, config, out, exec) + }); err != nil { return err } // TODO: handle managed schemas @@ -104,7 +107,15 @@ func pullDeclarativePgDelta(ctx context.Context, schema []string, config pgconn. } exported, err := diff.DeclarativeExportPgDelta(ctx, shadowConfig, config, schema, formatOptions, options...) if err != nil { - return err + // The pg-delta container connects to the remote (target) host; if that + // fails over IPv6, retry through the IPv4 pooler like the dump path does. + poolerConfig, ok := dump.PoolerFallbackConfig(ctx, config, err) + if !ok { + return err + } + if exported, err = diff.DeclarativeExportPgDelta(ctx, shadowConfig, poolerConfig, schema, formatOptions, options...); err != nil { + return err + } } if err := declarative.WriteDeclarativeSchemas(exported, fsys); err != nil { return err @@ -151,14 +162,25 @@ func dumpRemoteSchema(ctx context.Context, path string, config pgconn.Config, fs return errors.Errorf("failed to open dump file: %w", err) } defer f.Close() - return migration.DumpSchema(ctx, config, f, dump.DockerExec) + return dump.RunWithPoolerFallback(ctx, config, f, false, func(ctx context.Context, config pgconn.Config, out io.Writer, exec migration.ExecFunc) error { + return migration.DumpSchema(ctx, config, out, exec) + }) } 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 { // 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 { - return err + // The diff runs the remote (source) host inside a container; if that + // fails over IPv6, retry through the IPv4 pooler like the dump path does + // so the whole db pull workflow is self-healing, not just the dump pass. + poolerConfig, ok := dump.PoolerFallbackConfig(ctx, config, err) + if !ok { + return err + } + if result, err = diff.DiffDatabase(ctx, schema, poolerConfig, os.Stderr, fsys, differ, usePgDeltaDiff, options...); err != nil { + return err + } } output := result.SQL if trimmed := strings.TrimSpace(output); len(trimmed) == 0 { diff --git a/apps/cli-go/internal/functions/serve/templates/main.ts b/apps/cli-go/internal/functions/serve/templates/main.ts index bac39b39eb..f319c45350 100644 --- a/apps/cli-go/internal/functions/serve/templates/main.ts +++ b/apps/cli-go/internal/functions/serve/templates/main.ts @@ -96,15 +96,40 @@ const functionsConfig: Record = (() => { } })(); +/* --- JWT verification --- */ +export function extractBearerToken(rawToken: string) { + const tokenParts = rawToken.split(' ') + const [bearer, token] = tokenParts + if (bearer !== 'Bearer' || tokenParts.length !== 2) { + return null + } + + return token +} + function getAuthToken(req: Request) { const authHeader = req.headers.get("authorization"); - if (!authHeader) { + const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key") + + // NOTE:(kallebysantos) Kong on legacy CLI stack pass it down as 'Bearer Token' format + const cleanSbApiKeyCompatibilityToken = sbApiKeyCompatibilityToken?.replace('Bearer', '')?.trim() + + if (!authHeader && !cleanSbApiKeyCompatibilityToken) { throw new Error("Missing authorization header"); } - const [bearer, token] = authHeader.split(" "); - if (bearer !== "Bearer") { + + // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: + // - API proxy mints a temp token + // - Original bearer is not present or is ApiKey + const bearerToken = extractBearerToken(authHeader ?? '') + const token = (!bearerToken || bearerToken.startsWith('sb_')) + ? cleanSbApiKeyCompatibilityToken + : bearerToken + + if (!token) { throw new Error(`Auth header is not 'Bearer {token}'`); } + return token; } @@ -180,6 +205,19 @@ async function shouldUsePackageJsonDiscovery({ entrypointPath, importMapPath }: return true } +export function prepareUserRequest(req: Request): Request { + const clonedURL = new URL(req.url) + const forwardedHost = req.headers.get('x-forwarded-host') + clonedURL.hostname = forwardedHost ?? clonedURL.hostname + const clonedReq = new Request(clonedURL, req.clone()) + + // remove custom api headers + clonedReq.headers.delete('sb-api-key') + EdgeRuntime.applySupabaseTag(req, clonedReq) + + return clonedReq +} + Deno.serve({ handler: async (req: Request) => { const url = new URL(req.url); @@ -279,7 +317,8 @@ Deno.serve({ staticPatterns, }); - return await worker.fetch(req); + const userReq = prepareUserRequest(req) + return await worker.fetch(userReq); } catch (e) { console.error(e); diff --git a/apps/cli-go/internal/start/templates/kong.yml b/apps/cli-go/internal/start/templates/kong.yml index 4cbfc3b1eb..4ff1b36043 100644 --- a/apps/cli-go/internal/start/templates/kong.yml +++ b/apps/cli-go/internal/start/templates/kong.yml @@ -182,10 +182,10 @@ services: config: add: headers: - - "Authorization: {{ .BearerToken }}" + - "sb-api-key: {{ .BearerToken }}" replace: headers: - - "Authorization: {{ .BearerToken }}" + - "sb-api-key: {{ .BearerToken }}" # Management API endpoints - name: well-known-oauth _comment: "GoTrue: /.well-known/oauth-authorization-server -> http://auth:9999/.well-known/oauth-authorization-server" diff --git a/apps/cli-go/internal/testing/apitest/docker.go b/apps/cli-go/internal/testing/apitest/docker.go index 9b9020c044..b3c80f4b40 100644 --- a/apps/cli-go/internal/testing/apitest/docker.go +++ b/apps/cli-go/internal/testing/apitest/docker.go @@ -120,6 +120,33 @@ func MockDockerLogsExitCode(docker *client.Client, containerID string, exitCode return setupDockerLogs(docker, containerID, "", exitCode) } +// MockDockerErrorLogs streams stderr output alongside a non-zero exit code, +// mirroring a container whose command failed (e.g. pg_dump unable to reach the +// database). +func MockDockerErrorLogs(docker *client.Client, containerID string, exitCode int, stderr string) error { + var body bytes.Buffer + writer := stdcopy.NewStdWriter(&body, stdcopy.Stderr) + if _, err := io.Copy(writer, strings.NewReader(stderr)); err != nil { + return err + } + gock.New(docker.DaemonHost()). + Get("/v"+docker.ClientVersion()+"/containers/"+containerID+"/logs"). + Reply(http.StatusOK). + SetHeader("Content-Type", "application/vnd.docker.raw-stream"). + Body(&body) + gock.New(docker.DaemonHost()). + Get("/v" + docker.ClientVersion() + "/containers/" + containerID + "/json"). + Reply(http.StatusOK). + JSON(container.InspectResponse{ContainerJSONBase: &container.ContainerJSONBase{ + State: &container.State{ + ExitCode: exitCode, + }}}) + gock.New(docker.DaemonHost()). + Delete("/v" + docker.ClientVersion() + "/containers/" + containerID). + Reply(http.StatusOK) + return nil +} + func ListUnmatchedRequests() []string { result := make([]string, len(gock.GetUnmatchedRequests())) for i, r := range gock.GetUnmatchedRequests() { diff --git a/apps/cli-go/internal/utils/config.go b/apps/cli-go/internal/utils/config.go index 108c3391a1..d171d06753 100644 --- a/apps/cli-go/internal/utils/config.go +++ b/apps/cli-go/internal/utils/config.go @@ -216,6 +216,7 @@ func ToRealtimeEnv(addr config.AddressFamily) string { type InitParams struct { ProjectId string UseOrioleDB bool + UsePgDelta bool Overwrite bool } @@ -225,6 +226,10 @@ func InitConfig(params InitParams, fsys afero.Fs) error { if params.UseOrioleDB { c.Experimental.OrioleDBVersion = "15.1.0.150" } + // The supabase init command opts new projects into pg-delta. Existing configs are + // unaffected because mergeDefaultValues ejects with this flag false (default stays + // migra), and other InitConfig callers leave it disabled. + c.Experimental.PgDeltaInitEnabled = params.UsePgDelta // Create config file if err := MkdirIfNotExistFS(fsys, SupabaseDirPath); err != nil { return err diff --git a/apps/cli-go/internal/utils/config_test.go b/apps/cli-go/internal/utils/config_test.go index 9ac835170f..6d829304f1 100644 --- a/apps/cli-go/internal/utils/config_test.go +++ b/apps/cli-go/internal/utils/config_test.go @@ -72,6 +72,35 @@ func TestInitConfig(t *testing.T) { assert.True(t, exists) }) + t.Run("generated config enables pgdelta when requested", func(t *testing.T) { + fsys := afero.NewMemMapFs() + params := InitParams{ + ProjectId: "test-project", + UsePgDelta: true, + } + + err := InitConfig(params, fsys) + + require.NoError(t, err) + content, err := afero.ReadFile(fsys, ConfigPath) + require.NoError(t, err) + assert.Contains(t, string(content), "[experimental.pgdelta]\nenabled = true") + }) + + t.Run("generated config leaves pgdelta disabled by default", func(t *testing.T) { + fsys := afero.NewMemMapFs() + params := InitParams{ + ProjectId: "test-project", + } + + err := InitConfig(params, fsys) + + require.NoError(t, err) + content, err := afero.ReadFile(fsys, ConfigPath) + require.NoError(t, err) + assert.Contains(t, string(content), "[experimental.pgdelta]\nenabled = false") + }) + t.Run("creates config with orioledb", func(t *testing.T) { fsys := afero.NewMemMapFs() params := InitParams{ diff --git a/apps/cli-go/internal/utils/connect.go b/apps/cli-go/internal/utils/connect.go index b9e2d39df9..0653672342 100644 --- a/apps/cli-go/internal/utils/connect.go +++ b/apps/cli-go/internal/utils/connect.go @@ -7,12 +7,14 @@ import ( "net" "net/url" "os" + "regexp" "strings" "time" "github.com/go-errors/errors" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" + "github.com/spf13/afero" "github.com/spf13/viper" "github.com/supabase/cli/internal/debug" "github.com/supabase/cli/pkg/api" @@ -173,6 +175,125 @@ func ConnectByUrl(ctx context.Context, url string, options ...func(*pgx.ConnConf const SuggestEnvVar = "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD" +// ipv6LiteralPattern matches IPv6 addresses in connection errors, e.g. Go dial +// "dial tcp [2406:da18:...]:5432" or libpq +// `connection to server at "host" (2406:da18:...), port 5432 failed`. +var ipv6LiteralPattern = regexp.MustCompile(`(?:\[[0-9a-fA-F:]+\]|\([0-9a-fA-F:]+\))`) + +// isIPv6ConnectivityError reports whether the connection failure stems from the +// host resolving to an IPv6 address that the current network cannot route to. +// Supabase direct database connections (db..supabase.co:5432) are +// IPv6-only unless the IPv4 add-on is enabled, so users on IPv4-only networks +// (or inside a Docker container without an IPv6 stack, e.g. `supabase db dump`) +// hit these failures. +func isIPv6ConnectivityError(msg string) bool { + lower := strings.ToLower(msg) + switch { + case strings.Contains(lower, "address family for hostname not supported"), + strings.Contains(lower, "no address associated with hostname"): + // getaddrinfo inside the pg_dump container when the host is IPv6-only and + // the container has no IPv6 stack, so AI_ADDRCONFIG filters out the AAAA + // record: "could not translate host name ... to address: Address family + // for hostname not supported" / "... No address associated with hostname". + return true + case strings.Contains(lower, "network is unreachable"): + return true + case strings.Contains(lower, "no route to host"), + strings.Contains(lower, "cannot assign requested address"): + // Require an IPv6 literal so genuine project-not-found errors (which the + // branch below maps) keep their existing suggestion. + return ipv6LiteralPattern.MatchString(msg) + } + return false +} + +// IsIPv6ConnectivityError reports whether err is a database connection failure +// caused by an IPv6 address the current network (or container) cannot reach. +func IsIPv6ConnectivityError(err error) bool { + if err == nil { + return false + } + return isIPv6ConnectivityError(err.Error()) +} + +// ipv6Suggestion is the generic, command-agnostic hint shown when a direct +// connection fails because the host is IPv6-only. It points users at the IPv4 +// transaction pooler via --db-url; SuggestIPv6Pooler upgrades it with the +// project's actual connection string when one can be fetched. +func ipv6Suggestion() string { + return fmt.Sprintf( + "Your network does not support IPv6, which is required for direct connections to the database.\n"+ + "Retry with your project's IPv4 transaction pooler connection string via %s.\n"+ + "You can copy it from the dashboard under Connect > Transaction pooler.", + Aqua("--db-url"), + ) +} + +// poolerURLPasswordPattern captures the userinfo password of a postgres +// connection string (the bytes between "user:" and the "@" host separator). +var poolerURLPasswordPattern = regexp.MustCompile(`^(postgres(?:ql)?://[^:@/]+:)[^@]*@`) + +// maskPoolerPassword replaces the password in a pooler connection string with +// the [YOUR-PASSWORD] placeholder. The Management API may return a real password +// in connection_string, and the suggestion is printed to the terminal, so the +// password must never be echoed. The placeholder keeps the hint copy-pasteable. +func maskPoolerPassword(connString string) string { + return poolerURLPasswordPattern.ReplaceAllString(connString, "${1}[YOUR-PASSWORD]@") +} + +// ipv6PoolerSuggestion is the IPv6 hint enriched with the project's transaction +// pooler connection string (password masked), ready to paste into --db-url. +func ipv6PoolerSuggestion(connString string) string { + return fmt.Sprintf( + "Your network does not support IPv6, which is required for direct connections to the database.\n"+ + "Retry through the IPv4 transaction pooler by passing it to %s", + Aqua(fmt.Sprintf(`--db-url "%s"`, maskPoolerPassword(connString))), + ) +} + +// ProjectRefFromDirectDbHost extracts the project ref from a Supabase direct +// database host (db..supabase.co|red). It returns false for any other host, +// including pooler hosts and local databases. +func ProjectRefFromDirectDbHost(host string) (string, bool) { + matches := ProjectHostPattern.FindStringSubmatch(host) + if len(matches) < 3 { + return "", false + } + return matches[2], true +} + +// 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. +func WarnIPv6PoolerFallback(directHost string) { + fmt.Fprintln(os.Stderr, Yellow(fmt.Sprintf( + "Warning: Direct connection to %s is unavailable because this environment does not support IPv6.\n"+ + "Retrying via the IPv4 connection pooler.", + directHost, + ))) +} + +// SuggestIPv6Pooler upgrades CmdSuggestion with the project's transaction pooler +// connection string when host is a Supabase direct database host and the pooler +// config can be fetched. Returns true when the suggestion was set. +func SuggestIPv6Pooler(ctx context.Context, host string) bool { + ref, ok := ProjectRefFromDirectDbHost(host) + if !ok { + return false + } + // GetSupabase() fatally exits when no access token is configured, so only + // reach for the API when a token is available (e.g. --db-url without login). + if _, err := LoadAccessTokenFS(afero.NewOsFs()); err != nil { + return false + } + primary, err := GetPoolerConfigPrimary(ctx, ref) + if err != nil || len(primary.ConnectionString) == 0 { + return false + } + CmdSuggestion = ipv6PoolerSuggestion(primary.ConnectionString) + return true +} + // Sets CmdSuggestion to an actionable hint based on the given pg connection error. func SetConnectSuggestion(err error) { if err == nil { @@ -190,6 +311,8 @@ func SetConnectSuggestion(err error) { } else if strings.Contains(msg, "SCRAM exchange: Wrong password") || strings.Contains(msg, "failed SASL auth") { // password authentication failed for user / invalid SCRAM server-final-message received from server CmdSuggestion = SuggestEnvVar + } else if isIPv6ConnectivityError(msg) { + CmdSuggestion = ipv6Suggestion() } else if strings.Contains(msg, "connect: no route to host") || strings.Contains(msg, "Tenant or user not found") { // Assumes IPv6 check has been performed before this CmdSuggestion = "Make sure your project exists on profile: " + CurrentProfile.Name diff --git a/apps/cli-go/internal/utils/connect_test.go b/apps/cli-go/internal/utils/connect_test.go index 55a81c0ca1..bd1eee66d3 100644 --- a/apps/cli-go/internal/utils/connect_test.go +++ b/apps/cli-go/internal/utils/connect_test.go @@ -2,8 +2,10 @@ package utils import ( "context" + "io" "net" "net/http" + "os" "testing" "github.com/go-errors/errors" @@ -14,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/utils/cloudflare" + "github.com/supabase/cli/pkg/api" "github.com/supabase/cli/pkg/pgtest" ) @@ -221,7 +224,43 @@ func TestSetConnectSuggestion(t *testing.T) { suggestion: "Connect to your database by setting the env var correctly: SUPABASE_DB_PASSWORD", }, { - name: "no route to host", + name: "ipv6 no route to host", + err: errors.New("dial tcp [2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432: connect: no route to host"), + suggestion: "Your network does not support IPv6", + }, + { + name: "ipv6 network is unreachable", + err: errors.New("dial tcp [2406:da18:4fd:9b0d:80ec:9812:3e65:450b]:5432: connect: network is unreachable"), + suggestion: "Your network does not support IPv6", + }, + { + name: "libpq unsupported address family", + err: errors.New(`pg_dump: error: connection to server failed: could not translate host name "db.test.supabase.co" to address: Address family for hostname not supported`), + suggestion: "Your network does not support IPv6", + }, + { + name: "libpq no address associated with hostname", + err: errors.New(`pg_dump: error: could not translate host name "db.ngpopfcjxrfmzmhmmpct.supabase.co" to address: No address associated with hostname`), + suggestion: "Your network does not support IPv6", + }, + { + name: "libpq network is unreachable without literal", + err: errors.New(`connection to server at "db.test.supabase.co", port 5432 failed: Network is unreachable`), + suggestion: "Your network does not support IPv6", + }, + { + name: "libpq cannot assign requested address", + err: errors.New(`pg_dump: error: connection to server at "db.test.supabase.co" (2600:1f1c:c19:4901:963f:d22e:683a:381c), port 5432 failed: Cannot assign requested address + Is the server running on that host and accepting TCP/IP connections?`), + suggestion: "Your network does not support IPv6", + }, + { + name: "cannot assign requested address without ipv6 literal", + err: errors.New("connect: cannot assign requested address"), + suggestion: "", + }, + { + name: "no route to host without ipv6 address", err: errors.New("connect: no route to host"), suggestion: "Make sure your project exists on profile: " + CurrentProfile.Name, }, @@ -245,6 +284,104 @@ func TestSetConnectSuggestion(t *testing.T) { } } +func TestSuggestIPv6Pooler(t *testing.T) { + ref := apitest.RandomProjectRef() + poolerURL := "postgres://postgres." + ref + ":[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres" + + t.Run("enriches suggestion with transaction pooler url", func(t *testing.T) { + CmdSuggestion = "" + t.Cleanup(func() { CmdSuggestion = "" }) + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref + "/config/database/pooler"). + Reply(http.StatusOK). + JSON([]api.SupavisorConfigResponse{{ + DatabaseType: api.SupavisorConfigResponseDatabaseTypePRIMARY, + ConnectionString: poolerURL, + }}) + ok := SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co") + assert.True(t, ok) + assert.Contains(t, CmdSuggestion, "--db-url") + assert.Contains(t, CmdSuggestion, poolerURL) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("masks a real password returned by the api", func(t *testing.T) { + CmdSuggestion = "" + t.Cleanup(func() { CmdSuggestion = "" }) + t.Cleanup(apitest.MockPlatformAPI(t)) + secretURL := "postgres://postgres." + ref + ":sup3r-s3cret@aws-0-us-east-1.pooler.supabase.com:6543/postgres" + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref + "/config/database/pooler"). + Reply(http.StatusOK). + JSON([]api.SupavisorConfigResponse{{ + DatabaseType: api.SupavisorConfigResponseDatabaseTypePRIMARY, + ConnectionString: secretURL, + }}) + ok := SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co") + assert.True(t, ok) + assert.NotContains(t, CmdSuggestion, "sup3r-s3cret") + assert.Contains(t, CmdSuggestion, "[YOUR-PASSWORD]") + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("skips non-supabase host without api call", func(t *testing.T) { + CmdSuggestion = "" + assert.False(t, SuggestIPv6Pooler(context.Background(), "localhost")) + assert.Empty(t, CmdSuggestion) + }) + + t.Run("returns false when pooler config is unavailable", func(t *testing.T) { + CmdSuggestion = "" + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(DefaultApiHost). + Get("/v1/projects/" + ref + "/config/database/pooler"). + Reply(http.StatusOK). + JSON([]api.SupavisorConfigResponse{}) + assert.False(t, SuggestIPv6Pooler(context.Background(), "db."+ref+".supabase.co")) + assert.Empty(t, CmdSuggestion) + }) +} + +func TestProjectRefFromDirectDbHost(t *testing.T) { + ref := apitest.RandomProjectRef() + + t.Run("extracts ref from direct host", func(t *testing.T) { + got, ok := ProjectRefFromDirectDbHost("db." + ref + ".supabase.co") + assert.True(t, ok) + assert.Equal(t, ref, got) + }) + + t.Run("rejects pooler and local hosts", func(t *testing.T) { + for _, host := range []string{ + "aws-0-us-east-1.pooler.supabase.com", + "localhost", + "127.0.0.1", + "db." + ref + ".supabase.net", + } { + _, ok := ProjectRefFromDirectDbHost(host) + assert.False(t, ok, host) + } + }) +} + +func TestWarnIPv6PoolerFallback(t *testing.T) { + oldStderr := os.Stderr + r, w, err := os.Pipe() + require.NoError(t, err) + os.Stderr = w + t.Cleanup(func() { os.Stderr = oldStderr }) + + WarnIPv6PoolerFallback("db.test.supabase.co") + require.NoError(t, w.Close()) + out, err := io.ReadAll(r) + require.NoError(t, err) + + assert.Contains(t, string(out), "db.test.supabase.co") + assert.Contains(t, string(out), "does not support IPv6") + assert.Contains(t, string(out), "connection pooler") +} + func TestPostgresURL(t *testing.T) { url := ToPostgresURL(pgconn.Config{ Host: "2406:da18:4fd:9b0d:80ec:9812:3e65:450b", diff --git a/apps/cli-go/internal/utils/flags/db_url.go b/apps/cli-go/internal/utils/flags/db_url.go index b3fda6a007..bbfda766d6 100644 --- a/apps/cli-go/internal/utils/flags/db_url.go +++ b/apps/cli-go/internal/utils/flags/db_url.go @@ -36,6 +36,13 @@ const ( var DbConfig pgconn.Config +// PoolerFallbackEligible reports whether DbConfig was resolved via the linked +// path, where transparently retrying a failed remote operation through the +// project's IPv4 transaction pooler is safe. Explicit --db-url and --local +// targets must never be silently rerouted, so the fallback stays disabled for +// them. +var PoolerFallbackEligible bool + func ParseDatabaseConfig(ctx context.Context, flagSet *pflag.FlagSet, fsys afero.Fs) error { // Changed flags take precedence over default values var connType connection @@ -102,6 +109,8 @@ func ParseDatabaseConfig(ctx context.Context, flagSet *pflag.FlagSet, fsys afero DbConfig.Password = token DbConfig.Database = ProjectRef } + // Only the linked path may be transparently rerouted to the IPv4 pooler. + PoolerFallbackEligible = connType == linked return nil } @@ -164,6 +173,33 @@ func NewDbConfigWithPassword(ctx context.Context, projectRef string) (pgconn.Con return config, nil } +// ResolvePoolerConfigForFallback returns an authenticated IPv4 transaction +// pooler connection config for the given linked project. It prefers the pooler +// URL persisted at link time and falls back to fetching it from the Management +// API. This is used to transparently retry remote pg operations that failed +// because the direct database host was unreachable over IPv6. +func ResolvePoolerConfigForFallback(ctx context.Context, projectRef string) (pgconn.Config, error) { + poolerConfig := utils.GetPoolerConfig(projectRef) + if poolerConfig == nil { + primary, err := utils.GetPoolerConfigPrimary(ctx, projectRef) + if err != nil { + return pgconn.Config{}, err + } + poolerConfig, err = utils.ParsePoolerURL(primary.ConnectionString) + if err != nil { + return pgconn.Config{}, err + } + // Supavisor transaction mode does not support prepared statements. + poolerConfig.Port = 5432 + } + if password := viper.GetString("DB_PASSWORD"); len(password) > 0 { + poolerConfig.Password = password + } else if err := initPoolerLogin(ctx, projectRef, poolerConfig); err != nil { + return *poolerConfig, err + } + return *poolerConfig, nil +} + func initLoginRole(ctx context.Context, projectRef string, config *pgconn.Config) error { fmt.Fprintln(os.Stderr, "Initialising login role...") body := api.CreateRoleBody{ReadOnly: false} diff --git a/apps/cli-go/internal/utils/flags/db_url_test.go b/apps/cli-go/internal/utils/flags/db_url_test.go index c79c7f9361..a1ddc2a507 100644 --- a/apps/cli-go/internal/utils/flags/db_url_test.go +++ b/apps/cli-go/internal/utils/flags/db_url_test.go @@ -3,10 +3,12 @@ package flags import ( "context" "fmt" + "net/http" "os" "strings" "testing" + "github.com/h2non/gock" "github.com/spf13/afero" "github.com/spf13/pflag" "github.com/spf13/viper" @@ -14,6 +16,7 @@ import ( "github.com/stretchr/testify/require" "github.com/supabase/cli/internal/testing/apitest" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/api" ) func TestParseDatabaseConfig(t *testing.T) { @@ -85,6 +88,64 @@ func TestParseDatabaseConfig(t *testing.T) { }) } +func TestResolvePoolerConfigForFallback(t *testing.T) { + ref := apitest.RandomProjectRef() + + t.Run("uses linked pooler url with db password", func(t *testing.T) { + utils.Config.Db.Pooler.ConnectionString = "postgres://postgres." + ref + ":[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.com:6543/postgres" + viper.Set("DB_PASSWORD", "secret") + t.Cleanup(func() { + utils.Config.Db.Pooler.ConnectionString = "" + viper.Set("DB_PASSWORD", "") + }) + + config, err := ResolvePoolerConfigForFallback(context.Background(), ref) + + require.NoError(t, err) + assert.Equal(t, "aws-0-us-east-1.pooler.supabase.com", config.Host) + assert.Equal(t, uint16(5432), config.Port) + assert.Equal(t, "postgres."+ref, config.User) + assert.Equal(t, "secret", config.Password) + }) + + t.Run("falls back to api pooler config", func(t *testing.T) { + poolerURL := "postgres://postgres." + ref + ":[YOUR-PASSWORD]@aws-0-eu-west-1.pooler.supabase.com:6543/postgres" + utils.Config.Db.Pooler.ConnectionString = "" + viper.Set("DB_PASSWORD", "secret") + t.Cleanup(func() { viper.Set("DB_PASSWORD", "") }) + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + ref + "/config/database/pooler"). + Reply(http.StatusOK). + JSON([]api.SupavisorConfigResponse{{ + DatabaseType: api.SupavisorConfigResponseDatabaseTypePRIMARY, + ConnectionString: poolerURL, + }}) + + config, err := ResolvePoolerConfigForFallback(context.Background(), ref) + + require.NoError(t, err) + assert.Equal(t, "aws-0-eu-west-1.pooler.supabase.com", config.Host) + assert.Equal(t, uint16(5432), config.Port) + assert.Equal(t, "secret", config.Password) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) + + t.Run("returns error when api pooler is unavailable", func(t *testing.T) { + utils.Config.Db.Pooler.ConnectionString = "" + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + ref + "/config/database/pooler"). + Reply(http.StatusOK). + JSON([]api.SupavisorConfigResponse{}) + + _, err := ResolvePoolerConfigForFallback(context.Background(), ref) + + assert.Error(t, err) + assert.Empty(t, apitest.ListUnmatchedRequests()) + }) +} + func TestPromptPassword(t *testing.T) { t.Run("returns user input when provided", func(t *testing.T) { r, w, err := os.Pipe() diff --git a/apps/cli-go/pkg/api/client.gen.go b/apps/cli-go/pkg/api/client.gen.go index 43da53e9ae..5a8ab60de6 100644 --- a/apps/cli-go/pkg/api/client.gen.go +++ b/apps/cli-go/pkg/api/client.gen.go @@ -629,6 +629,9 @@ type ClientInterface interface { // V1DisableReadonlyModeTemporarily request V1DisableReadonlyModeTemporarily(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1RestartAProject request + V1RestartAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) + // V1ListAvailableRestoreVersions request V1ListAvailableRestoreVersions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) @@ -3053,6 +3056,18 @@ func (c *Client) V1DisableReadonlyModeTemporarily(ctx context.Context, ref strin return c.Client.Do(req) } +func (c *Client) V1RestartAProject(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewV1RestartAProjectRequest(c.Server, ref) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + func (c *Client) V1ListAvailableRestoreVersions(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*http.Response, error) { req, err := NewV1ListAvailableRestoreVersionsRequest(c.Server, ref) if err != nil { @@ -10271,6 +10286,40 @@ func NewV1DisableReadonlyModeTemporarilyRequest(server string, ref string) (*htt return req, nil } +// NewV1RestartAProjectRequest generates requests for V1RestartAProject +func NewV1RestartAProjectRequest(server string, ref string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "ref", runtime.ParamLocationPath, ref) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/v1/projects/%s/restart", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + // NewV1ListAvailableRestoreVersionsRequest generates requests for V1ListAvailableRestoreVersions func NewV1ListAvailableRestoreVersionsRequest(server string, ref string) (*http.Request, error) { var err error @@ -11700,6 +11749,9 @@ type ClientWithResponsesInterface interface { // V1DisableReadonlyModeTemporarilyWithResponse request V1DisableReadonlyModeTemporarilyWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1DisableReadonlyModeTemporarilyResponse, error) + // V1RestartAProjectWithResponse request + V1RestartAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1RestartAProjectResponse, error) + // V1ListAvailableRestoreVersionsWithResponse request V1ListAvailableRestoreVersionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAvailableRestoreVersionsResponse, error) @@ -14927,6 +14979,27 @@ func (r V1DisableReadonlyModeTemporarilyResponse) StatusCode() int { return 0 } +type V1RestartAProjectResponse struct { + Body []byte + HTTPResponse *http.Response +} + +// Status returns HTTPResponse.Status +func (r V1RestartAProjectResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r V1RestartAProjectResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + type V1ListAvailableRestoreVersionsResponse struct { Body []byte HTTPResponse *http.Response @@ -17055,6 +17128,15 @@ func (c *ClientWithResponses) V1DisableReadonlyModeTemporarilyWithResponse(ctx c return ParseV1DisableReadonlyModeTemporarilyResponse(rsp) } +// V1RestartAProjectWithResponse request returning *V1RestartAProjectResponse +func (c *ClientWithResponses) V1RestartAProjectWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1RestartAProjectResponse, error) { + rsp, err := c.V1RestartAProject(ctx, ref, reqEditors...) + if err != nil { + return nil, err + } + return ParseV1RestartAProjectResponse(rsp) +} + // V1ListAvailableRestoreVersionsWithResponse request returning *V1ListAvailableRestoreVersionsResponse func (c *ClientWithResponses) V1ListAvailableRestoreVersionsWithResponse(ctx context.Context, ref string, reqEditors ...RequestEditorFn) (*V1ListAvailableRestoreVersionsResponse, error) { rsp, err := c.V1ListAvailableRestoreVersions(ctx, ref, reqEditors...) @@ -20714,6 +20796,22 @@ func ParseV1DisableReadonlyModeTemporarilyResponse(rsp *http.Response) (*V1Disab return response, nil } +// ParseV1RestartAProjectResponse parses an HTTP response from a V1RestartAProjectWithResponse call +func ParseV1RestartAProjectResponse(rsp *http.Response) (*V1RestartAProjectResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &V1RestartAProjectResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + return response, nil +} + // ParseV1ListAvailableRestoreVersionsResponse parses an HTTP response from a V1ListAvailableRestoreVersionsWithResponse call func ParseV1ListAvailableRestoreVersionsResponse(rsp *http.Response) (*V1ListAvailableRestoreVersionsResponse, error) { bodyBytes, err := io.ReadAll(rsp.Body) diff --git a/apps/cli-go/pkg/api/types.gen.go b/apps/cli-go/pkg/api/types.gen.go index f31594e3c3..144291d194 100644 --- a/apps/cli-go/pkg/api/types.gen.go +++ b/apps/cli-go/pkg/api/types.gen.go @@ -55,6 +55,7 @@ const ( ApplyProjectAddonBodyAddonTypeAuthMfaWebAuthn ApplyProjectAddonBodyAddonType = "auth_mfa_web_authn" ApplyProjectAddonBodyAddonTypeComputeInstance ApplyProjectAddonBodyAddonType = "compute_instance" ApplyProjectAddonBodyAddonTypeCustomDomain ApplyProjectAddonBodyAddonType = "custom_domain" + ApplyProjectAddonBodyAddonTypeEtlPipeline ApplyProjectAddonBodyAddonType = "etl_pipeline" ApplyProjectAddonBodyAddonTypeIpv4 ApplyProjectAddonBodyAddonType = "ipv4" ApplyProjectAddonBodyAddonTypeLogDrain ApplyProjectAddonBodyAddonType = "log_drain" ApplyProjectAddonBodyAddonTypePitr ApplyProjectAddonBodyAddonType = "pitr" @@ -543,6 +544,7 @@ const ( ListProjectAddonsResponseAvailableAddonsTypeAuthMfaWebAuthn ListProjectAddonsResponseAvailableAddonsType = "auth_mfa_web_authn" ListProjectAddonsResponseAvailableAddonsTypeComputeInstance ListProjectAddonsResponseAvailableAddonsType = "compute_instance" ListProjectAddonsResponseAvailableAddonsTypeCustomDomain ListProjectAddonsResponseAvailableAddonsType = "custom_domain" + ListProjectAddonsResponseAvailableAddonsTypeEtlPipeline ListProjectAddonsResponseAvailableAddonsType = "etl_pipeline" ListProjectAddonsResponseAvailableAddonsTypeIpv4 ListProjectAddonsResponseAvailableAddonsType = "ipv4" ListProjectAddonsResponseAvailableAddonsTypeLogDrain ListProjectAddonsResponseAvailableAddonsType = "log_drain" ListProjectAddonsResponseAvailableAddonsTypePitr ListProjectAddonsResponseAvailableAddonsType = "pitr" @@ -602,6 +604,11 @@ const ( ListProjectAddonsResponseAvailableAddonsVariantsId6LogDrainDefault ListProjectAddonsResponseAvailableAddonsVariantsId6 = "log_drain_default" ) +// Defines values for ListProjectAddonsResponseAvailableAddonsVariantsId7. +const ( + ListProjectAddonsResponseAvailableAddonsVariantsId7EtlPipelineDefault ListProjectAddonsResponseAvailableAddonsVariantsId7 = "etl_pipeline_default" +) + // Defines values for ListProjectAddonsResponseAvailableAddonsVariantsPriceInterval. const ( ListProjectAddonsResponseAvailableAddonsVariantsPriceIntervalHourly ListProjectAddonsResponseAvailableAddonsVariantsPriceInterval = "hourly" @@ -620,6 +627,7 @@ const ( ListProjectAddonsResponseSelectedAddonsTypeAuthMfaWebAuthn ListProjectAddonsResponseSelectedAddonsType = "auth_mfa_web_authn" ListProjectAddonsResponseSelectedAddonsTypeComputeInstance ListProjectAddonsResponseSelectedAddonsType = "compute_instance" ListProjectAddonsResponseSelectedAddonsTypeCustomDomain ListProjectAddonsResponseSelectedAddonsType = "custom_domain" + ListProjectAddonsResponseSelectedAddonsTypeEtlPipeline ListProjectAddonsResponseSelectedAddonsType = "etl_pipeline" ListProjectAddonsResponseSelectedAddonsTypeIpv4 ListProjectAddonsResponseSelectedAddonsType = "ipv4" ListProjectAddonsResponseSelectedAddonsTypeLogDrain ListProjectAddonsResponseSelectedAddonsType = "log_drain" ListProjectAddonsResponseSelectedAddonsTypePitr ListProjectAddonsResponseSelectedAddonsType = "pitr" @@ -679,6 +687,11 @@ const ( ListProjectAddonsResponseSelectedAddonsVariantId6LogDrainDefault ListProjectAddonsResponseSelectedAddonsVariantId6 = "log_drain_default" ) +// Defines values for ListProjectAddonsResponseSelectedAddonsVariantId7. +const ( + ListProjectAddonsResponseSelectedAddonsVariantId7EtlPipelineDefault ListProjectAddonsResponseSelectedAddonsVariantId7 = "etl_pipeline_default" +) + // Defines values for ListProjectAddonsResponseSelectedAddonsVariantPriceInterval. const ( ListProjectAddonsResponseSelectedAddonsVariantPriceIntervalHourly ListProjectAddonsResponseSelectedAddonsVariantPriceInterval = "hourly" @@ -3015,6 +3028,9 @@ type ListProjectAddonsResponseAvailableAddonsVariantsId5 string // ListProjectAddonsResponseAvailableAddonsVariantsId6 defines model for ListProjectAddonsResponse.AvailableAddons.Variants.Id.6. type ListProjectAddonsResponseAvailableAddonsVariantsId6 string +// ListProjectAddonsResponseAvailableAddonsVariantsId7 defines model for ListProjectAddonsResponse.AvailableAddons.Variants.Id.7. +type ListProjectAddonsResponseAvailableAddonsVariantsId7 string + // ListProjectAddonsResponse_AvailableAddons_Variants_Id defines model for ListProjectAddonsResponse.AvailableAddons.Variants.Id. type ListProjectAddonsResponse_AvailableAddons_Variants_Id struct { union json.RawMessage @@ -3050,6 +3066,9 @@ type ListProjectAddonsResponseSelectedAddonsVariantId5 string // ListProjectAddonsResponseSelectedAddonsVariantId6 defines model for ListProjectAddonsResponse.SelectedAddons.Variant.Id.6. type ListProjectAddonsResponseSelectedAddonsVariantId6 string +// ListProjectAddonsResponseSelectedAddonsVariantId7 defines model for ListProjectAddonsResponse.SelectedAddons.Variant.Id.7. +type ListProjectAddonsResponseSelectedAddonsVariantId7 string + // ListProjectAddonsResponse_SelectedAddons_Variant_Id defines model for ListProjectAddonsResponse.SelectedAddons.Variant.Id. type ListProjectAddonsResponse_SelectedAddons_Variant_Id struct { union json.RawMessage @@ -6350,6 +6369,32 @@ func (t *ListProjectAddonsResponse_AvailableAddons_Variants_Id) MergeListProject return err } +// AsListProjectAddonsResponseAvailableAddonsVariantsId7 returns the union data inside the ListProjectAddonsResponse_AvailableAddons_Variants_Id as a ListProjectAddonsResponseAvailableAddonsVariantsId7 +func (t ListProjectAddonsResponse_AvailableAddons_Variants_Id) AsListProjectAddonsResponseAvailableAddonsVariantsId7() (ListProjectAddonsResponseAvailableAddonsVariantsId7, error) { + var body ListProjectAddonsResponseAvailableAddonsVariantsId7 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromListProjectAddonsResponseAvailableAddonsVariantsId7 overwrites any union data inside the ListProjectAddonsResponse_AvailableAddons_Variants_Id as the provided ListProjectAddonsResponseAvailableAddonsVariantsId7 +func (t *ListProjectAddonsResponse_AvailableAddons_Variants_Id) FromListProjectAddonsResponseAvailableAddonsVariantsId7(v ListProjectAddonsResponseAvailableAddonsVariantsId7) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeListProjectAddonsResponseAvailableAddonsVariantsId7 performs a merge with any union data inside the ListProjectAddonsResponse_AvailableAddons_Variants_Id, using the provided ListProjectAddonsResponseAvailableAddonsVariantsId7 +func (t *ListProjectAddonsResponse_AvailableAddons_Variants_Id) MergeListProjectAddonsResponseAvailableAddonsVariantsId7(v ListProjectAddonsResponseAvailableAddonsVariantsId7) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + func (t ListProjectAddonsResponse_AvailableAddons_Variants_Id) MarshalJSON() ([]byte, error) { b, err := t.union.MarshalJSON() return b, err @@ -6542,6 +6587,32 @@ func (t *ListProjectAddonsResponse_SelectedAddons_Variant_Id) MergeListProjectAd return err } +// AsListProjectAddonsResponseSelectedAddonsVariantId7 returns the union data inside the ListProjectAddonsResponse_SelectedAddons_Variant_Id as a ListProjectAddonsResponseSelectedAddonsVariantId7 +func (t ListProjectAddonsResponse_SelectedAddons_Variant_Id) AsListProjectAddonsResponseSelectedAddonsVariantId7() (ListProjectAddonsResponseSelectedAddonsVariantId7, error) { + var body ListProjectAddonsResponseSelectedAddonsVariantId7 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromListProjectAddonsResponseSelectedAddonsVariantId7 overwrites any union data inside the ListProjectAddonsResponse_SelectedAddons_Variant_Id as the provided ListProjectAddonsResponseSelectedAddonsVariantId7 +func (t *ListProjectAddonsResponse_SelectedAddons_Variant_Id) FromListProjectAddonsResponseSelectedAddonsVariantId7(v ListProjectAddonsResponseSelectedAddonsVariantId7) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeListProjectAddonsResponseSelectedAddonsVariantId7 performs a merge with any union data inside the ListProjectAddonsResponse_SelectedAddons_Variant_Id, using the provided ListProjectAddonsResponseSelectedAddonsVariantId7 +func (t *ListProjectAddonsResponse_SelectedAddons_Variant_Id) MergeListProjectAddonsResponseSelectedAddonsVariantId7(v ListProjectAddonsResponseSelectedAddonsVariantId7) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + func (t ListProjectAddonsResponse_SelectedAddons_Variant_Id) MarshalJSON() ([]byte, error) { b, err := t.union.MarshalJSON() return b, err diff --git a/apps/cli-go/pkg/config/config.go b/apps/cli-go/pkg/config/config.go index 61597d6806..dfd63999cd 100644 --- a/apps/cli-go/pkg/config/config.go +++ b/apps/cli-go/pkg/config/config.go @@ -253,6 +253,11 @@ type ( Webhooks *webhooks `toml:"webhooks" json:"webhooks"` PgDelta *PgDeltaConfig `toml:"pgdelta" json:"pgdelta"` Inspect inspect `toml:"inspect" json:"inspect"` + // PgDeltaInitEnabled drives the [experimental.pgdelta] enabled value rendered + // by Eject. It is true only for the supabase init scaffold so freshly generated + // projects opt into pg-delta, and false when Eject feeds mergeDefaultValues so + // existing configs without the section keep resolving to migra (non-breaking). + PgDeltaInitEnabled bool `toml:"-" json:"-"` } ) diff --git a/apps/cli-go/pkg/config/config_test.go b/apps/cli-go/pkg/config/config_test.go index 2a1a189076..c884a26229 100644 --- a/apps/cli-go/pkg/config/config_test.go +++ b/apps/cli-go/pkg/config/config_test.go @@ -242,6 +242,49 @@ format_options = "not-json" err := config.Load("", fsys) assert.ErrorContains(t, err, "experimental.pgdelta.format_options") }) + + t.Run("init scaffold opts into pgdelta", func(t *testing.T) { + config := NewConfig() + // supabase init renders the scaffold with the pg-delta opt-in flag set + config.Experimental.PgDeltaInitEnabled = true + var buf bytes.Buffer + require.NoError(t, config.Eject(&buf)) + fsys := fs.MapFS{"supabase/config.toml": &fs.MapFile{Data: buf.Bytes()}} + // Reloading the generated config resolves to pg-delta + require.NoError(t, config.Load("", fsys)) + require.NotNil(t, config.Experimental.PgDelta) + assert.True(t, config.Experimental.PgDelta.Enabled) + }) + + t.Run("absent pgdelta section falls back to migra", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental] +orioledb_version = "" +`)}, + } + + // The default ejected by mergeDefaultValues keeps pg-delta disabled, so a config + // without the section resolves to migra (PgDelta is non-nil only for version pinning). + require.NoError(t, config.Load("", fsys)) + require.NotNil(t, config.Experimental.PgDelta) + assert.False(t, config.Experimental.PgDelta.Enabled) + }) + + t.Run("explicit enabled false restores migra", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental.pgdelta] +enabled = false +`)}, + } + + require.NoError(t, config.Load("", fsys)) + require.NotNil(t, config.Experimental.PgDelta) + assert.False(t, config.Experimental.PgDelta.Enabled) + }) } func TestPgDeltaNpmVersionPinning(t *testing.T) { diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 37d4cebc60..fd807d8854 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -1,5 +1,5 @@ # Exposed for updates by .github/dependabot.yml -FROM supabase/postgres:17.6.1.134 AS pg +FROM supabase/postgres:17.6.1.135 AS pg # Append to ServiceImages when adding new dependencies below FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit @@ -11,9 +11,9 @@ FROM supabase/edge-runtime:v1.74.1 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector FROM supabase/supavisor:2.9.7 AS supavisor FROM supabase/gotrue:v2.189.0 AS gotrue -FROM supabase/realtime:v2.106.0 AS realtime -FROM supabase/storage-api:v1.60.15 AS storage -FROM supabase/logflare:1.44.1 AS logflare +FROM supabase/realtime:v2.107.1 AS realtime +FROM supabase/storage-api:v1.60.17 AS storage +FROM supabase/logflare:1.44.3 AS logflare # Append to JobImages when adding new dependencies below FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ FROM supabase/migra:3.0.1663481299 AS migra diff --git a/apps/cli-go/pkg/config/templates/config.toml b/apps/cli-go/pkg/config/templates/config.toml index ba3663e109..505811216a 100644 --- a/apps/cli-go/pkg/config/templates/config.toml +++ b/apps/cli-go/pkg/config/templates/config.toml @@ -404,9 +404,10 @@ s3_access_key = "env(S3_ACCESS_KEY)" # Configures AWS_SECRET_ACCESS_KEY for S3 bucket s3_secret_key = "env(S3_SECRET_KEY)" -# [experimental.pgdelta] -# When enabled, pg-delta becomes the active engine for supported schema flows. -# enabled = false +# pg-delta is the schema diff engine for db diff / db pull / db remote commit. +# Set enabled = false to fall back to the legacy migra engine. +[experimental.pgdelta] +enabled = {{ .Experimental.PgDeltaInitEnabled }} # Directory under `supabase/` where declarative files are written. # declarative_schema_path = "./database" # JSON string passed through to pg-delta SQL formatting. diff --git a/apps/cli-go/pkg/storage/batch.go b/apps/cli-go/pkg/storage/batch.go index 2e62774e1b..bb6dad0b5d 100644 --- a/apps/cli-go/pkg/storage/batch.go +++ b/apps/cli-go/pkg/storage/batch.go @@ -62,6 +62,31 @@ type UploadOptions struct { KeyPrefix string } +func isUploadableEntry(fsys fs.FS, filePath string, info fs.DirEntry) bool { + if info.Type().IsRegular() { + return true + } + if info.Type().IsDir() { + return false + } + if info.Type()&fs.ModeSymlink != 0 { + f, err := fsys.Open(filePath) + if err != nil { + fmt.Fprintln(os.Stderr, "Skipping non-regular file:", filePath) + return false + } + defer f.Close() + stat, err := f.Stat() + if err != nil || !stat.Mode().IsRegular() { + fmt.Fprintln(os.Stderr, "Skipping non-regular file:", filePath) + return false + } + return true + } + fmt.Fprintln(os.Stderr, "Skipping non-regular file:", filePath) + return false +} + func (s *StorageAPI) UpsertObjects(ctx context.Context, bucketConfig config.BucketConfig, fsys fs.FS, opts ...func(*UploadOptions)) error { uo := UploadOptions{MaxConcurrency: 5} for _, apply := range opts { @@ -77,7 +102,7 @@ func (s *StorageAPI) UpsertObjects(ctx context.Context, bucketConfig config.Buck if err != nil { return errors.New(err) } - if !info.Type().IsRegular() { + if !isUploadableEntry(fsys, filePath, info) { return nil } dstPath := uo.KeyPrefix diff --git a/apps/cli-go/pkg/storage/objects_test.go b/apps/cli-go/pkg/storage/objects_test.go index 423cc765c3..8983495097 100644 --- a/apps/cli-go/pkg/storage/objects_test.go +++ b/apps/cli-go/pkg/storage/objects_test.go @@ -1,14 +1,20 @@ package storage import ( + "bytes" "context" + "io" "mime" "net/http" + "os" + "path/filepath" "testing" fs "testing/fstest" "github.com/h2non/gock" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/fetcher" ) @@ -90,3 +96,83 @@ func TestParseFileOptionsContentTypeDetection(t *testing.T) { }) } } + +func TestUpsertObjects(t *testing.T) { + t.Run("uploads regular files", func(t *testing.T) { + defer gock.OffAll() + gock.New("http://127.0.0.1"). + Post("/storage/v1/object/uploads/pizza.jpg"). + Reply(http.StatusOK) + fsys := fs.MapFS{ + "seeds/pizza.jpg": &fs.MapFile{Data: []byte("image")}, + } + err := mockApi.UpsertObjects(context.Background(), config.BucketConfig{ + "uploads": {ObjectsPath: "seeds"}, + }, fsys) + assert.NoError(t, err) + assert.Empty(t, gock.Pending()) + assert.Empty(t, gock.GetUnmatchedRequests()) + }) + + t.Run("uploads symlinked files", func(t *testing.T) { + tmpDir := t.TempDir() + fixturesDir := filepath.Join(tmpDir, "fixtures") + seedsDir := filepath.Join(tmpDir, "seeds") + require.NoError(t, os.MkdirAll(fixturesDir, 0o755)) + require.NoError(t, os.MkdirAll(seedsDir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(fixturesDir, "pizza.jpg"), []byte("image"), 0o600)) + require.NoError(t, os.Symlink(filepath.Join(fixturesDir, "pizza.jpg"), filepath.Join(seedsDir, "pizza.jpg"))) + + defer gock.OffAll() + gock.New("http://127.0.0.1"). + Post("/storage/v1/object/uploads/pizza.jpg"). + Reply(http.StatusOK) + err := mockApi.UpsertObjects(context.Background(), config.BucketConfig{ + "uploads": {ObjectsPath: "seeds"}, + }, os.DirFS(tmpDir)) + assert.NoError(t, err) + assert.Empty(t, gock.Pending()) + assert.Empty(t, gock.GetUnmatchedRequests()) + }) + + t.Run("skips broken symlinks with warning", func(t *testing.T) { + tmpDir := t.TempDir() + seedsDir := filepath.Join(tmpDir, "seeds") + require.NoError(t, os.MkdirAll(seedsDir, 0o755)) + require.NoError(t, os.Symlink(filepath.Join(tmpDir, "missing.jpg"), filepath.Join(seedsDir, "pizza.jpg"))) + + stderr := captureStderr(t, func() { + err := mockApi.UpsertObjects(context.Background(), config.BucketConfig{ + "uploads": {ObjectsPath: "seeds"}, + }, os.DirFS(tmpDir)) + assert.NoError(t, err) + }) + assert.Contains(t, stderr, "Skipping non-regular file:") + assert.Contains(t, stderr, "seeds/pizza.jpg") + }) +} + +func captureStderr(t *testing.T, fn func()) string { + t.Helper() + reader, writer, err := os.Pipe() + require.NoError(t, err) + original := os.Stderr + os.Stderr = writer + t.Cleanup(func() { + os.Stderr = original + _ = reader.Close() + _ = writer.Close() + }) + done := make(chan struct{}) + var buf bytes.Buffer + go func() { + _, _ = io.Copy(&buf, reader) + close(done) + }() + fn() + closeErr := writer.Close() + os.Stderr = original + <-done + require.NoError(t, closeErr) + return buf.String() +} diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 0ae99db8c8..a77c0de262 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -19,21 +19,21 @@ Percentages and counts below are based on final leaf commands only. Command grou | Metric | Count | Percent | | ------------------------- | ------: | ------: | -| Fully ported commands | 6 / 94 | 6.4% | +| Fully ported commands | 8 / 94 | 8.5% | | Partially ported commands | 55 / 94 | 58.5% | ## Family Summary -| Family | Final commands | `ported` | `partial` | `missing` | Represented in TS | -| ------------------------- | -------------: | --------: | --------: | --------: | ----------------: | -| Quick Start | 1 | 0 (0%) | 0 (0%) | 1 (100%) | 0 (0%) | -| Project / Stack Lifecycle | 9 | 2 (22.2%) | 7 (77.8%) | 0 (0%) | 9 (100%) | -| Database | 19 | 0 (0%) | 0 (0%) | 19 (100%) | 0 (0%) | -| Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | -| Functions | 6 | 0 (0%) | 0 (0%) | 6 (100%) | 0 (0%) | -| Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | -| Management APIs | 47 | 0 (0%) | 47 (100%) | 0 (0%) | 47 (100%) | -| Additional Commands | 5 | 4 (80%) | 1 (20%) | 0 (0%) | 5 (100.0%) | +| Family | Final commands | `ported` | `partial` | `missing` | Represented in TS | +| ------------------------- | -------------: | --------: | --------: | ---------: | ----------------: | +| Quick Start | 1 | 0 (0%) | 0 (0%) | 1 (100%) | 0 (0%) | +| Project / Stack Lifecycle | 9 | 2 (22.2%) | 7 (77.8%) | 0 (0%) | 9 (100%) | +| Database | 19 | 2 (10.5%) | 0 (0%) | 17 (89.5%) | 2 (10.5%) | +| Code Generation | 3 | 0 (0%) | 0 (0%) | 3 (100%) | 0 (0%) | +| Functions | 6 | 0 (0%) | 0 (0%) | 6 (100%) | 0 (0%) | +| Storage | 4 | 0 (0%) | 0 (0%) | 4 (100%) | 0 (0%) | +| Management APIs | 47 | 0 (0%) | 47 (100%) | 0 (0%) | 47 (100%) | +| Additional Commands | 5 | 4 (80%) | 1 (20%) | 0 (0%) | 5 (100.0%) | ## Global Flags Overview @@ -80,51 +80,51 @@ These commands exist in the TS CLI today but have no direct top-level equivalent ## Database -| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | -| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------- | -| `db diff` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db lint` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db pull` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `inspect report` | `wrapped` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Phase 0 proxy. Wrapped in legacy shell. | -| `inspect db db-stats` | `wrapped` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db replication-slots` | `wrapped` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db locks` | `wrapped` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db blocking` | `wrapped` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db outliers` | `wrapped` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db calls` | `wrapped` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db index-stats` | `wrapped` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db long-running-queries` | `wrapped` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db bloat` | `wrapped` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db role-stats` | `wrapped` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db vacuum-stats` | `wrapped` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db table-stats` | `wrapped` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db traffic-profile` | `wrapped` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Phase 0 proxy. Queries Postgres directly. Wrapped in legacy shell. | -| `inspect db cache-hit` | `wrapped` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use db-stats). Wrapped in legacy shell. | -| `inspect db index-usage` | `wrapped` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use index-stats). Wrapped in legacy shell. | -| `inspect db total-index-size` | `wrapped` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use index-stats). Wrapped in legacy shell. | -| `inspect db index-sizes` | `wrapped` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use index-stats). Wrapped in legacy shell. | -| `inspect db table-sizes` | `wrapped` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use table-stats). Wrapped in legacy shell. | -| `inspect db table-index-sizes` | `wrapped` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use table-stats). Wrapped in legacy shell. | -| `inspect db total-table-sizes` | `wrapped` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use table-stats). Wrapped in legacy shell. | -| `inspect db unused-indexes` | `wrapped` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use index-stats). Wrapped in legacy shell. | -| `inspect db table-record-counts` | `wrapped` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use table-stats). Wrapped in legacy shell. | -| `inspect db seq-scans` | `wrapped` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use index-stats). Wrapped in legacy shell. | -| `inspect db role-configs` | `wrapped` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use role-stats). Wrapped in legacy shell. | -| `inspect db role-connections` | `wrapped` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Phase 0 proxy. Deprecated (use role-stats). Wrapped in legacy shell. | -| `migration down` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `seed buckets` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `test db` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | -| `test new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| Old command | TS status | TS command path or `missing` | Missing flags/params | Extra TS flags/params | Notes | +| --------------------------------- | --------- | -------------------------------------------------- | -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `db diff` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db dump` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db lint` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db pull` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db push` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db reset` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `db start` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `inspect report` | `wrapped` | `legacy/commands/inspect/report/` | `n/a` | `n/a` | Phase 0 proxy. Wrapped in legacy shell. | +| `inspect db db-stats` | `ported` | `legacy/commands/inspect/db/db-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db replication-slots` | `ported` | `legacy/commands/inspect/db/replication-slots/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db locks` | `ported` | `legacy/commands/inspect/db/locks/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db blocking` | `ported` | `legacy/commands/inspect/db/blocking/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db outliers` | `ported` | `legacy/commands/inspect/db/outliers/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db calls` | `ported` | `legacy/commands/inspect/db/calls/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db index-stats` | `ported` | `legacy/commands/inspect/db/index-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db long-running-queries` | `ported` | `legacy/commands/inspect/db/long-running-queries/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db bloat` | `ported` | `legacy/commands/inspect/db/bloat/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db role-stats` | `ported` | `legacy/commands/inspect/db/role-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db vacuum-stats` | `ported` | `legacy/commands/inspect/db/vacuum-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db table-stats` | `ported` | `legacy/commands/inspect/db/table-stats/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db traffic-profile` | `ported` | `legacy/commands/inspect/db/traffic-profile/` | `n/a` | `n/a` | Native TS port. Queries Postgres directly via LegacyDbConnection; renders Go-parity Glamour tables. | +| `inspect db cache-hit` | `ported` | `legacy/commands/inspect/db/cache-hit/` | `n/a` | `n/a` | Native TS port. Deprecated (use db-stats); routes to the active query. | +| `inspect db index-usage` | `ported` | `legacy/commands/inspect/db/index-usage/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db total-index-size` | `ported` | `legacy/commands/inspect/db/total-index-size/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db index-sizes` | `ported` | `legacy/commands/inspect/db/index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-sizes` | `ported` | `legacy/commands/inspect/db/table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db table-index-sizes` | `ported` | `legacy/commands/inspect/db/table-index-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db total-table-sizes` | `ported` | `legacy/commands/inspect/db/total-table-sizes/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db unused-indexes` | `ported` | `legacy/commands/inspect/db/unused-indexes/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db table-record-counts` | `ported` | `legacy/commands/inspect/db/table-record-counts/` | `n/a` | `n/a` | Native TS port. Deprecated (use table-stats); routes to the active query. | +| `inspect db seq-scans` | `ported` | `legacy/commands/inspect/db/seq-scans/` | `n/a` | `n/a` | Native TS port. Deprecated (use index-stats); routes to the active query. | +| `inspect db role-configs` | `ported` | `legacy/commands/inspect/db/role-configs/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `inspect db role-connections` | `ported` | `legacy/commands/inspect/db/role-connections/` | `n/a` | `n/a` | Native TS port. Deprecated (use role-stats); routes to the active query. | +| `migration down` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration fetch` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration list` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration new` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration repair` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration squash` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `migration up` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `seed buckets` | `missing` | `missing` | `n/a` | `n/a` | No native TS implementation yet. Wrapped in legacy shell. | +| `test db` | `ported` | `legacy/commands/test/db/` | `n/a` | `n/a` | Native TS port. `--db-url`/`--local`/`--linked` + variadic paths; runs `supabase/pg_prove:3.36` via `docker run`; pgTAP enable/disable via `@effect/sql-pg`. `--network-id` override and `[images]` config override not modeled (documented divergences). | +| `test new` | `ported` | `legacy/commands/test/new/` | `n/a` | `n/a` | Native TS port. Writes `supabase/tests/_test.sql` from the embedded pgtap template; `--template` (pgtap). | ## Code Generation @@ -211,108 +211,108 @@ Legend: - `wrapped`: Phase 0 proxy wrapper exists in the legacy shell - `missing`: no legacy shell command yet -| Command | Legacy status | Legacy command path | -| -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | -| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | -| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | -| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | -| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | -| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | -| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | -| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | -| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | -| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | -| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | -| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | -| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | -| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | -| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | -| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | -| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | -| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | -| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | -| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | -| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | -| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | -| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | -| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | -| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | -| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | -| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | -| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | -| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | -| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | -| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | -| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | -| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | -| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | -| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | -| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | -| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | -| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | -| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | -| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | -| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | -| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | -| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | -| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | -| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | -| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | -| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | -| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | -| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | -| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | -| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | -| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | -| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | -| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | -| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | -| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | -| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | -| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | -| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | -| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | -| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | -| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | -| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | -| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | -| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | -| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | -| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | -| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | -| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | -| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | -| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | -| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | -| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | -| `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | -| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | -| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | -| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | -| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | -| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | -| `storage rm` | `wrapped` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | -| `test db` | `wrapped` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | -| `test new` | `wrapped` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | -| `seed buckets` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | -| `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | -| `db dump` | `wrapped` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | -| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | -| `db pull` | `wrapped` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — includes `--diff-engine` (migra\|pg-delta, mutually exclusive with `--use-pg-delta`) | -| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | -| `db lint` | `wrapped` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | -| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | -| `db query` | `wrapped` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | -| `db advisors` | `wrapped` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | -| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | -| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | -| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | -| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | -| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | -| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | -| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | -| `db schema declarative sync` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | -| `db schema declarative generate` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | +| Command | Legacy status | Legacy command path | +| -------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `orgs list` | `ported` | [`../src/legacy/commands/orgs/list/list.command.ts`](../src/legacy/commands/orgs/list/list.command.ts) | +| `orgs create` | `ported` | [`../src/legacy/commands/orgs/create/create.command.ts`](../src/legacy/commands/orgs/create/create.command.ts) | +| `projects list` | `ported` | [`../src/legacy/commands/projects/list/list.command.ts`](../src/legacy/commands/projects/list/list.command.ts) | +| `projects create` | `ported` | [`../src/legacy/commands/projects/create/create.command.ts`](../src/legacy/commands/projects/create/create.command.ts) | +| `projects delete` | `ported` | [`../src/legacy/commands/projects/delete/delete.command.ts`](../src/legacy/commands/projects/delete/delete.command.ts) | +| `projects api-keys` | `ported` | [`../src/legacy/commands/projects/api-keys/api-keys.command.ts`](../src/legacy/commands/projects/api-keys/api-keys.command.ts) | +| `branches list` | `ported` | [`../src/legacy/commands/branches/list/list.command.ts`](../src/legacy/commands/branches/list/list.command.ts) | +| `branches create` | `ported` | [`../src/legacy/commands/branches/create/create.command.ts`](../src/legacy/commands/branches/create/create.command.ts) | +| `branches get` | `ported` | [`../src/legacy/commands/branches/get/get.command.ts`](../src/legacy/commands/branches/get/get.command.ts) | +| `branches update` | `ported` | [`../src/legacy/commands/branches/update/update.command.ts`](../src/legacy/commands/branches/update/update.command.ts) | +| `branches pause` | `ported` | [`../src/legacy/commands/branches/pause/pause.command.ts`](../src/legacy/commands/branches/pause/pause.command.ts) | +| `branches unpause` | `ported` | [`../src/legacy/commands/branches/unpause/unpause.command.ts`](../src/legacy/commands/branches/unpause/unpause.command.ts) | +| `branches delete` | `ported` | [`../src/legacy/commands/branches/delete/delete.command.ts`](../src/legacy/commands/branches/delete/delete.command.ts) | +| `branches disable` | `ported` | [`../src/legacy/commands/branches/disable/disable.command.ts`](../src/legacy/commands/branches/disable/disable.command.ts) | +| `secrets list` | `ported` | [`../src/legacy/commands/secrets/list/list.command.ts`](../src/legacy/commands/secrets/list/list.command.ts) | +| `secrets set` | `ported` | [`../src/legacy/commands/secrets/set/set.command.ts`](../src/legacy/commands/secrets/set/set.command.ts) | +| `secrets unset` | `ported` | [`../src/legacy/commands/secrets/unset/unset.command.ts`](../src/legacy/commands/secrets/unset/unset.command.ts) | +| `config push` | `ported` | [`../src/legacy/commands/config/push/push.command.ts`](../src/legacy/commands/config/push/push.command.ts) | +| `backups list` | `ported` | [`../src/legacy/commands/backups/list/list.command.ts`](../src/legacy/commands/backups/list/list.command.ts) | +| `backups restore` | `ported` | [`../src/legacy/commands/backups/restore/restore.command.ts`](../src/legacy/commands/backups/restore/restore.command.ts) | +| `snippets list` | `ported` | [`../src/legacy/commands/snippets/list/list.command.ts`](../src/legacy/commands/snippets/list/list.command.ts) | +| `snippets download` | `ported` | [`../src/legacy/commands/snippets/download/download.command.ts`](../src/legacy/commands/snippets/download/download.command.ts) | +| `sso list` | `ported` | [`../src/legacy/commands/sso/list/list.command.ts`](../src/legacy/commands/sso/list/list.command.ts) | +| `sso add` | `ported` | [`../src/legacy/commands/sso/add/add.command.ts`](../src/legacy/commands/sso/add/add.command.ts) | +| `sso remove` | `ported` | [`../src/legacy/commands/sso/remove/remove.command.ts`](../src/legacy/commands/sso/remove/remove.command.ts) | +| `sso update` | `ported` | [`../src/legacy/commands/sso/update/update.command.ts`](../src/legacy/commands/sso/update/update.command.ts) | +| `sso show` | `ported` | [`../src/legacy/commands/sso/show/show.command.ts`](../src/legacy/commands/sso/show/show.command.ts) | +| `sso info` | `ported` | [`../src/legacy/commands/sso/info/info.command.ts`](../src/legacy/commands/sso/info/info.command.ts) | +| `domains create` | `ported` | [`../src/legacy/commands/domains/create/create.command.ts`](../src/legacy/commands/domains/create/create.command.ts) | +| `domains get` | `ported` | [`../src/legacy/commands/domains/get/get.command.ts`](../src/legacy/commands/domains/get/get.command.ts) | +| `domains reverify` | `ported` | [`../src/legacy/commands/domains/reverify/reverify.command.ts`](../src/legacy/commands/domains/reverify/reverify.command.ts) | +| `domains activate` | `ported` | [`../src/legacy/commands/domains/activate/activate.command.ts`](../src/legacy/commands/domains/activate/activate.command.ts) | +| `domains delete` | `ported` | [`../src/legacy/commands/domains/delete/delete.command.ts`](../src/legacy/commands/domains/delete/delete.command.ts) | +| `vanity-subdomains get` | `ported` | [`../src/legacy/commands/vanity-subdomains/get/get.command.ts`](../src/legacy/commands/vanity-subdomains/get/get.command.ts) | +| `vanity-subdomains check-availability` | `ported` | [`../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts`](../src/legacy/commands/vanity-subdomains/check-availability/check-availability.command.ts) | +| `vanity-subdomains activate` | `ported` | [`../src/legacy/commands/vanity-subdomains/activate/activate.command.ts`](../src/legacy/commands/vanity-subdomains/activate/activate.command.ts) | +| `vanity-subdomains delete` | `ported` | [`../src/legacy/commands/vanity-subdomains/delete/delete.command.ts`](../src/legacy/commands/vanity-subdomains/delete/delete.command.ts) | +| `network-bans get` | `ported` | [`../src/legacy/commands/network-bans/get/get.command.ts`](../src/legacy/commands/network-bans/get/get.command.ts) | +| `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | +| `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | +| `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | +| `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | +| `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | +| `postgres-config update` | `ported` | [`../src/legacy/commands/postgres-config/update/update.command.ts`](../src/legacy/commands/postgres-config/update/update.command.ts) | +| `postgres-config delete` | `ported` | [`../src/legacy/commands/postgres-config/delete/delete.command.ts`](../src/legacy/commands/postgres-config/delete/delete.command.ts) | +| `login` | `ported` | [`../src/legacy/commands/login/login.command.ts`](../src/legacy/commands/login/login.command.ts) | +| `logout` | `ported` | [`../src/legacy/commands/logout/logout.command.ts`](../src/legacy/commands/logout/logout.command.ts) | +| `link` | `ported` | [`../src/legacy/commands/link/link.command.ts`](../src/legacy/commands/link/link.command.ts) | +| `unlink` | `ported` | [`../src/legacy/commands/unlink/unlink.command.ts`](../src/legacy/commands/unlink/unlink.command.ts) | +| `bootstrap` | `ported` | [`../src/legacy/commands/bootstrap/bootstrap.command.ts`](../src/legacy/commands/bootstrap/bootstrap.command.ts) (native; `db push` step delegated to the Go binary — interim) | +| `init` | `ported` | [`../src/legacy/commands/init/init.command.ts`](../src/legacy/commands/init/init.command.ts) | +| `services` | `ported` | [`../src/legacy/commands/services/services.command.ts`](../src/legacy/commands/services/services.command.ts) | +| `start` | `wrapped` | [`../src/legacy/commands/start/start.command.ts`](../src/legacy/commands/start/start.command.ts) | +| `stop` | `wrapped` | [`../src/legacy/commands/stop/stop.command.ts`](../src/legacy/commands/stop/stop.command.ts) | +| `status` | `wrapped` | [`../src/legacy/commands/status/status.command.ts`](../src/legacy/commands/status/status.command.ts) | +| `telemetry enable` | `ported` | [`../src/legacy/commands/telemetry/enable/enable.command.ts`](../src/legacy/commands/telemetry/enable/enable.command.ts) | +| `telemetry disable` | `ported` | [`../src/legacy/commands/telemetry/disable/disable.command.ts`](../src/legacy/commands/telemetry/disable/disable.command.ts) | +| `telemetry status` | `ported` | [`../src/legacy/commands/telemetry/status/status.command.ts`](../src/legacy/commands/telemetry/status/status.command.ts) | +| `migration list` | `wrapped` | [`../src/legacy/commands/migration/list/list.command.ts`](../src/legacy/commands/migration/list/list.command.ts) | +| `migration new` | `wrapped` | [`../src/legacy/commands/migration/new/new.command.ts`](../src/legacy/commands/migration/new/new.command.ts) | +| `migration repair` | `wrapped` | [`../src/legacy/commands/migration/repair/repair.command.ts`](../src/legacy/commands/migration/repair/repair.command.ts) | +| `migration squash` | `wrapped` | [`../src/legacy/commands/migration/squash/squash.command.ts`](../src/legacy/commands/migration/squash/squash.command.ts) | +| `migration up` | `wrapped` | [`../src/legacy/commands/migration/up/up.command.ts`](../src/legacy/commands/migration/up/up.command.ts) | +| `migration down` | `wrapped` | [`../src/legacy/commands/migration/down/down.command.ts`](../src/legacy/commands/migration/down/down.command.ts) | +| `migration fetch` | `wrapped` | [`../src/legacy/commands/migration/fetch/fetch.command.ts`](../src/legacy/commands/migration/fetch/fetch.command.ts) | +| `gen types` | `ported` | [`../src/legacy/commands/gen/types/types.command.ts`](../src/legacy/commands/gen/types/types.command.ts) | +| `gen signing-key` | `ported` | [`../src/legacy/commands/gen/signing-key/signing-key.command.ts`](../src/legacy/commands/gen/signing-key/signing-key.command.ts) | +| `gen bearer-jwt` | `wrapped` | [`../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts`](../src/legacy/commands/gen/bearer-jwt/bearer-jwt.command.ts) | +| `gen keys` | `wrapped` | [`../src/legacy/commands/gen/keys/keys.command.ts`](../src/legacy/commands/gen/keys/keys.command.ts) | +| `functions list` | `wrapped` | [`../src/legacy/commands/functions/list/list.command.ts`](../src/legacy/commands/functions/list/list.command.ts) | +| `functions delete` | `ported` | [`../src/legacy/commands/functions/delete/delete.command.ts`](../src/legacy/commands/functions/delete/delete.command.ts) | +| `functions download` | `ported` | [`../src/legacy/commands/functions/download/download.command.ts`](../src/legacy/commands/functions/download/download.command.ts) | +| `functions deploy` | `wrapped` | [`../src/legacy/commands/functions/deploy/deploy.command.ts`](../src/legacy/commands/functions/deploy/deploy.command.ts) | +| `functions new` | `wrapped` | [`../src/legacy/commands/functions/new/new.command.ts`](../src/legacy/commands/functions/new/new.command.ts) | +| `functions serve` | `wrapped` | [`../src/legacy/commands/functions/serve/serve.command.ts`](../src/legacy/commands/functions/serve/serve.command.ts) | +| `storage ls` | `wrapped` | [`../src/legacy/commands/storage/ls/ls.command.ts`](../src/legacy/commands/storage/ls/ls.command.ts) | +| `storage cp` | `wrapped` | [`../src/legacy/commands/storage/cp/cp.command.ts`](../src/legacy/commands/storage/cp/cp.command.ts) | +| `storage mv` | `wrapped` | [`../src/legacy/commands/storage/mv/mv.command.ts`](../src/legacy/commands/storage/mv/mv.command.ts) | +| `storage rm` | `wrapped` | [`../src/legacy/commands/storage/rm/rm.command.ts`](../src/legacy/commands/storage/rm/rm.command.ts) | +| `test db` | `ported` | [`../src/legacy/commands/test/db/db.command.ts`](../src/legacy/commands/test/db/db.command.ts) | +| `test new` | `ported` | [`../src/legacy/commands/test/new/new.command.ts`](../src/legacy/commands/test/new/new.command.ts) | +| `seed buckets` | `wrapped` | [`../src/legacy/commands/seed/buckets/buckets.command.ts`](../src/legacy/commands/seed/buckets/buckets.command.ts) | +| `db diff` | `wrapped` | [`../src/legacy/commands/db/diff/diff.command.ts`](../src/legacy/commands/db/diff/diff.command.ts) | +| `db dump` | `wrapped` | [`../src/legacy/commands/db/dump/dump.command.ts`](../src/legacy/commands/db/dump/dump.command.ts) | +| `db push` | `wrapped` | [`../src/legacy/commands/db/push/push.command.ts`](../src/legacy/commands/db/push/push.command.ts) | +| `db pull` | `wrapped` | [`../src/legacy/commands/db/pull/pull.command.ts`](../src/legacy/commands/db/pull/pull.command.ts) — includes `--declarative` (deprecated alias `--use-pg-delta`) and `--diff-engine` (migra\|pg-delta, mutually exclusive with `--declarative`) | +| `db reset` | `wrapped` | [`../src/legacy/commands/db/reset/reset.command.ts`](../src/legacy/commands/db/reset/reset.command.ts) | +| `db lint` | `wrapped` | [`../src/legacy/commands/db/lint/lint.command.ts`](../src/legacy/commands/db/lint/lint.command.ts) | +| `db start` | `wrapped` | [`../src/legacy/commands/db/start/start.command.ts`](../src/legacy/commands/db/start/start.command.ts) | +| `db query` | `wrapped` | [`../src/legacy/commands/db/query/query.command.ts`](../src/legacy/commands/db/query/query.command.ts) | +| `db advisors` | `wrapped` | [`../src/legacy/commands/db/advisors/advisors.command.ts`](../src/legacy/commands/db/advisors/advisors.command.ts) | +| `db test` | `wrapped` | [`../src/legacy/commands/db/test/test.command.ts`](../src/legacy/commands/db/test/test.command.ts) | +| `db branch create` | `wrapped` | [`../src/legacy/commands/db/branch/create/create.command.ts`](../src/legacy/commands/db/branch/create/create.command.ts) | +| `db branch delete` | `wrapped` | [`../src/legacy/commands/db/branch/delete/delete.command.ts`](../src/legacy/commands/db/branch/delete/delete.command.ts) | +| `db branch list` | `wrapped` | [`../src/legacy/commands/db/branch/list/list.command.ts`](../src/legacy/commands/db/branch/list/list.command.ts) | +| `db branch switch` | `wrapped` | [`../src/legacy/commands/db/branch/switch/switch.command.ts`](../src/legacy/commands/db/branch/switch/switch.command.ts) | +| `db remote changes` | `wrapped` | [`../src/legacy/commands/db/remote/changes/changes.command.ts`](../src/legacy/commands/db/remote/changes/changes.command.ts) | +| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) | +| `db schema declarative sync` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) | +| `db schema declarative generate` | `wrapped` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) | diff --git a/apps/cli/package.json b/apps/cli/package.json index e64697ff24..05de44e5cd 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -38,11 +38,12 @@ "fix:all": "nx run-many -t lint:fix fmt:fix knip:fix --projects=$npm_package_name" }, "devDependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.162", - "@anthropic-ai/sdk": "^0.100.1", + "@anthropic-ai/claude-agent-sdk": "^0.3.168", + "@anthropic-ai/sdk": "^0.102.0", "@clack/prompts": "^1.5.1", "@effect/atom-react": "catalog:", "@effect/platform-bun": "catalog:", + "@effect/sql-pg": "catalog:", "@effect/vitest": "catalog:", "@modelcontextprotocol/sdk": "^1.29.0", "@napi-rs/keyring": "^1.3.0", @@ -53,7 +54,7 @@ "@supabase/stack": "workspace:*", "@tsconfig/bun": "catalog:", "@types/bun": "catalog:", - "@types/react": "^19.2.16", + "@types/react": "^19.2.17", "@typescript/native-preview": "catalog:", "@vercel/detect-agent": "^1.2.3", "@vitest/coverage-istanbul": "catalog:", @@ -65,11 +66,12 @@ "oxfmt": "catalog:", "oxlint": "catalog:", "oxlint-tsgolint": "catalog:", - "posthog-node": "^5.35.14", + "posthog-node": "^5.36.4", "react": "^19.2.7", "react-devtools-core": "^7.0.1", "semantic-release": "^25.0.3", "smol-toml": "^1.6.1", + "tldts": "catalog:", "vitest": "catalog:", "yaml": "^2.9.0" }, diff --git a/apps/cli/src/legacy/auth/legacy-platform-api-factory.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api-factory.layer.ts new file mode 100644 index 0000000000..cd7ebeaeae --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api-factory.layer.ts @@ -0,0 +1,42 @@ +import { FetchHttpClient } from "effect/unstable/http"; +import { Effect, Layer } from "effect"; + +import { legacyMakePlatformApi } from "./legacy-platform-api.layer.ts"; +import { LegacyPlatformApi } from "./legacy-platform-api.service.ts"; +import { LegacyPlatformApiFactory } from "./legacy-platform-api-factory.service.ts"; + +type LegacyPlatformApiDeps = + typeof legacyMakePlatformApi extends Effect.Effect ? R : never; + +/** + * Captures the surrounding Management API context without resolving an access + * token. The raw fetch client is provided here so `legacyMakePlatformApi` owns + * the single typed-API debug wrapper. + */ +export const legacyPlatformApiFactoryLayer = Layer.effect( + LegacyPlatformApiFactory, + Effect.gen(function* () { + const context = yield* Effect.context(); + const make = yield* legacyMakePlatformApi.pipe(Effect.provideContext(context), Effect.cached); + + return LegacyPlatformApiFactory.of({ + make, + }); + }), +).pipe(Layer.provide(FetchHttpClient.layer)); + +/** + * Adapts an already-built eager `LegacyPlatformApi` into a factory. Use this in + * runtimes that intentionally require Management API auth up front but still + * need to satisfy services that consume the lazy factory shape. + */ +export const legacyPlatformApiFactoryFromApiLayer = Layer.effect( + LegacyPlatformApiFactory, + LegacyPlatformApi.pipe( + Effect.map((api) => + LegacyPlatformApiFactory.of({ + make: Effect.succeed(api), + }), + ), + ), +); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api-factory.service.ts b/apps/cli/src/legacy/auth/legacy-platform-api-factory.service.ts new file mode 100644 index 0000000000..7e5314060f --- /dev/null +++ b/apps/cli/src/legacy/auth/legacy-platform-api-factory.service.ts @@ -0,0 +1,26 @@ +import type { ApiClient, SupabaseApiConfigError } from "@supabase/api/effect"; +import { type Effect, Context } from "effect"; + +import type { + LegacyInvalidAccessTokenError, + LegacyPlatformAuthRequiredError, +} from "./legacy-errors.ts"; + +/** + * Lazy accessor for the typed Management API client. + * + * Unlike `LegacyPlatformApi`, whose layer resolves an access token when the + * command runtime is built, `make` defers client construction until a command + * branch actually reaches a Management API call. + */ +export interface LegacyPlatformApiFactoryShape { + readonly make: Effect.Effect< + ApiClient, + LegacyInvalidAccessTokenError | LegacyPlatformAuthRequiredError | SupabaseApiConfigError + >; +} + +export class LegacyPlatformApiFactory extends Context.Service< + LegacyPlatformApiFactory, + LegacyPlatformApiFactoryShape +>()("supabase/legacy/PlatformApiFactory") {} diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts index 45a5f70755..e167c59dcf 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.ts @@ -62,7 +62,7 @@ function isEphemeralIdentityRuntime(runtime: { return runtime.isCi || (runtime.isFirstRun && !runtime.isTty); } -const makeLegacyPlatformApiServices = Effect.gen(function* () { +export const legacyMakePlatformApi = Effect.gen(function* () { const cliConfig = yield* LegacyCliConfig; const credentials = yield* LegacyCredentials; const analytics = yield* Analytics; @@ -156,7 +156,7 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { ); } - const api = yield* makeApiClient( + return yield* makeApiClient( { baseUrl: cliConfig.apiUrl, accessToken: storedToken.value, @@ -166,7 +166,6 @@ const makeLegacyPlatformApiServices = Effect.gen(function* () { transformClient, }, ); - return Layer.succeed(LegacyPlatformApi, api); }); -export const legacyPlatformApiLayer = Layer.unwrap(makeLegacyPlatformApiServices); +export const legacyPlatformApiLayer = Layer.effect(LegacyPlatformApi, legacyMakePlatformApi); diff --git a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts index 24ff6c6b6c..34a8c00709 100644 --- a/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts +++ b/apps/cli/src/legacy/auth/legacy-platform-api.layer.unit.test.ts @@ -32,6 +32,7 @@ function mockCliConfig(opts: { profile: opts.profile ?? "supabase", apiUrl: opts.apiUrl ?? "https://api.supabase.com", projectHost: opts.projectHost ?? "supabase.co", + poolerHost: "supabase.com", accessToken: opts.accessToken === undefined ? Option.none() : Option.some(Redacted.make(opts.accessToken)), projectId: Option.none(), diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.ts index eff232709e..f172ff80dc 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.ts @@ -125,50 +125,3 @@ export function marshalDotEnv(env: Readonly>): string { lines.sort(); return lines.join("\n"); } - -// godotenv.Parse-compatible enough for `.env.example`: `KEY=VALUE` / `KEY="VALUE"` -// lines, `#` comments, blank lines. A line with an empty / invalid variable name -// throws (Go's `godotenv.Parse` surfaces `unexpected character ... in variable name`). -const EXPORT_PREFIX = /^\s*export\s+/; - -/** - * Minimal godotenv parser for `.env.example`. Returns the parsed key/value map. - * Throws an `Error` whose message mirrors Go's parser for a malformed variable - * name so the caller can surface the same failure (`"!="` → unexpected character). - */ -export function parseDotEnv(contents: string): Record { - const result: Record = {}; - for (const rawLine of contents.split("\n")) { - const line = rawLine.replace(EXPORT_PREFIX, "").trim(); - if (line.length === 0 || line.startsWith("#")) { - continue; - } - const eq = line.indexOf("="); - if (eq <= 0) { - const offending = line.slice(0, eq < 0 ? line.length : eq + 1); - throw new Error( - `unexpected character "${line[0] ?? ""}" in variable name near "${offending}"`, - ); - } - const key = line.slice(0, eq).trim(); - if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(key)) { - throw new Error(`unexpected character "${key[0] ?? ""}" in variable name near "${line}"`); - } - let value = line.slice(eq + 1).trim(); - if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) { - // godotenv expands escapes inside double-quoted values: `\n` / `\r` become - // real newlines, and a backslash before any other char (except `$`) is - // dropped (`\"` -> `"`, `\\` -> `\`). - value = value - .slice(1, -1) - .replaceAll("\\n", "\n") - .replaceAll("\\r", "\r") - .replace(/\\([^$])/g, "$1"); - } else if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) { - // Single-quoted values are taken literally (no escape expansion). - value = value.slice(1, -1); - } - result[key] = value; - } - return result; -} diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.unit.test.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.unit.test.ts index a4c8b16a4a..77d59c00b9 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.unit.test.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.dotenv.unit.test.ts @@ -1,7 +1,7 @@ import type { ApiKeyResponse } from "@supabase/api/effect"; import { describe, expect, it } from "vitest"; -import { buildDotEnv, marshalDotEnv, parseDotEnv } from "./bootstrap.dotenv.ts"; +import { buildDotEnv, marshalDotEnv } from "./bootstrap.dotenv.ts"; import type { LegacyDbConfig } from "./bootstrap.pgconfig.ts"; type ApiKey = typeof ApiKeyResponse.Type; @@ -94,28 +94,3 @@ describe("marshalDotEnv", () => { ); }); }); - -describe("parseDotEnv", () => { - it("parses KEY=VALUE lines, skipping comments and blanks, and strips quotes", () => { - expect(parseDotEnv('# comment\nFOO=bar\n\nBAZ="quoted"\nexport QUX=1')).toEqual({ - FOO: "bar", - BAZ: "quoted", - QUX: "1", - }); - }); - - it("expands escape sequences in double-quoted values (godotenv parity)", () => { - expect(parseDotEnv('A="line1\\nline2"\nB="a\\"b\\\\c"')).toEqual({ - A: "line1\nline2", - B: 'a"b\\c', - }); - }); - - it("takes single-quoted values literally (no escape expansion)", () => { - expect(parseDotEnv("A='line1\\nline2'")).toEqual({ A: "line1\\nline2" }); - }); - - it("throws Go's 'unexpected character' error on a malformed variable name", () => { - expect(() => parseDotEnv("!=")).toThrow(/unexpected character "!" in variable name/); - }); -}); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts index 4491efad22..ce94918852 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.handler.ts @@ -18,8 +18,9 @@ import { legacyLinkServicesCore } from "../../shared/legacy-link-services-core.t import { legacyProjectCreateCore } from "../../shared/legacy-project-create-core.ts"; import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; import { legacyExtractServiceKeys } from "../../shared/legacy-tenant-keys.ts"; +import { parseDotEnv } from "../../shared/legacy-dotenv.ts"; import { initProject } from "../../../shared/init/project-init.ts"; -import { buildDotEnv, marshalDotEnv, parseDotEnv } from "./bootstrap.dotenv.ts"; +import { buildDotEnv, marshalDotEnv } from "./bootstrap.dotenv.ts"; import { LegacyBootstrapHealthError, LegacyBootstrapInvalidTemplateError, @@ -175,6 +176,7 @@ export const legacyBootstrap = Effect.fn("legacy.bootstrap")(function* ( dbPassword: seededPassword, region: undefined, size: undefined, + highAvailability: undefined, templateUrl: starter.url.length > 0 ? starter.url : undefined, emitStructuredResult: false, }); diff --git a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts index 985b88ee2b..ea52ed4196 100644 --- a/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts +++ b/apps/cli/src/legacy/commands/bootstrap/bootstrap.layers.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; +import { legacyPlatformApiFactoryFromApiLayer } from "../../auth/legacy-platform-api-factory.layer.ts"; import { legacyPlatformApiLayer } from "../../auth/legacy-platform-api.layer.ts"; import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; import { legacyProjectRefLayer } from "../../config/legacy-project-ref.layer.ts"; @@ -42,13 +43,15 @@ const platformApi = legacyPlatformApiLayer.pipe( Layer.provide(httpClient), Layer.provide(debugLogger), ); +const platformApiFactory = legacyPlatformApiFactoryFromApiLayer.pipe(Layer.provide(platformApi)); export const legacyBootstrapRuntimeLayer = Layer.mergeAll( platformApi, + platformApiFactory, httpClient, credentials, cliConfig, - legacyProjectRefLayer.pipe(Layer.provide(platformApi), Layer.provide(cliConfig)), + legacyProjectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliConfig)), legacyLinkedProjectCacheLayer.pipe( Layer.provide(credentials), Layer.provide(cliConfig), diff --git a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md index 323ecedd05..f027cb07d8 100644 --- a/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/db/pull/SIDE_EFFECTS.md @@ -52,5 +52,5 @@ Not applicable. - Optional positional argument sets the migration name (defaults to `remote_schema`). - `--schema` / `-s` restricts pull to specific schemas. - `--db-url`, `--linked` (default true), and `--local` are mutually exclusive. -- `--use-pg-delta` activates declarative pull output through pg-delta. -- `--diff-engine migra|pg-delta` selects the diff engine for migration-style pull; mutually exclusive with `--use-pg-delta`. +- `--declarative` activates declarative pull output through pg-delta (writes `./database` files instead of a migration). `--use-pg-delta` is a deprecated alias. +- `--diff-engine migra|pg-delta` selects the diff engine for migration-style pull; mutually exclusive with `--declarative` / `--use-pg-delta`. When the flag is omitted, the engine defaults to pg-delta if `[experimental.pgdelta] enabled = true` in `config.toml` (or `EXPERIMENTAL_PG_DELTA`), otherwise migra. An explicit `--diff-engine migra` always forces migra. Enabling pg-delta in config does not switch `db pull` to declarative output. diff --git a/apps/cli/src/legacy/commands/db/pull/pull.command.ts b/apps/cli/src/legacy/commands/db/pull/pull.command.ts index 16e2ad511e..ba773ecbe8 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.command.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.command.ts @@ -7,8 +7,15 @@ const config = { Argument.withDescription("Optional name for the migration file."), Argument.optional, ), + declarative: Flag.boolean("declarative").pipe( + Flag.withDescription( + "Pull schema as declarative files using pg-delta instead of creating a migration.", + ), + ), usePgDelta: Flag.boolean("use-pg-delta").pipe( - Flag.withDescription("Use pg-delta to pull declarative schema."), + Flag.withDescription( + "Deprecated alias for --declarative. Use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead.", + ), ), diffEngine: Flag.choice("diff-engine", ["migra", "pg-delta"] as const).pipe( Flag.withDescription("Diff engine to use for migration-style db pull."), 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 c1ab9564c6..01a925035d 100644 --- a/apps/cli/src/legacy/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/legacy/commands/db/pull/pull.handler.ts @@ -6,6 +6,7 @@ export const legacyDbPull = Effect.fn("legacy.db.pull")(function* (flags: Legacy const proxy = yield* LegacyGoProxy; const args: string[] = ["db", "pull"]; if (Option.isSome(flags.name)) args.push(flags.name.value); + if (flags.declarative) args.push("--declarative"); if (flags.usePgDelta) args.push("--use-pg-delta"); if (Option.isSome(flags.diffEngine)) args.push("--diff-engine", flags.diffEngine.value); for (const s of flags.schema) { diff --git a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md index dbfd1dcf48..90660186db 100644 --- a/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/domains/SIDE_EFFECTS.md @@ -98,4 +98,5 @@ suppressed on stderr. `delete` ignores `-o`. - `--include-raw-output` is declared as a normal boolean **on each subcommand** (Go declares it as a persistent flag on the `domains` group). Two consequences: (a) it must appear after the subcommand name (`domains get --include-raw-output`) rather than before it (`domains --include-raw-output get`), matching how `--project-ref` is already handled shell-wide; (b) it cannot reproduce Cobra's help-hiding or the `Flag --include-raw-output has been deprecated` stderr warning, which Effect CLI has no hook for. It still reproduces the behavioral effect (forces `-o json` when `-o` is unset/pretty); on `delete` it is inert, matching Go. - `-o json|yaml|toml|env` encode the decoded snake_case response, not Go's PascalCase struct keys (consistent with `backups list` / `sso add`). - The degenerate `validation_records != 1` status message approximates Go's `%+v` struct dump (which embeds a non-deterministic pointer address). + - Text-mode status output is newline-terminated even for Go's `Fprintf` branches. Without the final newline, interactive shell prompts can redraw over the last status line, hiding the ACME TXT record. - In a structured `-o` mode the human status is suppressed on stderr. Go technically still writes `PrintStatus` to stderr, but the `5_*`/`4_*` messages carry no trailing newline, so they fuse with Go's version-update notice and are stripped together by the e2e normalizer — making Go's observable machine-output stderr empty. Suppressing keeps stdout clean and matches the parity contract. diff --git a/apps/cli/src/legacy/commands/domains/domains.emit.ts b/apps/cli/src/legacy/commands/domains/domains.emit.ts index 23c0691a21..f293394a84 100644 --- a/apps/cli/src/legacy/commands/domains/domains.emit.ts +++ b/apps/cli/src/legacy/commands/domains/domains.emit.ts @@ -10,40 +10,52 @@ import { } from "../../shared/legacy-go-output.encoders.ts"; import { formatHostnameStatus, type LegacyHostnameResponse } from "./domains.format.ts"; -function normalizeLegacyHostnameResponse(response: LegacyHostnameResponse): LegacyHostnameResponse { - if (response.data.result.ssl.validation_records !== undefined) { - return response; - } +function normalizeLegacyHostnameResponse( + response: LegacyHostnameResponse, +): Record { return { ...response, + status: response.status ?? "", + custom_hostname: response.custom_hostname ?? "", data: { ...response.data, result: { ...response.data.result, + ownership_verification: response.data.result.ownership_verification ?? { + type: "", + name: "", + value: "", + }, ssl: { ...response.data.result.ssl, - validation_records: [], + validation_records: response.data.result.ssl.validation_records ?? [], }, }, }, }; } +function terminateHumanStatus(status: string): string { + if (status === "" || status.endsWith("\n")) { + return status; + } + return `${status}\n`; +} + /** * Emit a custom-hostname response across all output modes, mirroring the Go * subcommands (`apps/cli-go/internal/hostnames/{get,create,activate,reverify}`): * * - In `pretty`/text mode the human status text goes to **stderr** (Go's - * `PrintStatus`), and nothing goes to stdout. + * `PrintStatus`), and nothing goes to stdout. Unlike Go's no-newline + * `Fprintf` branches, the final human status is newline-terminated so an + * interactive shell prompt cannot redraw over the last line. * - In a structured Go `-o` mode (`json`/`yaml`/`toml`/`env`) the encoded * response goes to **stdout** and the human status is **suppressed**. Go - * technically still writes `PrintStatus` to stderr here, but because the - * `5_services_reconfigured`/`4_origin_setup_completed` messages carry no - * trailing newline they get fused with — and stripped alongside — Go's - * version-update notice (see `normalize.ts` rule 11), so the observable Go - * stderr in machine-output mode is empty. Suppressing keeps stdout clean and - * matches that contract (verified by the `domains get --output json` parity - * e2e). + * technically still writes `PrintStatus` to stderr here. Suppressing keeps + * stdout/stderr stable for machine consumers; the parity e2e opts in to + * normalizing Go's stderr-only status instead of depending on upgrade-check + * output to erase it. * - `--include-raw-output` (deprecated) forces `-o` to `json` when it is unset * or `pretty`. * - For the TS-native `--output-format json|stream-json` modes (no Go `-o`), @@ -55,35 +67,34 @@ export const emitLegacyHostnameResult = Effect.fnUntraced(function* ( ) { const output = yield* Output; const goOutputFlag = yield* LegacyOutputFlag; - const normalizedResponse = normalizeLegacyHostnameResponse(response); const goFmt = Option.getOrUndefined(goOutputFlag); const effectiveGoFmt = includeRawOutput && (goFmt === undefined || goFmt === "pretty") ? "json" : goFmt; if (effectiveGoFmt === "json") { - yield* output.raw(encodeGoJson(normalizedResponse)); + yield* output.raw(encodeGoJson(normalizeLegacyHostnameResponse(response))); return; } if (effectiveGoFmt === "yaml") { - yield* output.raw(encodeYaml(normalizedResponse)); + yield* output.raw(encodeYaml(normalizeLegacyHostnameResponse(response))); return; } if (effectiveGoFmt === "toml") { - yield* output.raw(encodeToml(normalizedResponse) + "\n"); + yield* output.raw(encodeToml(normalizeLegacyHostnameResponse(response)) + "\n"); return; } if (effectiveGoFmt === "env") { - yield* output.raw(encodeEnv(normalizedResponse) + "\n"); + yield* output.raw(encodeEnv(normalizeLegacyHostnameResponse(response)) + "\n"); return; } // goFmt is undefined or "pretty" — defer to the TS --output-format mode. if (output.format === "json" || output.format === "stream-json") { - yield* output.success("", normalizedResponse); + yield* output.success("", normalizeLegacyHostnameResponse(response)); return; } // text mode (Go pretty parity): status to stderr, nothing to stdout. - yield* output.raw(formatHostnameStatus(normalizedResponse), "stderr"); + yield* output.raw(terminateHumanStatus(formatHostnameStatus(response)), "stderr"); }); diff --git a/apps/cli/src/legacy/commands/domains/domains.format.ts b/apps/cli/src/legacy/commands/domains/domains.format.ts index b6277cdf49..fdbfab3872 100644 --- a/apps/cli/src/legacy/commands/domains/domains.format.ts +++ b/apps/cli/src/legacy/commands/domains/domains.format.ts @@ -9,6 +9,24 @@ export type LegacyHostnameResponse = typeof V1GetHostnameConfigOutput.Type; type LegacyHostnameSsl = LegacyHostnameResponse["data"]["result"]["ssl"]; +type LegacyHostnameStatus = Exclude; + +function getHostnameStatus(response: LegacyHostnameResponse): LegacyHostnameStatus | undefined { + if (response.status !== undefined) { + return response.status; + } + const result = response.data.result; + if ( + result.status === "pending" || + result.ssl.status === "initializing" || + result.ssl.validation_records !== undefined || + result.ownership_verification !== undefined + ) { + return "2_initiated"; + } + return undefined; +} + /** * Byte-for-byte port of Go's `hostnames.PrintStatus` * (`apps/cli-go/internal/hostnames/common.go:24-59`). Returns the exact string @@ -16,7 +34,7 @@ type LegacyHostnameSsl = LegacyHostnameResponse["data"]["result"]["ssl"]; * `Fprintln` (adds `\n`) and `Fprintf` (does not). */ export function formatHostnameStatus(response: LegacyHostnameResponse): string { - switch (response.status) { + switch (getHostnameStatus(response)) { case "5_services_reconfigured": // Fprintf — no trailing newline. return `Custom hostname setup completed. Project is now accessible at ${response.custom_hostname}.`; diff --git a/apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts b/apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts index d7e7ae04cb..d5cee7459c 100644 --- a/apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/domains/domains.format.unit.test.ts @@ -6,11 +6,11 @@ import { type LegacyHostnameResponse, } from "./domains.format.ts"; -type Status = LegacyHostnameResponse["status"]; +type Status = Exclude; type Ssl = LegacyHostnameResponse["data"]["result"]["ssl"]; function makeResponse(args: { - readonly status: Status; + readonly status?: Status; readonly customHostname?: string; readonly customOriginServer?: string; readonly ssl: Ssl; @@ -77,6 +77,17 @@ describe("formatHostnameStatus", () => { ); }); + it("infers in-progress status from sparse processing responses", () => { + const out = formatHostnameStatus( + makeResponse({ + ssl: { status: "initializing" }, + }), + ); + expect(out).toBe( + "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n", + ); + }); + it("short-circuits to a CAA mismatch hint when a validation error mentions caa_error", () => { const out = formatHostnameStatus( makeResponse({ diff --git a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts index 5f01adb681..45cbd2ba3b 100644 --- a/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/domains/get/get.integration.test.ts @@ -106,13 +106,17 @@ describe("legacy domains get integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("preserves validation_records in Go JSON output when the API omits it", () => { + it.live("backfills Go zero values in JSON output when the API omits nested fields", () => { + const { + ownership_verification: _ownershipVerification, + ...resultWithoutOwnershipVerification + } = HOSTNAME_RESPONSE.data.result; const response: typeof V1GetHostnameConfigOutput.Type = { ...HOSTNAME_RESPONSE, data: { ...HOSTNAME_RESPONSE.data, result: { - ...HOSTNAME_RESPONSE.data.result, + ...resultWithoutOwnershipVerification, ssl: { status: "pending_validation" }, }, }, @@ -121,10 +125,113 @@ describe("legacy domains get integration", () => { return Effect.gen(function* () { yield* legacyDomainsGet(baseFlags); const parsed = JSON.parse(out.stdoutText) as typeof V1GetHostnameConfigOutput.Type; + expect(parsed.data.result.ownership_verification).toEqual({ type: "", name: "", value: "" }); expect(parsed.data.result.ssl.validation_records).toEqual([]); }).pipe(Effect.provide(layer)); }); + it.live("backfills Go zero values in JSON output when the API omits envelope fields", () => { + const { + status: _status, + custom_hostname: _customHostname, + ...responseWithoutEnvelope + } = HOSTNAME_RESPONSE; + const { + ownership_verification: _ownershipVerification, + ...resultWithoutOwnershipVerification + } = HOSTNAME_RESPONSE.data.result; + const response: typeof V1GetHostnameConfigOutput.Type = { + ...responseWithoutEnvelope, + data: { + ...HOSTNAME_RESPONSE.data, + result: { + ...resultWithoutOwnershipVerification, + ssl: { status: "initializing" }, + status: "pending", + }, + }, + }; + const { layer, out } = setup({ goOutput: "json", response }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + const parsed = JSON.parse(out.stdoutText) as Record; + expect(parsed.status).toBe(""); + expect(parsed.custom_hostname).toBe(""); + expect(parsed.data).toMatchObject({ + result: { + ownership_verification: { type: "", name: "", value: "" }, + ssl: { status: "initializing", validation_records: [] }, + status: "pending", + }, + }); + }).pipe(Effect.provide(layer)); + }); + + it.live("prints processing guidance in text mode when the API omits envelope fields", () => { + const { + status: _status, + custom_hostname: _customHostname, + ...responseWithoutEnvelope + } = HOSTNAME_RESPONSE; + const response: typeof V1GetHostnameConfigOutput.Type = { + ...responseWithoutEnvelope, + data: { + ...HOSTNAME_RESPONSE.data, + result: { + ...HOSTNAME_RESPONSE.data.result, + ssl: { status: "initializing" }, + status: "pending", + }, + }, + }; + const { layer, out } = setup({ response }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stderrText).toBe( + "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n", + ); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("prints outstanding ACME validation records in text mode", () => { + const response: typeof V1GetHostnameConfigOutput.Type = { + status: "2_initiated", + custom_hostname: "sbstg4.thewheatfield.org", + data: { + success: true, + errors: [], + messages: [], + result: { + id: "bd8d3485-bcfb-41cd-a094-ccec2af6be48", + hostname: "sbstg4.thewheatfield.org", + ownership_verification: { name: "", type: "", value: "" }, + custom_origin_server: "coekrxjvyzzhmchfbwzr.supabase.red", + status: "active", + ssl: { + status: "pending_validation", + validation_records: [ + { + txt_name: "_acme-challenge.sbstg4.thewheatfield.org", + txt_value: "i6XyXv3kU4SRX9YcCE8h4LExoHE6y_poV1-5R1cjpk4", + }, + ], + }, + }, + }, + }; + const { layer, out } = setup({ response }); + return Effect.gen(function* () { + yield* legacyDomainsGet(baseFlags); + expect(out.stderrText).toBe( + "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\n" + + "Required outstanding validation records:\n" + + "\t_acme-challenge.sbstg4.thewheatfield.org TXT -> i6XyXv3kU4SRX9YcCE8h4LExoHE6y_poV1-5R1cjpk4\n", + ); + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + it.live("emits YAML to stdout for -o yaml", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/gen/types/types.command.ts b/apps/cli/src/legacy/commands/gen/types/types.command.ts index 637b9107d5..c5f6bd71f5 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.command.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.command.ts @@ -1,9 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; -import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyGenTypes } from "./types.handler.ts"; +import { legacyGenTypesRuntimeLayer } from "./types.layers.ts"; const LANG_VALUES = ["typescript", "go", "swift", "python"] as const; const SWIFT_ACCESS_CONTROL_VALUES = ["internal", "public"] as const; @@ -74,5 +74,5 @@ export const legacyGenTypesCommand = Command.make("types", config).pipe( withJsonErrorHandling, ), ), - Command.provide(legacyManagementApiRuntimeLayer(["gen", "types"])), + Command.provide(legacyGenTypesRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 6e6e5f9f1e..c9bd07fb86 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -3,21 +3,23 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; import { LegacyDebugFlag, LegacyNetworkIdFlag } from "../../../../shared/legacy/global-flags.ts"; import { Output } from "../../../../shared/output/output.service.ts"; -import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; import { LegacyProjectNotLinkedError } from "../../../config/legacy-project-ref.errors.ts"; -import { PROJECT_NOT_LINKED_MESSAGE } from "../../../config/legacy-project-ref.service.ts"; -import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { + LegacyProjectRefResolver, + PROJECT_NOT_LINKED_MESSAGE, +} from "../../../config/legacy-project-ref.service.ts"; import { mapLegacyHttpError } from "../../../shared/legacy-http-errors.ts"; import { legacyTempPaths } from "../../../shared/legacy-temp-paths.ts"; import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { LegacyGenTypesNetworkError, LegacyGenTypesUnexpectedStatusError } from "./types.errors.ts"; +import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; import { buildPostgresUrl, defaultSchemas, - getServicesHostname, localDbContainerId, localDbPassword, localNetworkId, @@ -159,10 +161,7 @@ function hasExplicitLongFlag(rawArgs: ReadonlyArray, flagName: string): export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: LegacyGenTypesFlags) { const output = yield* Output; - const api = yield* LegacyPlatformApi; const cliConfig = yield* LegacyCliConfig; - const resolver = yield* LegacyProjectRefResolver; - const linkedProjectCache = yield* LegacyLinkedProjectCache; const telemetryState = yield* LegacyTelemetryState; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -171,6 +170,9 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le const debug = yield* LegacyDebugFlag; const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const rawArgs = yield* stdio.args; + const platformApi = yield* LegacyPlatformApiFactory; + const projectRef = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; yield* ensureMutuallyExclusive( ["local", "linked", "project-id", "db-url"], @@ -235,6 +237,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le ); } + const api = yield* platformApi.make; const response = yield* api.v1 .generateTypescriptTypes({ ref: projectRef, @@ -400,7 +403,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le }), host: "db", port: 5432, - probeHost: getServicesHostname(), + probeHost: legacyGetHostname(), probePort: loaded.config.db.port, networkMode: localNetworkId(projectId), includedSchemas, @@ -432,7 +435,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if (flags.linked) { const loaded = yield* loadConfig(); - const ref = yield* resolver.resolve(Option.none()); + const ref = yield* projectRef.resolve(Option.none()); yield* runProjectTypes( ref, schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), @@ -442,7 +445,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le if (Option.isSome(flags.projectId)) { const loaded = yield* loadConfig(); - const ref = yield* resolver.resolve(flags.projectId); + const ref = yield* projectRef.resolve(flags.projectId); yield* runProjectTypes( ref, schemas.length > 0 ? schemas : defaultSchemas(loaded?.config.api.schemas), @@ -450,7 +453,7 @@ export const legacyGenTypes = Effect.fn("legacy.gen.types")(function* (flags: Le return; } - const resolvedRef = yield* resolver.resolve(Option.none()).pipe( + const resolvedRef = yield* projectRef.resolve(Option.none()).pipe( Effect.catch((cause) => { if ( cause instanceof LegacyProjectNotLinkedError && diff --git a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts index 6dc7852152..87ad983e86 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.integration.test.ts @@ -5,13 +5,24 @@ import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { ChildProcessSpawner } from "effect/unstable/process"; +import { CliOutput, Command } from "effect/unstable/cli"; import { Deferred, Effect, Exit, Layer, Option, Sink, Stdio, Stream } from "effect"; import { + LEGACY_GLOBAL_FLAGS, LegacyDebugFlag, LegacyNetworkIdFlag, LegacyOutputFlag, } from "../../../../shared/legacy/global-flags.ts"; -import { mockOutput, mockProcessControl } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { + mockAnalytics, + mockOutput, + mockProcessControl, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../../tests/helpers/mocks.ts"; import { buildLegacyTestRuntime, LEGACY_VALID_REF, @@ -21,6 +32,9 @@ import { mockLegacyTelemetryStateTracked, } from "../../../../../tests/helpers/legacy-mocks.ts"; import { mockChildProcessSpawner } from "../../../../../../../packages/process-compose/tests/helpers/mocks.ts"; +import { textCliOutputFormatter } from "../../../../shared/output/text-formatter.ts"; +import { TelemetryRuntime } from "../../../../shared/telemetry/runtime.service.ts"; +import { legacyGenCommand } from "../gen.command.ts"; import type { LegacyGenTypesFlags } from "./types.command.ts"; import { legacyGenTypes } from "./types.handler.ts"; import { parseQueryTimeoutSeconds, resolvePgmetaImage } from "./types.shared.ts"; @@ -169,6 +183,9 @@ function setup( Layer.succeed(LegacyOutputFlag, opts.goOutput ?? Option.none()), Layer.succeed(LegacyDebugFlag, opts.debug ?? false), Layer.succeed(LegacyNetworkIdFlag, opts.networkId ?? Option.none()), + Layer.succeed(LegacyPlatformApiFactory, { + make: LegacyPlatformApi.pipe(Effect.provide(api.layer)), + }), ); return { @@ -275,6 +292,11 @@ async function withSslProbeServer( } } +const legacyTestRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyGenCommand]), +); + describe("legacy gen types", () => { it.effect("accepts Go-style microsecond duration aliases", () => Effect.gen(function* () { @@ -283,6 +305,83 @@ describe("legacy gen types", () => { }), ); + it.live("runs tokenless local generation through command wiring", () => + Effect.tryPromise({ + try: () => + withSslProbeServer(async (port) => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-command-local-")); + writeConfig( + workdir, + [ + 'project_id = "demo"', + "", + "[api]", + 'schemas = ["public"]', + "", + "[db]", + `port = ${port}`, + ].join("\n"), + ); + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockAnalytics(); + const child = mockSequentialChildProcessSpawner([ + { exitCode: 0 }, + { exitCode: 0, stdout: ["export type Database = {};"] }, + ]); + const args = [ + "gen", + "types", + "typescript", + "--local", + "--schema", + "public", + "--workdir", + workdir, + ]; + const layer = Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + analytics.layer, + processEnvLayer({ SUPABASE_HOME: workdir }), + mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + child.layer, + Stdio.layerTest({ args: Effect.succeed(args) }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: join(workdir, ".supabase"), + tracesDir: join(workdir, ".supabase", "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + distinctId: undefined, + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + + await Effect.runPromise( + Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(args).pipe( + Effect.provide(layer), + ) as Effect.Effect, + ); + + expect(out.stdoutText).toContain("export type Database = {};"); + expect(out.stderrText).not.toContain("Access token not provided"); + expect(child.spawned).toHaveLength(2); + }), + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + it.live("generates typescript types from a project ref", () => { const { layer, out, api, linkedProjectCache, telemetry } = setup({ projectId: Option.some(LEGACY_VALID_REF), diff --git a/apps/cli/src/legacy/commands/gen/types/types.layers.ts b/apps/cli/src/legacy/commands/gen/types/types.layers.ts new file mode 100644 index 0000000000..1891ddf35f --- /dev/null +++ b/apps/cli/src/legacy/commands/gen/types/types.layers.ts @@ -0,0 +1,63 @@ +import { Layer } from "effect"; + +import { legacyCredentialsLayer } from "../../../auth/legacy-credentials.layer.ts"; +import { legacyPlatformApiFactoryLayer } from "../../../auth/legacy-platform-api-factory.layer.ts"; +import { LegacyPlatformApiFactory } from "../../../auth/legacy-platform-api-factory.service.ts"; +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { legacyProjectRefLayer } from "../../../config/legacy-project-ref.layer.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyHttpClientLayer } from "../../../auth/legacy-http-debug.layer.ts"; +import { legacyLinkedProjectCacheLayer } from "../../../telemetry/legacy-linked-project-cache.layer.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; +import { CommandRuntime } from "../../../../shared/runtime/command-runtime.service.ts"; + +/** + * `gen types --local` and `--db-url` do not use the Management API, so this + * runtime deliberately avoids `legacyManagementApiRuntimeLayer`: that layer + * eagerly builds the platform API client and requires an access token before + * the handler can choose the local/db-url branch. + */ +export const legacyGenTypesRuntimeLayer = (() => { + const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + ); + const platformApiFactory = legacyPlatformApiFactoryLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + ); + + const built = Layer.mergeAll( + cliConfig, + platformApiFactory, + legacyProjectRefLayer.pipe(Layer.provide(platformApiFactory), Layer.provide(cliConfig)), + legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + ), + legacyTelemetryStateLayer, + commandRuntimeLayer(["gen", "types"]), + ); + + const _serviceCoverageCheck: Layer.Layer = built; + void _serviceCoverageCheck; + + return built; +})(); + +type LegacyGenTypesServices = + | LegacyPlatformApiFactory + | LegacyCliConfig + | LegacyProjectRefResolver + | LegacyLinkedProjectCache + | LegacyTelemetryState + | CommandRuntime; diff --git a/apps/cli/src/legacy/commands/gen/types/types.shared.ts b/apps/cli/src/legacy/commands/gen/types/types.shared.ts index a84c2abfd0..ab83824b99 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.shared.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.shared.ts @@ -117,10 +117,6 @@ export function parseQueryTimeoutSeconds( }); } -export function getServicesHostname() { - return process.env["SUPABASE_SERVICES_HOSTNAME"] || "127.0.0.1"; -} - /** * The default generated docker network name for a local project (Go's `utils.NetId` * fallback, `GetId("network")`). The `--network-id` override is applied at the docker diff --git a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts index 52852fa931..3145cbb08f 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.unit.test.ts @@ -1,10 +1,10 @@ import { createServer, type Server, type Socket } from "node:net"; import { describe, expect, it } from "@effect/vitest"; import { Effect, Exit } from "effect"; +import { legacyGetHostname } from "../../../shared/legacy-hostname.ts"; import { buildPostgresUrl, defaultSchemas, - getServicesHostname, legacyRootCaBundle, localDbContainerId, localDbPassword, @@ -209,10 +209,12 @@ describe("schema and id helpers", () => { }); it("reads the services hostname and db password from the environment", () => { - expect(withEnv("SUPABASE_SERVICES_HOSTNAME", undefined, () => getServicesHostname())).toBe( - "127.0.0.1", - ); - expect(withEnv("SUPABASE_SERVICES_HOSTNAME", "db.internal", () => getServicesHostname())).toBe( + expect( + withEnv("DOCKER_HOST", undefined, () => + withEnv("SUPABASE_SERVICES_HOSTNAME", undefined, () => legacyGetHostname()), + ), + ).toBe("127.0.0.1"); + expect(withEnv("SUPABASE_SERVICES_HOSTNAME", "db.internal", () => legacyGetHostname())).toBe( "db.internal", ); expect(withEnv("SUPABASE_DB_PASSWORD", undefined, () => localDbPassword())).toBe("postgres"); diff --git a/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md new file mode 100644 index 0000000000..f5728c86be --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/SIDE_EFFECTS.md @@ -0,0 +1,121 @@ +# `supabase inspect db ` + +Single shared side-effect document for all 13 active `inspect db` subcommands and +their 12 deprecated aliases. Every subcommand has the same surface — it resolves a +Postgres connection from `--db-url` / `--linked` / `--local`, runs one read-only +`SELECT`, and renders the result as a Glamour ASCII table. They differ only in the +SQL run and the columns rendered (see the per-subcommand `.query.ts`). + +## Files Read + +| Path | Format | When | +| -------------------------------- | ---------- | ----------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | `--local` (db host/port/password); `--linked` (project ref) | +| `~/.supabase/access-token` | plain text | `--linked` only, lazily, when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `.pgpass` / `pg_service.conf` | libpq | only if referenced by a `--db-url` connection string | +| `$PGSSLROOTCERT` CA bundle | PEM | only if a `--db-url` sets `sslrootcert` / `PGSSLROOTCERT` | + +Connection resolution and all of the above are handled inside the already-ported +`LegacyDbConfigResolver` (`legacy/shared/legacy-db-config.layer.ts`); this port adds +no new config reads. + +## Files Written + +| Path | Format | When | +| ---------------------------- | ------ | ----------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always, post-run (`LegacyTelemetryState.flush`) | + +## API Routes (MAY fire on `--linked`, inside the resolver) + +| Method | Path | Auth | When | +| -------- | ----------------------------------------- | ------------ | --------------------------------------- | +| `POST` | `/v1/projects/{ref}/login-role` | Bearer token | `--linked`, to create a temp login role | +| `GET` | pooler config endpoint | Bearer token | `--linked`, pooler fallback | +| `GET` | `/v1/projects/{ref}/network-bans` | Bearer token | `--linked`, on pooler retry | +| `DELETE` | `/v1/projects/{ref}/network-bans` (unban) | Bearer token | `--linked`, when a self-ban is detected | + +## Environment Variables + +| Variable | Purpose | Required? | +| ---------------------------------------------------- | --------------------------------- | --------------------------------------- | +| `SUPABASE_DB_PASSWORD` / `DB_PASSWORD` | database password (linked/local) | no (prompts / config fallback) | +| `SUPABASE_ACCESS_TOKEN` | Management API auth (linked only) | no (falls back to keyring / token file) | +| `PROJECT_ID` | project ref fallback (linked) | no (config resolution fallback) | +| libpq vars (`PGSSLROOTCERT`, `PGCONNECT_TIMEOUT`, …) | honored when `--db-url` is used | no | + +## Database Queries + +Each subcommand runs one read-only `SELECT` (the embedded Go `.sql`). The +5 schema-filtered queries take `$1` = the LIKE-escaped internal-schema list; +`db-stats` additionally takes `$2` = the database name. + +| Subcommand | SQL file | InternalSchemas param? | +| -------------------- | ------------------------ | --------------------------- | +| db-stats | db_stats.sql | yes (`$1`) + db name (`$2`) | +| index-stats | index_stats.sql | yes (`$1`) | +| bloat | bloat.sql | yes (`$1`) | +| vacuum-stats | vacuum_stats.sql | yes (`$1`) | +| table-stats | table_stats.sql | yes (`$1`) | +| replication-slots | replication_slots.sql | no | +| locks | locks.sql | no | +| blocking | blocking.sql | no | +| outliers | outliers.sql | no | +| calls | calls.sql | no | +| long-running-queries | long_running_queries.sql | no | +| role-stats | role_stats.sql | no | +| traffic-profile | traffic_profile.sql | no | + +Deprecated aliases run an active subcommand's query: `cache-hit`→db-stats; +`index-usage`/`total-index-size`/`index-sizes`/`unused-indexes`/`seq-scans`/`table-record-counts`→index-stats; +`table-sizes`/`table-index-sizes`/`total-table-sizes`→table-stats; +`role-configs`/`role-connections`→role-stats. (`table-record-counts` warns +"table-stats" but runs index-stats — a Go inconsistency preserved verbatim.) + +## Exit Codes + +| Code | Condition | +| ---- | ------------------------------------------------------------------ | +| `0` | success | +| `1` | mutually-exclusive flags, resolution, connection, or query failure | + +## Telemetry Events Fired + +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | ----------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` | + +Go fires no custom `phtelemetry.*` events for inspect. + +## Output + +### `--output-format text` (Go CLI compatible) + +A Glamour ASCII table (byte-exact with Go's `glamour.RenderTable(..., AsciiStyle)`): +a leading blank line, a decorative line, the header row, a dashes separator, then one +row per result. Statement/query cells (locks, blocking, outliers, calls) have their +whitespace runs collapsed to single spaces (long-running-queries' query is NOT +collapsed, matching Go). The "Connecting to local/remote database..." diagnostic is +written to **stderr** before the query runs (Go's `ConnectByConfig`). + +### `--output-format json` + +A single object: `{ "rows": [ ] }`. TS-extra — +Go has no machine output for inspect. + +### `--output-format stream-json` + +A `result` event carrying the same `{ rows }` payload. + +### Deprecated aliases + +Emit one extra stderr line before the table: +`Command "" is deprecated, use "" instead.` + +## Notes + +- The Management API stack is built lazily, only on the `--linked` path, so `--local` + and `--db-url` never require an access token. +- `--linked` defaults to `true` (Go's persistent flag default); the runner derives it + from the absence of `--db-url` / `--local` while keeping the mutual-exclusivity check + keyed off explicitly-set flags. +- All queries are read-only `SELECT`s; the command performs no writes to the database. diff --git a/apps/cli/src/legacy/commands/inspect/db/bloat/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/bloat/SIDE_EFFECTS.md deleted file mode 100644 index 80854c63d4..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/bloat/SIDE_EFFECTS.md +++ /dev/null @@ -1,44 +0,0 @@ -# `supabase inspect db bloat` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table estimating space allocated to relations that are full of dead tuples. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.command.ts b/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.command.ts index 1f510dacde..5ba5343480 100644 --- a/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.command.ts @@ -1,22 +1,14 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbBloat } from "./bloat.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbBloatFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbBloatCommand = Command.make("bloat", config).pipe( +export const legacyInspectDbBloatCommand = Command.make("bloat", LEGACY_INSPECT_DB_FLAGS).pipe( Command.withDescription("Estimates space allocated to a relation that is full of dead tuples."), Command.withShortDescription("Show relation bloat"), - Command.withHandler((flags) => legacyInspectDbBloat(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbBloat)), + Command.provide(legacyInspectDbRuntimeLayer("bloat")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.handler.ts b/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.handler.ts index c84fea5286..6689de7a56 100644 --- a/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbBloatFlags } from "./bloat.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyBloatSpec } from "./bloat.query.ts"; -export const legacyInspectDbBloat = Effect.fn("legacy.inspect.db.bloat")(function* ( - flags: LegacyInspectDbBloatFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "bloat"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbBloat = legacyMakeInspectDbHandler( + legacyBloatSpec, + "legacy.inspect.db.bloat", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.query.ts b/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.query.ts new file mode 100644 index 0000000000..cfe083ff92 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/bloat/bloat.query.ts @@ -0,0 +1,81 @@ +import { legacyInspectText, type LegacyInspectQuerySpec } from "../legacy-inspect-query.ts"; +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "../legacy-inspect-schemas.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/bloat/bloat.sql`. +const SQL = `WITH constants AS ( + SELECT current_setting('block_size')::numeric AS bs, 23 AS hdr, 4 AS ma +), bloat_info AS ( + SELECT + ma,bs,schemaname,tablename, + (datawidth+(hdr+ma-(case when hdr%ma=0 THEN ma ELSE hdr%ma END)))::numeric AS datahdr, + (maxfracsum*(nullhdr+ma-(case when nullhdr%ma=0 THEN ma ELSE nullhdr%ma END))) AS nullhdr2 + FROM ( + SELECT + schemaname, tablename, hdr, ma, bs, + SUM((1-null_frac)*avg_width) AS datawidth, + MAX(null_frac) AS maxfracsum, + hdr+( + SELECT 1+count(*)/8 + FROM pg_stats s2 + WHERE null_frac<>0 AND s2.schemaname = s.schemaname AND s2.tablename = s.tablename + ) AS nullhdr + FROM pg_stats s, constants + GROUP BY 1,2,3,4,5 + ) AS foo +), table_bloat AS ( + SELECT + schemaname, tablename, cc.relpages, bs, + CEIL((cc.reltuples*((datahdr+ma- + (CASE WHEN datahdr%ma=0 THEN ma ELSE datahdr%ma END))+nullhdr2+4))/(bs-20::float)) AS otta + FROM bloat_info + JOIN pg_class cc ON cc.relname = bloat_info.tablename + JOIN pg_namespace nn ON cc.relnamespace = nn.oid AND nn.nspname = bloat_info.schemaname + WHERE NOT nn.nspname LIKE ANY($1) +), index_bloat AS ( + SELECT + schemaname, tablename, bs, + COALESCE(c2.relname,'?') AS iname, COALESCE(c2.reltuples,0) AS ituples, COALESCE(c2.relpages,0) AS ipages, + COALESCE(CEIL((c2.reltuples*(datahdr-12))/(bs-20::float)),0) AS iotta -- very rough approximation, assumes all cols + FROM bloat_info + JOIN pg_class cc ON cc.relname = bloat_info.tablename + JOIN pg_namespace nn ON cc.relnamespace = nn.oid AND nn.nspname = bloat_info.schemaname + JOIN pg_index i ON indrelid = cc.oid + JOIN pg_class c2 ON c2.oid = i.indexrelid + WHERE NOT nn.nspname LIKE ANY($1) +), bloat_summary AS ( + SELECT + 'table' as type, + FORMAT('%I.%I', schemaname, tablename) AS name, + ROUND(CASE WHEN otta=0 THEN 0.0 ELSE table_bloat.relpages/otta::numeric END,1) AS bloat, + CASE WHEN relpages < otta THEN '0' ELSE (bs*(table_bloat.relpages-otta)::bigint)::bigint END AS raw_waste + FROM table_bloat + UNION + SELECT + 'index' as type, + FORMAT('%I.%I::%I', schemaname, tablename, iname) AS name, + ROUND(CASE WHEN iotta=0 OR ipages=0 THEN 0.0 ELSE ipages/iotta::numeric END,1) AS bloat, + CASE WHEN ipages < iotta THEN '0' ELSE (bs*(ipages-iotta))::bigint END AS raw_waste + FROM index_bloat +) +SELECT type, name, bloat, pg_size_pretty(raw_waste) as waste +FROM bloat_summary +ORDER BY raw_waste DESC, bloat DESC`; + +/** + * `inspect db bloat` — space allocated to relations full of dead tuples. + * Port of `apps/cli-go/internal/inspect/bloat/bloat.go`. Go's markdown header is + * malformed (`|Type|Name|Bloat|Waste` — missing trailing pipe); this passes the + * clean 4-header array so the table renders correctly. + */ +export const legacyBloatSpec: LegacyInspectQuerySpec = { + name: "bloat", + sql: SQL, + params: () => [legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS)], + headers: ["Type", "Name", "Bloat", "Waste"], + project: (row) => [ + legacyInspectText(row["type"]), + legacyInspectText(row["name"]), + legacyInspectText(row["bloat"]), + legacyInspectText(row["waste"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/blocking/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/blocking/SIDE_EFFECTS.md deleted file mode 100644 index 3dcfe7505b..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/blocking/SIDE_EFFECTS.md +++ /dev/null @@ -1,44 +0,0 @@ -# `supabase inspect db blocking` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table of queries that are holding locks and queries waiting for them to be released. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.command.ts b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.command.ts index 7e084c3d09..94245fa991 100644 --- a/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbBlocking } from "./blocking.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbBlockingFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbBlockingCommand = Command.make("blocking", config).pipe( +export const legacyInspectDbBlockingCommand = Command.make( + "blocking", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( "Show queries that are holding locks and the queries that are waiting for them to be released.", ), Command.withShortDescription("Show blocking queries"), - Command.withHandler((flags) => legacyInspectDbBlocking(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbBlocking)), + Command.provide(legacyInspectDbRuntimeLayer("blocking")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.handler.ts b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.handler.ts index 7f6aef072f..dae6934690 100644 --- a/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbBlockingFlags } from "./blocking.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyBlockingSpec } from "./blocking.query.ts"; -export const legacyInspectDbBlocking = Effect.fn("legacy.inspect.db.blocking")(function* ( - flags: LegacyInspectDbBlockingFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "blocking"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbBlocking = legacyMakeInspectDbHandler( + legacyBlockingSpec, + "legacy.inspect.db.blocking", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.query.ts b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.query.ts new file mode 100644 index 0000000000..e5656096e6 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/blocking/blocking.query.ts @@ -0,0 +1,50 @@ +import { + legacyInspectInt, + legacyInspectStmt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/blocking/blocking.sql`. +const SQL = `SELECT + bl.pid AS blocked_pid, + ka.query AS blocking_statement, + age(now(), ka.query_start)::text AS blocking_duration, + kl.pid AS blocking_pid, + a.query AS blocked_statement, + age(now(), a.query_start)::text AS blocked_duration +FROM pg_catalog.pg_locks bl +JOIN pg_catalog.pg_stat_activity a + ON bl.pid = a.pid +JOIN pg_catalog.pg_locks kl +JOIN pg_catalog.pg_stat_activity ka + ON kl.pid = ka.pid + ON bl.transactionid = kl.transactionid AND bl.pid != kl.pid +WHERE NOT bl.granted`; + +/** + * `inspect db blocking` — queries holding locks and the queries waiting on them. + * Port of `apps/cli-go/internal/inspect/blocking/blocking.go`. Both statement + * columns are whitespace-collapsed. + */ +export const legacyBlockingSpec: LegacyInspectQuerySpec = { + name: "blocking", + sql: SQL, + params: () => [], + headers: [ + "blocked pid", + "blocking statement", + "blocking duration", + "blocking pid", + "blocked statement", + "blocked duration", + ], + project: (row) => [ + legacyInspectInt(row["blocked_pid"]), + legacyInspectStmt(row["blocking_statement"]), + legacyInspectText(row["blocking_duration"]), + legacyInspectInt(row["blocking_pid"]), + legacyInspectStmt(row["blocked_statement"]), + legacyInspectText(row["blocked_duration"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/cache-hit/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/cache-hit/SIDE_EFFECTS.md deleted file mode 100644 index 9b810bbaa2..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/cache-hit/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db cache-hit` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `db-stats` internally. Prints cache hit rates for tables and indices. - -## Notes - -- Deprecated: use `db-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.command.ts b/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.command.ts index 4f023b2aba..f2f37b0a26 100644 --- a/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbCacheHit } from "./cache-hit.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbCacheHitFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbCacheHitCommand = Command.make("cache-hit", config).pipe( +export const legacyInspectDbCacheHitCommand = Command.make( + "cache-hit", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show cache hit rates for tables and indices. Deprecated: use "db-stats" instead.', ), Command.withShortDescription("Show cache hit rates (deprecated)"), - Command.withHandler((flags) => legacyInspectDbCacheHit(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbCacheHit)), + Command.provide(legacyInspectDbRuntimeLayer("cache-hit")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.handler.ts b/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.handler.ts index ce53a449e0..711aa83862 100644 --- a/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/cache-hit/cache-hit.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbCacheHitFlags } from "./cache-hit.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyDbStatsSpec } from "../db-stats/db-stats.query.ts"; -export const legacyInspectDbCacheHit = Effect.fn("legacy.inspect.db.cache-hit")(function* ( - flags: LegacyInspectDbCacheHitFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "cache-hit"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbCacheHit = legacyMakeInspectDbHandler( + legacyDbStatsSpec, + "legacy.inspect.db.cache-hit", + legacyInspectDeprecationNotice("cache-hit", "db-stats"), +); diff --git a/apps/cli/src/legacy/commands/inspect/db/calls/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/calls/SIDE_EFFECTS.md deleted file mode 100644 index 882366da11..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/calls/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db calls` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table of queries from pg_stat_statements ordered by total times called. - -## Notes - -- Requires `pg_stat_statements` extension to be enabled on the database. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/calls/calls.command.ts b/apps/cli/src/legacy/commands/inspect/db/calls/calls.command.ts index 92f92c4eca..e297223d85 100644 --- a/apps/cli/src/legacy/commands/inspect/db/calls/calls.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/calls/calls.command.ts @@ -1,22 +1,14 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbCalls } from "./calls.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbCallsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbCallsCommand = Command.make("calls", config).pipe( +export const legacyInspectDbCallsCommand = Command.make("calls", LEGACY_INSPECT_DB_FLAGS).pipe( Command.withDescription("Show queries from pg_stat_statements ordered by total times called."), Command.withShortDescription("Show queries by call count"), - Command.withHandler((flags) => legacyInspectDbCalls(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbCalls)), + Command.provide(legacyInspectDbRuntimeLayer("calls")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/calls/calls.handler.ts b/apps/cli/src/legacy/commands/inspect/db/calls/calls.handler.ts index 1ccc187d97..4d2378df20 100644 --- a/apps/cli/src/legacy/commands/inspect/db/calls/calls.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/calls/calls.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbCallsFlags } from "./calls.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyCallsSpec } from "./calls.query.ts"; -export const legacyInspectDbCalls = Effect.fn("legacy.inspect.db.calls")(function* ( - flags: LegacyInspectDbCallsFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "calls"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbCalls = legacyMakeInspectDbHandler( + legacyCallsSpec, + "legacy.inspect.db.calls", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/calls/calls.query.ts b/apps/cli/src/legacy/commands/inspect/db/calls/calls.query.ts new file mode 100644 index 0000000000..b6d3299dec --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/calls/calls.query.ts @@ -0,0 +1,58 @@ +import { + legacyInspectBacktickStmt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/calls/calls.sql`. +const SQL = `SELECT + query, + (interval '1 millisecond' * total_exec_time)::text AS total_exec_time, + to_char((total_exec_time/sum(total_exec_time) OVER()) * 100, 'FM90D0') || '%' AS prop_exec_time, + to_char(calls, 'FM999G999G999G999G990') AS ncalls, + /* + Handle column names for 15 and 17 + */ + ( + interval '1 millisecond' * ( + COALESCE( + (to_jsonb(s) ->> 'shared_blk_read_time')::double precision, + (to_jsonb(s) ->> 'blk_read_time')::double precision, + 0 + ) + + + COALESCE( + (to_jsonb(s) ->> 'shared_blk_write_time')::double precision, + (to_jsonb(s) ->> 'blk_write_time')::double precision, + 0 + ) + ) + )::text AS sync_io_time +FROM extensions.pg_stat_statements s +ORDER BY calls DESC +LIMIT 10`; + +/** + * `inspect db calls` — pg_stat_statements ordered by number of calls. + * Port of `apps/cli-go/internal/inspect/calls/calls.go`. The `query` column is + * whitespace-collapsed and rendered first. + */ +export const legacyCallsSpec: LegacyInspectQuerySpec = { + name: "calls", + sql: SQL, + params: () => [], + headers: [ + "Query", + "Total Execution Time", + "Proportion of total exec time", + "Number Calls", + "Sync IO time", + ], + project: (row) => [ + legacyInspectBacktickStmt(row["query"]), + legacyInspectText(row["total_exec_time"]), + legacyInspectText(row["prop_exec_time"]), + legacyInspectText(row["ncalls"]), + legacyInspectText(row["sync_io_time"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/db-stats/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/db-stats/SIDE_EFFECTS.md deleted file mode 100644 index 94c24e4f03..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/db-stats/SIDE_EFFECTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# `supabase inspect db db-stats` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Runs the db-stats query directly against the linked (or local) Postgres database and -prints a table showing cache hit rates, total sizes, and WAL size. - -### `--output-format json` - -Not applicable — this command queries Postgres directly and outputs tabular text. - -### `--output-format stream-json` - -Not applicable — this command queries Postgres directly and outputs tabular text. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.command.ts b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.command.ts index fd3e7e10eb..62ac6929e7 100644 --- a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.command.ts @@ -1,22 +1,14 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbDbStats } from "./db-stats.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbDbStatsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbDbStatsCommand = Command.make("db-stats", config).pipe( +export const legacyInspectDbDbStatsCommand = Command.make("db-stats", LEGACY_INSPECT_DB_FLAGS).pipe( Command.withDescription("Show stats such as cache hit rates, total sizes, and WAL size."), Command.withShortDescription("Show database stats"), - Command.withHandler((flags) => legacyInspectDbDbStats(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbDbStats)), + Command.provide(legacyInspectDbRuntimeLayer("db-stats")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.handler.ts b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.handler.ts index 716579088b..5b35654013 100644 --- a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbDbStatsFlags } from "./db-stats.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyDbStatsSpec } from "./db-stats.query.ts"; -export const legacyInspectDbDbStats = Effect.fn("legacy.inspect.db.db-stats")(function* ( - flags: LegacyInspectDbDbStatsFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "db-stats"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbDbStats = legacyMakeInspectDbHandler( + legacyDbStatsSpec, + "legacy.inspect.db.db-stats", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.query.ts b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.query.ts new file mode 100644 index 0000000000..fc69190226 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/db-stats/db-stats.query.ts @@ -0,0 +1,96 @@ +import { legacyInspectText, type LegacyInspectQuerySpec } from "../legacy-inspect-query.ts"; +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "../legacy-inspect-schemas.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/db_stats/db_stats.sql`. +const SQL = `WITH total_objects AS ( + SELECT c.relkind, pg_size_pretty(SUM(pg_relation_size(c.oid))) AS size + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE c.relkind IN ('i', 'r', 't') AND NOT n.nspname LIKE ANY($1) + GROUP BY c.relkind +), cache_hit AS ( + SELECT + 'i' AS relkind, + ROUND(SUM(idx_blks_hit)::numeric / nullif(SUM(idx_blks_hit + idx_blks_read), 0), 2) AS ratio + FROM pg_statio_user_indexes + WHERE NOT schemaname LIKE ANY($1) + UNION + SELECT + 't' AS relkind, + /* + Handle column names for both PG15 and 17 + */ + ROUND( + ( + SUM( + COALESCE( + (to_jsonb(s) ->> 'rel_blks_hit')::bigint, + (to_jsonb(s) ->> 'heap_blks_hit')::bigint, + 0 + ) + )::numeric + / + nullif( + SUM( + COALESCE( + (to_jsonb(s) ->> 'rel_blks_hit')::bigint, + (to_jsonb(s) ->> 'heap_blks_hit')::bigint, + 0 + ) + + + COALESCE( + (to_jsonb(s) ->> 'rel_blks_read')::bigint, + (to_jsonb(s) ->> 'heap_blks_read')::bigint, + 0 + ) + ), + 0 + ) + ), + 2 + ) AS ratio + FROM pg_statio_user_tables s + WHERE NOT schemaname LIKE ANY($1) +) +SELECT + pg_size_pretty(pg_database_size($2)) AS database_size, + COALESCE((SELECT size FROM total_objects WHERE relkind = 'i'), '0 bytes') AS total_index_size, + COALESCE((SELECT size FROM total_objects WHERE relkind = 'r'), '0 bytes') AS total_table_size, + COALESCE((SELECT size FROM total_objects WHERE relkind = 't'), '0 bytes') AS total_toast_size, + COALESCE((SELECT (now() - stats_reset)::text FROM extensions.pg_stat_statements_info), 'N/A') AS time_since_stats_reset, + (SELECT COALESCE(ratio::text, 'N/A') FROM cache_hit WHERE relkind = 'i') AS index_hit_rate, + (SELECT COALESCE(ratio::text, 'N/A') FROM cache_hit WHERE relkind = 't') AS table_hit_rate, + COALESCE((SELECT pg_size_pretty(SUM(size)) FROM pg_ls_waldir()), '0 bytes') AS wal_size`; + +/** + * `inspect db db-stats` — cache hit rates, total sizes, and WAL size. + * Port of `apps/cli-go/internal/inspect/db_stats/db_stats.go`. The leading + * `Name` column is the resolved database name, injected per row (not a query + * column); the query takes `$1` = escaped internal schemas, `$2` = database name. + */ +export const legacyDbStatsSpec: LegacyInspectQuerySpec = { + name: "db-stats", + sql: SQL, + params: (cfg) => [legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS), cfg.conn.database], + headers: [ + "Name", + "Database Size", + "Total Index Size", + "Total Table Size", + "Total Toast Size", + "Time Since Stats Reset", + "Index Hit Rate", + "Table Hit Rate", + "WAL Size", + ], + project: (row, cfg) => [ + cfg.conn.database, + legacyInspectText(row["database_size"]), + legacyInspectText(row["total_index_size"]), + legacyInspectText(row["total_table_size"]), + legacyInspectText(row["total_toast_size"]), + legacyInspectText(row["time_since_stats_reset"]), + legacyInspectText(row["index_hit_rate"]), + legacyInspectText(row["table_hit_rate"]), + legacyInspectText(row["wal_size"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/db.layers.ts b/apps/cli/src/legacy/commands/inspect/db/db.layers.ts new file mode 100644 index 0000000000..b895cb5f1d --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/db.layers.ts @@ -0,0 +1,60 @@ +import { Layer } from "effect"; + +import { legacyCliConfigLayer } from "../../../config/legacy-cli-config.layer.ts"; +import { legacyDbConfigLayer } from "../../../shared/legacy-db-config.layer.ts"; +import { legacyDbConnectionLayer } from "../../../shared/legacy-db-connection.layer.ts"; +import { legacyDebugLoggerLayer } from "../../../shared/legacy-debug-logger.layer.ts"; +import { legacyTelemetryStateLayer } from "../../../telemetry/legacy-telemetry-state.layer.ts"; +import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; + +/** + * `legacyCliConfigLayer` is provided to the resolver AND exposed at the top level + * because `Layer.provide` does not share to merge siblings (legacy CLAUDE.md item + * 5); the resolver requires it internally and so it is provided to `dbConfig`, + * while the merge keeps it available alongside. + */ +const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + +const dbConfig = legacyDbConfigLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDbConnectionLayer), + Layer.provide(legacyDebugLoggerLayer), +); + +/** + * The services every `inspect db` subcommand shares, minus the command-runtime + * identity. Mirrors `test/test.layers.ts` minus the docker layer: the DB-config + * resolver, the Postgres connection, the CLI config, and telemetry state. The + * Management API stack is NOT merged here — it resolves an access token eagerly, + * which would break the auth-free `--local` / `--db-url` paths. The `--linked` + * path provides it lazily inside the resolver (`legacy-db-config.layer.ts`). + */ +const baseLayer = Layer.mergeAll( + dbConfig, + legacyDbConnectionLayer, + cliConfig, + legacyTelemetryStateLayer, +); + +/** + * The command-runtime path for a single `inspect db ` subcommand. + * + * The `leaf` is the cobra `Use` name of the invoked command (e.g. `"locks"`, or a + * deprecated alias like `"cache-hit"`) and is appended to `["inspect", "db"]`. This + * path is what `withLegacyCommandInstrumentation` records as the PostHog + * `cli_command_executed` `command` property, matching Go's `cmd.CommandPath()` + * (`apps/cli-go/cmd/root_analytics.go:32-38`): Go's inspect tree is a real 3-level + * hierarchy, so each of the 25 leaves emits a distinct command name. A shared + * `["inspect", "db"]` path would collapse them all into one event, so each leaf must + * pass its own name — and a deprecated alias records the alias the user typed, not + * the backend command it delegates to (`cmd/inspect.go:139-247`). + */ +export const legacyInspectDbCommandPath = (leaf: string): ReadonlyArray => [ + "inspect", + "db", + leaf, +]; + +/** Runtime layer for a single `supabase inspect db ` subcommand. */ +export const legacyInspectDbRuntimeLayer = (leaf: string) => + Layer.merge(baseLayer, commandRuntimeLayer(legacyInspectDbCommandPath(leaf))); diff --git a/apps/cli/src/legacy/commands/inspect/db/db.layers.unit.test.ts b/apps/cli/src/legacy/commands/inspect/db/db.layers.unit.test.ts new file mode 100644 index 0000000000..c0084fbfb8 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/db.layers.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { legacyInspectDbCommandPath } from "./db.layers.ts"; + +describe("legacyInspectDbCommandPath", () => { + // Go's inspect tree is a real 3-level cobra hierarchy, so each leaf's + // `cmd.CommandPath()` is distinct and `cli_command_executed` records the full + // path (apps/cli-go/cmd/root_analytics.go:32-38). The TS command-runtime path + // must append the leaf to `["inspect", "db"]` so telemetry matches Go rather than + // collapsing all 25 subcommands into a single `inspect db` event. + it("appends a native leaf to the inspect db path", () => { + expect(legacyInspectDbCommandPath("locks")).toEqual(["inspect", "db", "locks"]); + expect(legacyInspectDbCommandPath("vacuum-stats")).toEqual(["inspect", "db", "vacuum-stats"]); + }); + + it("records a deprecated alias under its own name, not the backend command", () => { + // `cache-hit` delegates to the db-stats backend but is its own cobra command; + // Go's CommandPath() reflects the alias the user typed (cmd/inspect.go:139-247), + // so the path must carry the alias, never `db-stats`. + expect(legacyInspectDbCommandPath("cache-hit")).toEqual(["inspect", "db", "cache-hit"]); + expect(legacyInspectDbCommandPath("index-usage")).toEqual(["inspect", "db", "index-usage"]); + }); +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/index-sizes/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/index-sizes/SIDE_EFFECTS.md deleted file mode 100644 index cbb4837451..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/index-sizes/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db index-sizes` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `index-stats` internally. Prints index sizes of individual indexes. - -## Notes - -- Deprecated: use `index-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.command.ts b/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.command.ts index 6aaf58a572..3027f37cf0 100644 --- a/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbIndexSizes } from "./index-sizes.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbIndexSizesFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbIndexSizesCommand = Command.make("index-sizes", config).pipe( +export const legacyInspectDbIndexSizesCommand = Command.make( + "index-sizes", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show index sizes of individual indexes. Deprecated: use "index-stats" instead.', ), Command.withShortDescription("Show individual index sizes (deprecated)"), - Command.withHandler((flags) => legacyInspectDbIndexSizes(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbIndexSizes)), + Command.provide(legacyInspectDbRuntimeLayer("index-sizes")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.handler.ts b/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.handler.ts index f892820c71..032d8a68c0 100644 --- a/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/index-sizes/index-sizes.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbIndexSizesFlags } from "./index-sizes.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyIndexStatsSpec } from "../index-stats/index-stats.query.ts"; -export const legacyInspectDbIndexSizes = Effect.fn("legacy.inspect.db.index-sizes")(function* ( - flags: LegacyInspectDbIndexSizesFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "index-sizes"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbIndexSizes = legacyMakeInspectDbHandler( + legacyIndexStatsSpec, + "legacy.inspect.db.index-sizes", + legacyInspectDeprecationNotice("index-sizes", "index-stats"), +); diff --git a/apps/cli/src/legacy/commands/inspect/db/index-stats/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/index-stats/SIDE_EFFECTS.md deleted file mode 100644 index aa457f2745..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/index-stats/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db index-stats` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table showing combined index size, usage percent, scan counts, and unused status. -Supersedes `cache-hit`, `index-usage`, `total-index-size`, `index-sizes`, `unused-indexes`, and `seq-scans`. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.command.ts b/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.command.ts index 31cda82370..fe2261cb24 100644 --- a/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbIndexStats } from "./index-stats.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbIndexStatsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbIndexStatsCommand = Command.make("index-stats", config).pipe( +export const legacyInspectDbIndexStatsCommand = Command.make( + "index-stats", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( "Show combined index size, usage percent, scan counts, and unused status.", ), Command.withShortDescription("Show index stats"), - Command.withHandler((flags) => legacyInspectDbIndexStats(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbIndexStats)), + Command.provide(legacyInspectDbRuntimeLayer("index-stats")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.handler.ts b/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.handler.ts index 7bf88d7be2..572091f7e8 100644 --- a/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbIndexStatsFlags } from "./index-stats.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyIndexStatsSpec } from "./index-stats.query.ts"; -export const legacyInspectDbIndexStats = Effect.fn("legacy.inspect.db.index-stats")(function* ( - flags: LegacyInspectDbIndexStatsFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "index-stats"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbIndexStats = legacyMakeInspectDbHandler( + legacyIndexStatsSpec, + "legacy.inspect.db.index-stats", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.query.ts b/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.query.ts new file mode 100644 index 0000000000..fceb63bce2 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/index-stats/index-stats.query.ts @@ -0,0 +1,78 @@ +import { + legacyInspectBool, + legacyInspectInt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "../legacy-inspect-schemas.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/index_stats/index_stats.sql`. +const SQL = `-- Combined index statistics: size, usage percent, seq scans, and mark unused +WITH idx_sizes AS ( + SELECT + i.indexrelid AS oid, + FORMAT('%I.%I', n.nspname, c.relname) AS name, + pg_relation_size(i.indexrelid) AS index_size_bytes + FROM pg_stat_user_indexes ui + JOIN pg_index i ON ui.indexrelid = i.indexrelid + JOIN pg_class c ON ui.indexrelid = c.oid + JOIN pg_namespace n ON c.relnamespace = n.oid + WHERE NOT n.nspname LIKE ANY($1) +), +idx_usage AS ( + SELECT + indexrelid AS oid, + idx_scan::bigint AS idx_scans + FROM pg_stat_user_indexes ui + WHERE NOT schemaname LIKE ANY($1) +), +seq_usage AS ( + SELECT + relid AS oid, + seq_scan::bigint AS seq_scans + FROM pg_stat_user_tables + WHERE NOT schemaname LIKE ANY($1) +), +usage_pct AS ( + SELECT + u.oid, + CASE + WHEN u.idx_scans IS NULL OR u.idx_scans = 0 THEN 0 + WHEN s.seq_scans IS NULL THEN 100 + ELSE ROUND(100.0 * u.idx_scans / (s.seq_scans + u.idx_scans), 1) + END AS percent_used + FROM idx_usage u + LEFT JOIN seq_usage s ON s.oid = u.oid +) +SELECT + s.name, + pg_size_pretty(s.index_size_bytes) AS size, + COALESCE(up.percent_used, 0)::text || '%' AS percent_used, + COALESCE(u.idx_scans, 0) AS index_scans, + COALESCE(sq.seq_scans, 0) AS seq_scans, + CASE WHEN COALESCE(u.idx_scans, 0) = 0 THEN true ELSE false END AS unused +FROM idx_sizes s +LEFT JOIN idx_usage u ON u.oid = s.oid +LEFT JOIN seq_usage sq ON sq.oid = s.oid +LEFT JOIN usage_pct up ON up.oid = s.oid +ORDER BY s.index_size_bytes DESC`; + +/** + * `inspect db index-stats` — combined index size, usage percent, scan counts, + * and unused status. Port of `apps/cli-go/internal/inspect/index_stats/index_stats.go`. + * Also the routed query for the deprecated index/table aliases. + */ +export const legacyIndexStatsSpec: LegacyInspectQuerySpec = { + name: "index-stats", + sql: SQL, + params: () => [legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS)], + headers: ["Name", "Size", "Percent used", "Index scans", "Seq scans", "Unused"], + project: (row) => [ + legacyInspectText(row["name"]), + legacyInspectText(row["size"]), + legacyInspectText(row["percent_used"]), + legacyInspectInt(row["index_scans"]), + legacyInspectInt(row["seq_scans"]), + legacyInspectBool(row["unused"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/index-usage/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/index-usage/SIDE_EFFECTS.md deleted file mode 100644 index 1ebc8f215f..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/index-usage/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db index-usage` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `index-stats` internally. Prints information about the efficiency of indexes. - -## Notes - -- Deprecated: use `index-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.command.ts b/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.command.ts index 7095b11a43..c7e9a407e0 100644 --- a/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbIndexUsage } from "./index-usage.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbIndexUsageFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbIndexUsageCommand = Command.make("index-usage", config).pipe( +export const legacyInspectDbIndexUsageCommand = Command.make( + "index-usage", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show information about the efficiency of indexes. Deprecated: use "index-stats" instead.', ), Command.withShortDescription("Show index efficiency (deprecated)"), - Command.withHandler((flags) => legacyInspectDbIndexUsage(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbIndexUsage)), + Command.provide(legacyInspectDbRuntimeLayer("index-usage")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.handler.ts b/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.handler.ts index cc85357d9e..cad5e78bb2 100644 --- a/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/index-usage/index-usage.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbIndexUsageFlags } from "./index-usage.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyIndexStatsSpec } from "../index-stats/index-stats.query.ts"; -export const legacyInspectDbIndexUsage = Effect.fn("legacy.inspect.db.index-usage")(function* ( - flags: LegacyInspectDbIndexUsageFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "index-usage"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbIndexUsage = legacyMakeInspectDbHandler( + legacyIndexStatsSpec, + "legacy.inspect.db.index-usage", + legacyInspectDeprecationNotice("index-usage", "index-stats"), +); diff --git a/apps/cli/src/legacy/commands/inspect/db/inspect-db.e2e.test.ts b/apps/cli/src/legacy/commands/inspect/db/inspect-db.e2e.test.ts new file mode 100644 index 0000000000..90f2e6c9a7 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/inspect-db.e2e.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, test } from "vitest"; + +import { makeTempHome, runSupabase } from "../../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; + +// A definitely-closed local port: resolution succeeds (the `--db-url` is parsed +// directly, no config.toml / running stack needed), then the native handler dials +// and fails fast with a connection error. This exercises the real subprocess path +// — flag parse → resolution → native query run — without the Go binary and without +// depending on a live database in CI. +const DEAD_DB_URL = "postgres://postgres:postgres@127.0.0.1:1/postgres"; + +// `--agent no` forces text-mode output deterministically: the CLI otherwise +// auto-selects a machine format (JSON on stdout) when it detects a coding-agent +// environment, which would route the error away from stderr. +const TEXT_MODE = "--agent"; +const TEXT_MODE_VALUE = "no"; + +describe("supabase inspect db (legacy)", () => { + test( + "inspect db locks fails gracefully when the database is unreachable", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase( + ["inspect", "db", "locks", TEXT_MODE, TEXT_MODE_VALUE, "--db-url", DEAD_DB_URL], + { entrypoint: "legacy", home: home.dir, env: { HOME: home.dir } }, + ); + expect(exitCode).toBe(1); + // The native handler writes the connection diagnostic to stderr (Go parity) + // and then surfaces the connection failure. + expect(stderr).toContain("Connecting to remote database..."); + expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); + }, + ); + + test( + "inspect db cache-hit prints the deprecation notice before the connection error", + { timeout: E2E_TIMEOUT_MS }, + async () => { + using home = makeTempHome(); + const { exitCode, stderr } = await runSupabase( + ["inspect", "db", "cache-hit", TEXT_MODE, TEXT_MODE_VALUE, "--db-url", DEAD_DB_URL], + { entrypoint: "legacy", home: home.dir, env: { HOME: home.dir } }, + ); + expect(exitCode).toBe(1); + expect(stderr).toContain('Command "cache-hit" is deprecated, use "db-stats" instead.'); + // The deprecation line precedes the connection diagnostic/error. + const deprecationIndex = stderr.indexOf('Command "cache-hit" is deprecated'); + const connectingIndex = stderr.indexOf("Connecting to remote database..."); + expect(deprecationIndex).toBeGreaterThanOrEqual(0); + expect(connectingIndex).toBeGreaterThan(deprecationIndex); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts new file mode 100644 index 0000000000..73feddc40f --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-db-command.ts @@ -0,0 +1,41 @@ +import { Effect } from "effect"; +import { Flag } from "effect/unstable/cli"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import type { LegacyInspectConnectionFlags } from "./legacy-inspect-query.ts"; + +/** + * The `inspect` persistent flag set, inherited by every `inspect db` subcommand + * (`apps/cli-go/cmd/inspect.go:259-263`). Shared verbatim across all 25 commands + * so the flag names and descriptions live in one place. `Command.make` reads this + * immutable descriptor without mutating it, so a single instance is safe to reuse. + */ +export const LEGACY_INSPECT_DB_FLAGS = { + dbUrl: Flag.string("db-url").pipe( + Flag.withDescription( + "Inspect the database specified by the connection string (must be percent-encoded).", + ), + Flag.optional, + ), + linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), + local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), +} as const; + +/** + * Wraps an `inspect db` handler with the standard command-level pipeline: legacy + * telemetry instrumentation (the Go-shape `cli_command_executed` event, with the + * three connection flags) and the machine-format JSON error envelope. Shared by + * all 25 command files so the wiring is defined once. + */ +export function legacyInspectDbCommandHandler( + handler: (flags: LegacyInspectConnectionFlags) => Effect.Effect, +) { + return (flags: LegacyInspectConnectionFlags) => + handler(flags).pipe( + withLegacyCommandInstrumentation({ + flags: { "db-url": flags.dbUrl, linked: flags.linked, local: flags.local }, + }), + withJsonErrorHandling, + ); +} diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts new file mode 100644 index 0000000000..39e88394b2 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-deprecated.integration.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyTelemetryStateTracked } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import type { + LegacyInspectConnectionFlags, + LegacyInspectQuerySpec, +} from "./legacy-inspect-query.ts"; +import { legacyDbStatsSpec } from "./db-stats/db-stats.query.ts"; +import { legacyIndexStatsSpec } from "./index-stats/index-stats.query.ts"; +import { legacyRoleStatsSpec } from "./role-stats/role-stats.query.ts"; +import { legacyTableStatsSpec } from "./table-stats/table-stats.query.ts"; +import { legacyInspectDbCacheHit } from "./cache-hit/cache-hit.handler.ts"; +import { legacyInspectDbIndexSizes } from "./index-sizes/index-sizes.handler.ts"; +import { legacyInspectDbIndexUsage } from "./index-usage/index-usage.handler.ts"; +import { legacyInspectDbRoleConfigs } from "./role-configs/role-configs.handler.ts"; +import { legacyInspectDbRoleConnections } from "./role-connections/role-connections.handler.ts"; +import { legacyInspectDbSeqScans } from "./seq-scans/seq-scans.handler.ts"; +import { legacyInspectDbTableIndexSizes } from "./table-index-sizes/table-index-sizes.handler.ts"; +import { legacyInspectDbTableRecordCounts } from "./table-record-counts/table-record-counts.handler.ts"; +import { legacyInspectDbTableSizes } from "./table-sizes/table-sizes.handler.ts"; +import { legacyInspectDbTotalIndexSize } from "./total-index-size/total-index-size.handler.ts"; +import { legacyInspectDbTotalTableSizes } from "./total-table-sizes/total-table-sizes.handler.ts"; +import { legacyInspectDbUnusedIndexes } from "./unused-indexes/unused-indexes.handler.ts"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +function setup() { + const out = mockOutput({ format: "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + let querySql: string | undefined; + const layer = Layer.mergeAll( + out.layer, + telemetry.layer, + Layer.succeed(LegacyDnsResolverFlag, "native"), + Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed({ conn: LOCAL_CONN, isLocal: true } satisfies LegacyResolvedDbConfig), + }), + Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + exec: () => Effect.void, + extensionExists: () => Effect.succeed(false), + query: (sql: string) => { + querySql = sql; + return Effect.succeed([]); + }, + }), + }), + ); + return { + layer, + out, + get querySql() { + return querySql; + }, + }; +} + +const flags: LegacyInspectConnectionFlags = { + dbUrl: Option.none(), + linked: false, + local: true, +}; + +// All deprecated-alias handlers share the same factory-produced type. +type AliasHandler = typeof legacyInspectDbCacheHit; + +interface AliasCase { + readonly alias: string; + readonly handler: AliasHandler; + readonly routedSpec: LegacyInspectQuerySpec; + readonly target: string; +} + +// One row per deprecated alias: the cobra deprecation target text and the active +// query it actually runs. `table-record-counts` is the Go inconsistency — it warns +// "table-stats" but runs the index-stats query. +const cases: ReadonlyArray = [ + { + alias: "cache-hit", + handler: legacyInspectDbCacheHit, + routedSpec: legacyDbStatsSpec, + target: "db-stats", + }, + { + alias: "index-usage", + handler: legacyInspectDbIndexUsage, + routedSpec: legacyIndexStatsSpec, + target: "index-stats", + }, + { + alias: "total-index-size", + handler: legacyInspectDbTotalIndexSize, + routedSpec: legacyIndexStatsSpec, + target: "index-stats", + }, + { + alias: "index-sizes", + handler: legacyInspectDbIndexSizes, + routedSpec: legacyIndexStatsSpec, + target: "index-stats", + }, + { + alias: "unused-indexes", + handler: legacyInspectDbUnusedIndexes, + routedSpec: legacyIndexStatsSpec, + target: "index-stats", + }, + { + alias: "seq-scans", + handler: legacyInspectDbSeqScans, + routedSpec: legacyIndexStatsSpec, + target: "index-stats", + }, + { + alias: "table-record-counts", + handler: legacyInspectDbTableRecordCounts, + routedSpec: legacyIndexStatsSpec, + target: "table-stats", + }, + { + alias: "table-sizes", + handler: legacyInspectDbTableSizes, + routedSpec: legacyTableStatsSpec, + target: "table-stats", + }, + { + alias: "table-index-sizes", + handler: legacyInspectDbTableIndexSizes, + routedSpec: legacyTableStatsSpec, + target: "table-stats", + }, + { + alias: "total-table-sizes", + handler: legacyInspectDbTotalTableSizes, + routedSpec: legacyTableStatsSpec, + target: "table-stats", + }, + { + alias: "role-configs", + handler: legacyInspectDbRoleConfigs, + routedSpec: legacyRoleStatsSpec, + target: "role-stats", + }, + { + alias: "role-connections", + handler: legacyInspectDbRoleConnections, + routedSpec: legacyRoleStatsSpec, + target: "role-stats", + }, +]; + +describe("legacy inspect db deprecated aliases", () => { + it("covers all 12 deprecated aliases", () => { + expect(cases).toHaveLength(12); + }); + + for (const testCase of cases) { + it.live(`${testCase.alias} warns and runs the ${testCase.routedSpec.name} query`, () => { + const ctx = setup(); + return Effect.gen(function* () { + yield* testCase.handler(flags); + expect(ctx.out.stderrText).toContain( + `Command "${testCase.alias}" is deprecated, use "${testCase.target}" instead.`, + ); + expect(ctx.querySql).toBe(testCase.routedSpec.sql); + }).pipe(Effect.provide(ctx.layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts new file mode 100644 index 0000000000..f46b8ec901 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.integration.test.ts @@ -0,0 +1,400 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { mockLegacyTelemetryStateTracked } from "../../../../../tests/helpers/legacy-mocks.ts"; +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { LegacyDbConfigLoadError } from "../../../shared/legacy-db-config.errors.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { + LegacyDbConnectError, + LegacyDbExecError, +} from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyInspectDbDbStats } from "./db-stats/db-stats.handler.ts"; +import { legacyDbStatsSpec } from "./db-stats/db-stats.query.ts"; +import { legacyInspectDbLocks } from "./locks/locks.handler.ts"; +import { legacyInspectDbRoleStats } from "./role-stats/role-stats.handler.ts"; +import { legacyRoleStatsSpec } from "./role-stats/role-stats.query.ts"; +import { + LegacyInspectMutuallyExclusiveFlagsError, + type LegacyInspectConnectionFlags, +} from "./legacy-inspect-query.ts"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; +const REMOTE_CONN: LegacyPgConnInput = { + host: "db.abcdefghijklmnopqrst.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + +const DB_STATS_ROW = { + database_size: "8192 kB", + total_index_size: "1024 kB", + total_table_size: "2048 kB", + total_toast_size: "0 bytes", + time_since_stats_reset: "01:23:45", + index_hit_rate: "0.99", + table_hit_rate: "0.95", + wal_size: "16 MB", +}; + +const LOCKS_ROW = { + pid: 1234, + relname: "public.users", + transactionid: "null", + granted: true, + stmt: "SELECT *\n FROM\tusers", + age: "00:05:00", +}; + +function mockResolver(opts: { conn?: LegacyPgConnInput; isLocal?: boolean; fails?: boolean }) { + let resolveInput: LegacyDbConfigFlags | undefined; + const layer = Layer.succeed(LegacyDbConfigResolver, { + resolve: (flags: LegacyDbConfigFlags) => { + resolveInput = flags; + if (opts.fails === true) { + return Effect.fail(new LegacyDbConfigLoadError({ message: "cannot load config" })); + } + return Effect.succeed({ + conn: opts.conn ?? LOCAL_CONN, + isLocal: opts.isLocal ?? true, + } satisfies LegacyResolvedDbConfig); + }, + }); + return { + layer, + get resolveInput() { + return resolveInput; + }, + }; +} + +function mockDbConnection(opts: { + rows?: ReadonlyArray>; + connectFails?: boolean; + queryFails?: boolean; +}) { + const connectCalls: Array<{ + cfg: LegacyPgConnInput; + isLocal: boolean; + dnsResolver: "native" | "https"; + }> = []; + let querySql: string | undefined; + let queryParams: ReadonlyArray | undefined; + const layer = Layer.succeed(LegacyDbConnection, { + connect: (cfg, options) => { + connectCalls.push({ cfg, isLocal: options.isLocal, dnsResolver: options.dnsResolver }); + if (opts.connectFails === true) { + return Effect.fail( + new LegacyDbConnectError({ message: "failed to connect to postgres: refused" }), + ); + } + return Effect.succeed({ + exec: () => Effect.void, + extensionExists: () => Effect.succeed(false), + query: (sql: string, params?: ReadonlyArray) => { + querySql = sql; + queryParams = params; + if (opts.queryFails === true) { + return Effect.fail(new LegacyDbExecError({ message: "syntax error" })); + } + return Effect.succeed(opts.rows ?? []); + }, + }); + }, + }); + return { + layer, + get connectCalls() { + return connectCalls; + }, + get querySql() { + return querySql; + }, + get queryParams() { + return queryParams; + }, + }; +} + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + conn?: LegacyPgConnInput; + isLocal?: boolean; + rows?: ReadonlyArray>; + resolveFails?: boolean; + connectFails?: boolean; + queryFails?: boolean; + dnsResolver?: "native" | "https"; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const resolver = mockResolver({ + conn: opts.conn, + isLocal: opts.isLocal, + fails: opts.resolveFails, + }); + const connection = mockDbConnection({ + rows: opts.rows, + connectFails: opts.connectFails, + queryFails: opts.queryFails, + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const layer = Layer.mergeAll( + out.layer, + resolver.layer, + connection.layer, + telemetry.layer, + Layer.succeed(LegacyDnsResolverFlag, opts.dnsResolver ?? "native"), + ); + return { layer, out, resolver, connection, telemetry }; +} + +const flags = (over: Partial = {}): LegacyInspectConnectionFlags => ({ + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? false, +}); + +describe("legacy inspect db query runner", () => { + it.live("renders a glamour table in text mode (db-stats)", () => { + const { layer, out, connection } = setup({ rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags()); + // The query ran with the embedded SQL and both params (escaped schemas + db name). + expect(connection.querySql).toBe(legacyDbStatsSpec.sql); + expect(connection.queryParams?.[1]).toBe("postgres"); + expect(Array.isArray(connection.queryParams?.[0])).toBe(true); + // stdout is byte-exact the Go-parity glamour table. + const expected = renderGlamourTable(legacyDbStatsSpec.headers, [ + legacyDbStatsSpec.project(DB_STATS_ROW, { conn: LOCAL_CONN, isLocal: true }), + ]); + expect(out.stdoutText).toBe(expected); + // The leading Name column is the resolved database name. + expect(out.stdoutText).toContain("postgres"); + expect(out.stdoutText).toContain("8192 kB"); + expect(out.stdoutText).toContain("WAL Size"); + }).pipe(Effect.provide(layer)); + }); + + it.live("collapses statement whitespace and formats bool/int cells (locks)", () => { + const { layer, out } = setup({ rows: [LOCKS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbLocks(flags()); + expect(out.stdoutText).toContain("SELECT * FROM users"); + expect(out.stdoutText).toContain("true"); + expect(out.stdoutText).toContain("1234"); + }).pipe(Effect.provide(layer)); + }); + + it.live("renders an empty backtick-wrapped cell as two literal backticks (role-stats)", () => { + // Go wraps every role-stats cell in `` `%s` `` (role_stats.go:43); the postgres + // row has no custom config, so glamour emits an empty code span as the two + // literal backtick characters. The TS port must byte-match, including the + // resulting column width. + const ROLE_ROW = { + role_name: "postgres", + active_connections: 3, + connection_limit: 100, + custom_config: null, + }; + const { layer, out } = setup({ rows: [ROLE_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbRoleStats(flags()); + const cells = legacyRoleStatsSpec.project(ROLE_ROW, { conn: LOCAL_CONN, isLocal: true }); + expect(cells[3]).toBe("``"); + const expected = renderGlamourTable(legacyRoleStatsSpec.headers, [cells]); + expect(out.stdoutText).toBe(expected); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits raw rows in json mode", () => { + const { layer, out } = setup({ format: "json", rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags()); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + message: "inspect db db-stats", + data: { rows: [DB_STATS_ROW] }, + }), + ); + // No table is written to stdout in machine modes. + expect(out.stdoutText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event in stream-json mode", () => { + const { layer, out } = setup({ format: "stream-json", rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags()); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", message: "inspect db db-stats" }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("inspects an explicit database url", () => { + const { layer, resolver, connection } = setup({ + conn: REMOTE_CONN, + isLocal: false, + rows: [DB_STATS_ROW], + }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); + expect(Option.isSome(resolver.resolveInput?.dbUrl ?? Option.none())).toBe(true); + expect(resolver.resolveInput?.linked).toBe(false); + expect(connection.connectCalls[0]?.isLocal).toBe(false); + }).pipe(Effect.provide(layer)); + }); + + it.live("inspects the local database", () => { + const { layer, resolver, out } = setup({ rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags({ local: true })); + expect(resolver.resolveInput?.local).toBe(true); + expect(resolver.resolveInput?.linked).toBe(false); + expect(out.stderrText).toContain("Connecting to local database..."); + }).pipe(Effect.provide(layer)); + }); + + it.live("inspects the linked project by default (no connection flag)", () => { + const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags()); + // Go's `--linked` defaults to true; the runner derives it from absence. + expect(resolver.resolveInput?.linked).toBe(true); + expect(resolver.resolveInput?.local).toBe(false); + expect(Option.isNone(resolver.resolveInput?.dbUrl ?? Option.some("x"))).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("labels the diagnostic 'remote' for a non-local connection", () => { + const { layer, out } = setup({ conn: REMOTE_CONN, isLocal: false, rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); + expect(out.stderrText).toContain("Connecting to remote database..."); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects conflicting connection flags", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectDbDbStats(flags({ linked: true, local: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const failure = Cause.findErrorOption(exit.cause); + expect(Option.isSome(failure)).toBe(true); + if (Option.isSome(failure)) { + const error = failure.value; + expect(error).toBeInstanceOf(LegacyInspectMutuallyExclusiveFlagsError); + if (error instanceof LegacyInspectMutuallyExclusiveFlagsError) { + expect(error.message).toBe( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); + } + } + } + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces a query failure", () => { + const { layer } = setup({ queryFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectDbDbStats(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("syntax error"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces a connection failure", () => { + const { layer } = setup({ connectFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectDbDbStats(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to connect to postgres"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("surfaces a resolution failure", () => { + const { layer } = setup({ resolveFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyInspectDbDbStats(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("cannot load config"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("renders the header and separator for an empty result set", () => { + const { layer, out } = setup({ rows: [] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags()); + const expected = renderGlamourTable(legacyDbStatsSpec.headers, []); + expect(out.stdoutText).toBe(expected); + expect(out.stdoutText).toContain("Database Size"); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits an empty rows array in json mode for no results", () => { + const { layer, out } = setup({ format: "json", rows: [] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags()); + expect(out.messages).toContainEqual( + expect.objectContaining({ type: "success", data: { rows: [] } }), + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards the https dns resolver to the connection", () => { + const { layer, connection } = setup({ + conn: REMOTE_CONN, + isLocal: false, + rows: [DB_STATS_ROW], + dnsResolver: "https", + }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags({ dbUrl: Option.some("postgres://x") })); + expect(connection.connectCalls[0]?.dnsResolver).toBe("https"); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry on completion", () => { + const { layer, telemetry } = setup({ rows: [DB_STATS_ROW] }); + return Effect.gen(function* () { + yield* legacyInspectDbDbStats(flags()); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry even when the query fails", () => { + const { layer, telemetry } = setup({ queryFails: true }); + return Effect.gen(function* () { + yield* Effect.exit(legacyInspectDbDbStats(flags())); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts new file mode 100644 index 0000000000..de21b0bf85 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.ts @@ -0,0 +1,263 @@ +import { Data, Effect, Option } from "effect"; + +import { LegacyDnsResolverFlag } from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { renderGlamourTable } from "../../../output/legacy-glamour-table.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { LegacyResolvedDbConfig } from "../../../shared/legacy-db-config.types.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; + +/** + * The connection selector flags every `inspect db` subcommand inherits from the + * `inspect` persistent flag set (`apps/cli-go/cmd/inspect.go:259-263`): + * `--db-url` / `--linked` / `--local`, mutually exclusive. `--linked` defaults to + * `true` in Go; the runner derives that default from the absence of the others + * while keeping the exclusivity check keyed off the raw (explicitly-set) flags. + */ +export interface LegacyInspectConnectionFlags { + readonly dbUrl: Option.Option; + readonly linked: boolean; + readonly local: boolean; +} + +/** + * A single `inspect db` subcommand: the SQL it runs, the query parameters, the + * markdown table headers, and how each result row projects to clean table cells. + * + * 1:1 with a Go `internal/inspect/` package — `sql` is the embedded + * `.sql` verbatim, `headers` are the markdown column titles verbatim, and + * `project` reproduces the per-column `fmt` verbs (via the cell formatters below) + * minus Go's backtick code-spans and `\|` pipe escaping, since `renderGlamourTable` + * takes already-clean cell strings. + */ +export interface LegacyInspectQuerySpec { + /** The subcommand `Use` name, e.g. `"db-stats"`. */ + readonly name: string; + /** The embedded Go `.sql`, verbatim. */ + readonly sql: string; + /** Positional query parameters (`$1`, `$2`, …); `[]` for the no-param queries. */ + readonly params: (cfg: LegacyResolvedDbConfig) => ReadonlyArray; + /** Markdown table column titles, verbatim from the Go table header string. */ + readonly headers: ReadonlyArray; + /** Projects one driver row to the ordered, already-clean table cells. */ + readonly project: ( + row: Record, + cfg: LegacyResolvedDbConfig, + ) => ReadonlyArray; +} + +/** + * Raised when more than one of `--db-url` / `--linked` / `--local` is explicitly + * set, reproducing cobra's `MarkFlagsMutuallyExclusive` error + * (`apps/cli-go/cmd/inspect.go:263`). The message byte-matches cobra's text. + * + * Not reusing `test db`'s identical error type: hoisting it would drag that + * command's test surface into scope for a single shared string. Revisit if a + * third consumer appears. + */ +export class LegacyInspectMutuallyExclusiveFlagsError extends Data.TaggedError( + "LegacyInspectMutuallyExclusiveFlagsError", +)<{ readonly message: string }> {} + +// --------------------------------------------------------------------------- +// Cell formatters — pure, exported, unit-tested. Each reproduces a Go `fmt` +// verb. They branch on `typeof` rather than casting, so an unexpected driver +// type degrades to a string instead of throwing. +// --------------------------------------------------------------------------- + +/** + * Go's backtick-wrapped `` `%s` `` text cell — the shape of almost every `inspect + * db` string column (e.g. `role_stats.go:43` wraps each cell in `` `…` ``). + * + * Glamour's `AsciiStyle` strips the backticks from a non-empty inline code span, + * so a populated cell renders as its bare value. But an EMPTY code span (`` `` ``) + * is not a valid token, so glamour passes the two backtick characters through + * literally. We therefore render an empty/null value as the two literal backticks + * to byte-match Go (and so the cell contributes width 2, exactly like Go's). The + * few columns Go leaves UNWRAPPED (`%s`, no code span) use `legacyInspectPlainText`. + */ +export function legacyInspectText(value: unknown): string { + const text = value === null || value === undefined ? "" : String(value); + return text === "" ? "``" : text; +} + +/** + * Go's UNWRAPPED `%s` text cell (no backtick code span): an empty/null value + * renders as the empty string. Only the `vacuum_stats` timestamp columns + * (`Last_vacuum`/`Last_autovacuum`/`Last_analyze`/`Last_autoanalyze`) are written + * as bare `%s|` in Go (`vacuum_stats.go:53`); every other string column is wrapped + * (use `legacyInspectText`). + */ +export function legacyInspectPlainText(value: unknown): string { + if (value === null || value === undefined) return ""; + return String(value); +} + +/** Go `%t` for a bool column. The driver maps Postgres `boolean` to a JS boolean. */ +export function legacyInspectBool(value: unknown): string { + if (typeof value === "boolean") return value ? "true" : "false"; + if (value === null || value === undefined) return "false"; + return String(value); +} + +/** + * Go `%d` for an int column. The `pg` driver returns `int4` as a number and + * `int8`/`bigint` as a string (or a JS `bigint` if configured), so pass the + * base-10 representation straight through. + */ +export function legacyInspectInt(value: unknown): string { + if (value === null || value === undefined) return "0"; + if (typeof value === "bigint") return value.toString(); + return String(value); +} + +/** Go `%.1f` for a float column: always one decimal place (`12` → `"12.0"`). */ +export function legacyInspectFloat1(value: unknown): string { + if (typeof value === "number") return value.toFixed(1); + if (typeof value === "bigint") return Number(value).toFixed(1); + if (typeof value === "string") { + const parsed = Number(value); + return Number.isNaN(parsed) ? value : parsed.toFixed(1); + } + if (value === null || value === undefined) return "0.0"; + return String(value); +} + +/** + * A statement/query cell (locks, blocking, outliers, calls): collapse every run + * of whitespace to a single space, reproducing Go's + * `regexp.MustCompile(`\s+|\r+|\n+|\t+|\v`).ReplaceAllString(stmt, " ")`. Go also + * escapes pipes (`\|`), but `renderGlamourTable` takes literal cells, so pipes are + * left as-is here. + * + * Note: `long-running-queries.query` is NOT normalized in Go (`%s` directly), so + * its spec uses `legacyInspectText`, not this. + */ +export function legacyInspectStmt(value: unknown): string { + if (value === null || value === undefined) return ""; + // Go's RE2 `\s` is only `[\t\n\f\r ]` (NOT vertical tab), which is why the Go + // regex appends `|\v`. JS's `\s` differs — it includes `\v` AND Unicode spaces + // (nbsp, U+2028, …) — so a naive `/\s+/g` would over-collapse runs Go leaves + // alone. Replicate Go's exact character set: collapse runs of `[\t\n\f\r ]` and + // replace each `\v` individually with a single space. + return String(value).replace(/[\t\n\f\r ]+|\v/g, " "); +} + +/** + * A whitespace-collapsed statement cell that Go ALSO wraps in backticks + * (`calls.go:52` / `outliers.go:50` write the query as `` `%s` ``, unlike + * `locks`/`blocking` which leave it bare). Same empty-code-span rule as + * `legacyInspectText`: an empty value surfaces as the two literal backticks. + */ +export function legacyInspectBacktickStmt(value: unknown): string { + const stmt = legacyInspectStmt(value); + return stmt === "" ? "``" : stmt; +} + +/** + * Runs an `inspect db` subcommand's query and renders the result. + * + * Mirrors the shared Go shape (`internal/inspect//.go`): resolve the + * connection, `utils.ConnectByConfig` (which prints "Connecting to + * database..." to stderr — `connect.go:205-228`), run the query, then + * `utils.RenderTable`. In `json`/`stream-json` mode the raw driver rows are + * emitted as a structured result instead (TS-extra; Go has no machine output). + */ +export const legacyRunInspectQuery = Effect.fnUntraced(function* ( + spec: LegacyInspectQuerySpec, + flags: LegacyInspectConnectionFlags, + dnsResolver: "native" | "https", +) { + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + + // Reproduce cobra's MarkFlagsMutuallyExclusive("db-url","linked","local"), + // keyed off explicitly-set flags (cobra's `Changed`), not the default value. + const setFlags: Array = []; + if (Option.isSome(flags.dbUrl)) setFlags.push("db-url"); + if (flags.linked) setFlags.push("linked"); + if (flags.local) setFlags.push("local"); + if (setFlags.length > 1) { + return yield* Effect.fail( + new LegacyInspectMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }), + ); + } + + // Go's `--linked` defaults to true, so absence of `--db-url`/`--local` resolves + // to the linked project. Exclusivity above is already keyed off the raw flags, + // so deriving the default here does not re-trigger it. + const linked = flags.linked || (Option.isNone(flags.dbUrl) && !flags.local); + + const cfg = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + linked, + local: flags.local, + dnsResolver, + }); + + const rows = yield* Effect.scoped( + Effect.gen(function* () { + // Go's `ConnectByConfig` writes "Connecting to database..." + // to os.Stderr before dialing (`connect.go:205-228`). stdout is reserved + // for the rendered table (the machine payload in json modes), so this + // diagnostic always goes to stderr regardless of output mode. + yield* output.raw( + `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, + "stderr", + ); + const session = yield* dbConn.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); + return yield* session.query(spec.sql, spec.params(cfg)); + }), + ); + + if (output.format === "text") { + const cells = rows.map((row) => spec.project(row, cfg)); + yield* output.raw(renderGlamourTable(spec.headers, cells)); + return; + } + + // json / stream-json — emit the raw driver rows (snake_case keys). TS-extra: + // Go has no `--output-format` for inspect, so this is additive. + yield* output.success(`inspect db ${spec.name}`, { rows }); +}); + +/** + * The cobra deprecation line emitted to stderr before a deprecated alias runs: + * `Command "%q" is deprecated, %s\n` where `%s` is the alias's `Deprecated` field + * (`use "" instead.`). Centralized so the single format string tracks Go's + * `command.go` template rather than living as 12 independent literals. + * See `apps/cli-go/cmd/inspect.go:139-245`. + */ +export function legacyInspectDeprecationNotice(alias: string, target: string): string { + return `Command "${alias}" is deprecated, use "${target}" instead.\n`; +} + +/** + * Builds an `inspect db ` handler from its spec. Each active subcommand and + * each deprecated alias gets its own `Effect.fn` trace span (`legacy.inspect.db.`) + * and flushes telemetry on completion (success or failure), matching Go's + * `PersistentPostRun` — callers must NOT add a second `Effect.ensuring(flush)` at + * the command level. Deprecated aliases pass `deprecation`, the exact cobra stderr + * line (build it with `legacyInspectDeprecationNotice`) emitted before the query runs. + */ +export function legacyMakeInspectDbHandler( + spec: LegacyInspectQuerySpec, + traceName: string, + deprecation?: string, +) { + return Effect.fn(traceName)(function* (flags: LegacyInspectConnectionFlags) { + const dnsResolver = yield* LegacyDnsResolverFlag; + const telemetryState = yield* LegacyTelemetryState; + yield* Effect.gen(function* () { + if (deprecation !== undefined) { + const output = yield* Output; + yield* output.raw(deprecation, "stderr"); + } + yield* legacyRunInspectQuery(spec, flags, dnsResolver); + }).pipe(Effect.ensuring(telemetryState.flush)); + }); +} diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.unit.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.unit.test.ts new file mode 100644 index 0000000000..bbeeb3162e --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-query.unit.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from "vitest"; + +import { + legacyInspectBacktickStmt, + legacyInspectBool, + legacyInspectFloat1, + legacyInspectInt, + legacyInspectPlainText, + legacyInspectStmt, + legacyInspectText, +} from "./legacy-inspect-query.ts"; +import { legacyVacuumStatsSpec } from "./vacuum-stats/vacuum-stats.query.ts"; + +describe("legacyInspectText (backtick-wrapped `%s`)", () => { + it("passes a non-empty value through with its backticks stripped (glamour)", () => { + expect(legacyInspectText("hello")).toBe("hello"); + expect(legacyInspectText(42)).toBe("42"); + }); + it("renders an empty/null value as the two literal backticks of an empty code span", () => { + // Go wraps the cell as `` `%s` ``; an empty code span isn't a valid token, so + // glamour emits the two backtick characters literally (e.g. role-stats + // `custom_config` for the `postgres` row). Matches `role_stats.go:43`. + expect(legacyInspectText("")).toBe("``"); + expect(legacyInspectText(null)).toBe("``"); + expect(legacyInspectText(undefined)).toBe("``"); + }); +}); + +describe("legacyInspectPlainText (unwrapped `%s`)", () => { + it("passes strings through and renders null/undefined as empty", () => { + // The unwrapped columns (vacuum_stats timestamps) have no code span, so an + // empty value stays empty rather than `` `` ``. + expect(legacyInspectPlainText("2024-01-01 00:00")).toBe("2024-01-01 00:00"); + expect(legacyInspectPlainText("")).toBe(""); + expect(legacyInspectPlainText(null)).toBe(""); + expect(legacyInspectPlainText(undefined)).toBe(""); + }); +}); + +describe("legacyInspectBool (%t)", () => { + it("renders booleans as true/false", () => { + expect(legacyInspectBool(true)).toBe("true"); + expect(legacyInspectBool(false)).toBe("false"); + }); + it("treats null/undefined as the false zero value", () => { + expect(legacyInspectBool(null)).toBe("false"); + expect(legacyInspectBool(undefined)).toBe("false"); + }); + it("stringifies any other type", () => { + expect(legacyInspectBool("t")).toBe("t"); + }); +}); + +describe("legacyInspectInt (%d)", () => { + it("passes numbers, numeric strings, and bigints through in base 10", () => { + expect(legacyInspectInt(5)).toBe("5"); + expect(legacyInspectInt("123")).toBe("123"); + expect(legacyInspectInt(5n)).toBe("5"); + }); + it("renders null/undefined as the zero value", () => { + expect(legacyInspectInt(null)).toBe("0"); + expect(legacyInspectInt(undefined)).toBe("0"); + }); + it("stringifies a non-finite number without throwing", () => { + expect(legacyInspectInt(Number.NaN)).toBe("NaN"); + }); +}); + +describe("legacyInspectFloat1 (%.1f)", () => { + it("formats numbers, numeric strings, and bigints to one decimal", () => { + expect(legacyInspectFloat1(12)).toBe("12.0"); + expect(legacyInspectFloat1(0.04)).toBe("0.0"); + expect(legacyInspectFloat1("3")).toBe("3.0"); + expect(legacyInspectFloat1(2n)).toBe("2.0"); + }); + it("renders null/undefined as the zero value", () => { + expect(legacyInspectFloat1(null)).toBe("0.0"); + expect(legacyInspectFloat1(undefined)).toBe("0.0"); + }); + it("passes a non-numeric string through unchanged", () => { + expect(legacyInspectFloat1("n/a")).toBe("n/a"); + }); + it("stringifies any other type", () => { + expect(legacyInspectFloat1(true)).toBe("true"); + }); +}); + +describe("legacyInspectStmt (whitespace-collapsed %s)", () => { + it("collapses every whitespace run to a single space", () => { + expect(legacyInspectStmt("a\n\tb c")).toBe("a b c"); + expect(legacyInspectStmt("SELECT\n 1")).toBe("SELECT 1"); + }); + it("renders null/undefined as empty", () => { + expect(legacyInspectStmt(null)).toBe(""); + expect(legacyInspectStmt(undefined)).toBe(""); + }); + it("leaves a literal pipe in place (renderGlamourTable takes clean cells)", () => { + expect(legacyInspectStmt("a | b")).toBe("a | b"); + }); + it("replaces each vertical tab individually (Go's RE2 `\\s` excludes `\\v`)", () => { + // Go's regex appends `|\v` because RE2 `\s` does not match `\v`; consecutive + // vertical tabs therefore collapse to one space each, not a single space. + expect(legacyInspectStmt("a\v\vb")).toBe("a b"); + // A space-then-vtab is a space run then an individual vtab → two spaces. + expect(legacyInspectStmt("a \vb")).toBe("a b"); + }); + it("leaves a non-breaking space untouched (not in Go's `\\s`)", () => { + expect(legacyInspectStmt("a b")).toBe("a b"); + }); +}); + +describe("legacyInspectBacktickStmt (backtick-wrapped, whitespace-collapsed `%s`)", () => { + it("collapses whitespace like legacyInspectStmt for a non-empty statement", () => { + // calls/outliers wrap the query in `` `%s` `` (calls.go:52), so a populated + // statement renders bare with its runs collapsed. + expect(legacyInspectBacktickStmt("SELECT\n 1")).toBe("SELECT 1"); + }); + it("renders an empty/null statement as the two literal backticks", () => { + expect(legacyInspectBacktickStmt("")).toBe("``"); + expect(legacyInspectBacktickStmt(null)).toBe("``"); + expect(legacyInspectBacktickStmt(undefined)).toBe("``"); + }); +}); + +describe("legacyVacuumStatsSpec rowcount projection", () => { + const cfg = { + conn: { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", + }, + isLocal: true, + }; + const row = (rowcount: string) => ({ + name: "public.t", + last_vacuum: "", + last_autovacuum: "", + last_analyze: "", + last_autoanalyze: "", + rowcount, + dead_rowcount: "0", + autovacuum_threshold: "0", + expect_autovacuum: "no", + autoanalyze_threshold: "0", + expect_autoanalyze: "no", + }); + + it("replaces the first `-1` substring within the padded to_char output", () => { + // `to_char(reltuples, '9G999G999G999')` right-justifies in a fixed width, so a + // -1 reltuples comes back space-padded. Go's `strings.Replace(..., 1)` rewrites + // only the first `-1` substring; the projection must match byte-for-byte. + const cells = legacyVacuumStatsSpec.project(row(" -1"), cfg); + expect(cells[5]).toBe(" No stats"); + }); + + it("leaves a real formatted count untouched", () => { + const cells = legacyVacuumStatsSpec.project(row(" 1,234,567"), cfg); + expect(cells[5]).toBe(" 1,234,567"); + }); +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-schemas.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-schemas.ts new file mode 100644 index 0000000000..3479541302 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-schemas.ts @@ -0,0 +1,54 @@ +/** + * Internal Postgres schemas the `inspect db` queries exclude, and the LIKE-escape + * helper that turns them into `LIKE ANY($1)` exclusion patterns. + * + * 1:1 port of Go's `utils.InternalSchemas` (`apps/cli-go/pkg/migration/dump.go:21-53`) + * and `reset.LikeEscapeSchema` (`apps/cli-go/internal/db/reset/reset.go:259-266`). + * The order is preserved verbatim because the escaped array is passed straight + * through to `LIKE ANY($1)`, where order is observable in nothing but is kept + * identical to avoid any drift from the Go source. + */ +export const LEGACY_INTERNAL_SCHEMAS: ReadonlyArray = [ + "information_schema", + "pg_*", // Wildcard pattern follows pg_dump + // Initialised by supabase/postgres image and owned by postgres role + "_analytics", + "_realtime", + "_supavisor", + "auth", + "etl", + "extensions", + "pgbouncer", + "realtime", + "storage", + "supabase_functions", + "supabase_migrations", + // Owned by extensions + "cron", + "dbdev", + "graphql", + "graphql_public", + "net", + "pgmq", + "pgsodium", + "pgsodium_masks", + "pgtle", + "repack", + "tiger", + "tiger_data", + "timescaledb_*", + "_timescaledb_*", + "topology", + "vault", +]; + +/** + * Escapes each schema name into a SQL `LIKE` pattern, treating `_` as a literal + * underscore (`\_`) and `*` as the any-character wildcard (`%`). Mirrors Go's + * `strings.NewReplacer("_", "\\_", "*", "%")` — both replacements are applied to + * the original string, and since `_`→`\_` introduces only a backslash (not a `*`) + * and `*`→`%` introduces only a `%`, sequential JS replaces are equivalent. + */ +export function legacyLikeEscapeSchema(schemas: ReadonlyArray): ReadonlyArray { + return schemas.map((schema) => schema.replace(/_/g, "\\_").replace(/\*/g, "%")); +} diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-schemas.unit.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-schemas.unit.test.ts new file mode 100644 index 0000000000..e71326d751 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-schemas.unit.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; + +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "./legacy-inspect-schemas.ts"; + +describe("legacyLikeEscapeSchema", () => { + it("escapes underscores as literals and stars as the any-character wildcard", () => { + expect(legacyLikeEscapeSchema(["pg_*"])).toEqual(["pg\\_%"]); + expect(legacyLikeEscapeSchema(["_timescaledb_*"])).toEqual(["\\_timescaledb\\_%"]); + expect(legacyLikeEscapeSchema(["timescaledb_*"])).toEqual(["timescaledb\\_%"]); + expect(legacyLikeEscapeSchema(["supabase_functions"])).toEqual(["supabase\\_functions"]); + }); + + it("leaves a plain schema name untouched", () => { + expect(legacyLikeEscapeSchema(["auth"])).toEqual(["auth"]); + }); + + it("escapes the full internal-schema set", () => { + const escaped = legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS); + expect(escaped).toHaveLength(LEGACY_INTERNAL_SCHEMAS.length); + // No raw `_` or `*` survives; every original `_` becomes `\_` and `*` becomes `%`. + for (const pattern of escaped) { + expect(pattern).not.toMatch(/\*/); + expect(pattern).not.toMatch(/(? { + it("matches the Go `utils.InternalSchemas` list (29 entries, in order)", () => { + expect(LEGACY_INTERNAL_SCHEMAS).toEqual([ + "information_schema", + "pg_*", + "_analytics", + "_realtime", + "_supavisor", + "auth", + "etl", + "extensions", + "pgbouncer", + "realtime", + "storage", + "supabase_functions", + "supabase_migrations", + "cron", + "dbdev", + "graphql", + "graphql_public", + "net", + "pgmq", + "pgsodium", + "pgsodium_masks", + "pgtle", + "repack", + "tiger", + "tiger_data", + "timescaledb_*", + "_timescaledb_*", + "topology", + "vault", + ]); + }); +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts new file mode 100644 index 0000000000..2b1dbc1874 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/legacy-inspect-specs.integration.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import type { + LegacyDbConfigFlags, + LegacyResolvedDbConfig, +} from "../../../shared/legacy-db-config.types.ts"; +import { + LegacyDbConnection, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { legacyBloatSpec } from "./bloat/bloat.query.ts"; +import { legacyBlockingSpec } from "./blocking/blocking.query.ts"; +import { legacyCallsSpec } from "./calls/calls.query.ts"; +import { legacyDbStatsSpec } from "./db-stats/db-stats.query.ts"; +import { legacyIndexStatsSpec } from "./index-stats/index-stats.query.ts"; +import { LEGACY_INTERNAL_SCHEMAS } from "./legacy-inspect-schemas.ts"; +import { legacyRunInspectQuery, type LegacyInspectQuerySpec } from "./legacy-inspect-query.ts"; +import { legacyLocksSpec } from "./locks/locks.query.ts"; +import { legacyLongRunningQueriesSpec } from "./long-running-queries/long-running-queries.query.ts"; +import { legacyOutliersSpec } from "./outliers/outliers.query.ts"; +import { legacyReplicationSlotsSpec } from "./replication-slots/replication-slots.query.ts"; +import { legacyRoleStatsSpec } from "./role-stats/role-stats.query.ts"; +import { legacyTableStatsSpec } from "./table-stats/table-stats.query.ts"; +import { legacyTrafficProfileSpec } from "./traffic-profile/traffic-profile.query.ts"; +import { legacyVacuumStatsSpec } from "./vacuum-stats/vacuum-stats.query.ts"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; + +function setup(rows: ReadonlyArray>) { + const out = mockOutput({ format: "text" }); + let querySql: string | undefined; + let queryParams: ReadonlyArray | undefined; + const layer = Layer.mergeAll( + out.layer, + Layer.succeed(LegacyDbConfigResolver, { + resolve: (_flags: LegacyDbConfigFlags) => + Effect.succeed({ conn: LOCAL_CONN, isLocal: true } satisfies LegacyResolvedDbConfig), + }), + Layer.succeed(LegacyDbConnection, { + connect: () => + Effect.succeed({ + exec: () => Effect.void, + extensionExists: () => Effect.succeed(false), + query: (sql: string, params?: ReadonlyArray) => { + querySql = sql; + queryParams = params; + return Effect.succeed(rows); + }, + }), + }), + ); + return { + layer, + out, + get querySql() { + return querySql; + }, + get queryParams() { + return queryParams; + }, + }; +} + +const localFlags = { dbUrl: Option.none(), linked: false, local: true }; + +type ParamKind = "none" | "schemas1" | "schemas2"; + +interface Case { + readonly spec: LegacyInspectQuerySpec; + readonly row: Record; + readonly params: ParamKind; + readonly expect: ReadonlyArray; + readonly absent?: ReadonlyArray; +} + +const cases: ReadonlyArray = [ + { + spec: legacyDbStatsSpec, + params: "schemas2", + row: { + database_size: "8 kB", + total_index_size: "1 kB", + total_table_size: "2 kB", + total_toast_size: "0 bytes", + time_since_stats_reset: "N/A", + index_hit_rate: "0.9", + table_hit_rate: "0.8", + wal_size: "16 MB", + }, + expect: ["postgres", "8 kB", "16 MB", "Database Size"], + }, + { + spec: legacyReplicationSlotsSpec, + params: "none", + row: { + slot_name: "slot1", + active: true, + state: "streaming", + replication_client_address: "10.0.0.1", + replication_lag_gb: "0", + }, + expect: ["slot1", "true", "streaming", "10.0.0.1"], + }, + { + spec: legacyLocksSpec, + params: "none", + row: { + pid: 42, + relname: "public.t", + transactionid: "100", + granted: false, + stmt: "SELECT\n1", + age: "00:01", + }, + expect: ["42", "public.t", "false", "SELECT 1"], + }, + { + spec: legacyBlockingSpec, + params: "none", + row: { + blocked_pid: 1, + blocking_statement: "UPDATE\tx", + blocking_duration: "00:02", + blocking_pid: 2, + blocked_statement: "SELECT y", + blocked_duration: "00:03", + }, + expect: ["UPDATE x", "SELECT y", "00:02"], + }, + { + spec: legacyOutliersSpec, + params: "none", + row: { + query: "SELECT\n *", + total_exec_time: "10ms", + prop_exec_time: "50%", + ncalls: "5", + sync_io_time: "1ms", + }, + expect: ["SELECT *", "10ms", "50%"], + }, + { + spec: legacyCallsSpec, + params: "none", + row: { + query: "INSERT\tINTO t", + total_exec_time: "20ms", + prop_exec_time: "25%", + ncalls: "9", + sync_io_time: "2ms", + }, + expect: ["INSERT INTO t", "20ms", "25%"], + }, + { + spec: legacyIndexStatsSpec, + params: "schemas1", + row: { + name: "public.idx", + size: "8 kB", + percent_used: "50%", + index_scans: "100", + seq_scans: "5", + unused: false, + }, + expect: ["public.idx", "8 kB", "50%", "100", "false"], + }, + { + spec: legacyLongRunningQueriesSpec, + params: "none", + row: { pid: 7, duration: "00:06", query: "SELECT pg_sleep(600)" }, + expect: ["7", "00:06", "SELECT pg_sleep(600)"], + }, + { + spec: legacyBloatSpec, + params: "schemas1", + row: { type: "table", name: "public.t", bloat: "1.5", waste: "100 kB" }, + expect: ["table", "public.t", "1.5", "100 kB"], + }, + { + spec: legacyRoleStatsSpec, + params: "none", + row: { + role_name: "postgres", + active_connections: 3, + connection_limit: 100, + custom_config: "search_path=public", + }, + expect: ["postgres", "3", "100", "search_path=public"], + }, + { + spec: legacyVacuumStatsSpec, + params: "schemas1", + row: { + name: "public.t", + last_vacuum: "2024-01-01 00:00", + last_autovacuum: "", + last_analyze: "", + last_autoanalyze: "", + // Padded as Postgres `to_char(reltuples, '9G999G999G999')` returns it for -1. + rowcount: " -1", + dead_rowcount: "0", + autovacuum_threshold: "777", + expect_autovacuum: "no", + autoanalyze_threshold: "888", + expect_autoanalyze: "no", + }, + expect: ["public.t", "No stats", "2024-01-01 00:00"], + // The two threshold columns are dropped (Go renders only 9 of 11 columns). + absent: ["777", "888"], + }, + { + spec: legacyTableStatsSpec, + params: "schemas1", + row: { + name: "public.t", + table_size: "8 kB", + index_size: "2 kB", + total_size: "10 kB", + estimated_row_count: 1000, + seq_scans: 5, + }, + expect: ["public.t", "8 kB", "10 kB", "1000"], + }, + { + spec: legacyTrafficProfileSpec, + params: "none", + row: { + schemaname: "public", + table_name: "t", + blocks_read: 100, + write_tuples: 50, + blocks_write: 12, + activity_ratio: "1:1 (Balanced)", + }, + expect: ["public", "100", "50", "12.0", "1:1 (Balanced)"], + }, +]; + +describe("legacy inspect db specs (per-subcommand correctness)", () => { + it("covers all 13 active subcommands", () => { + expect(cases).toHaveLength(13); + }); + + for (const testCase of cases) { + it.live(`runs the ${testCase.spec.name} query and renders its cells`, () => { + const ctx = setup([testCase.row]); + return Effect.gen(function* () { + yield* legacyRunInspectQuery(testCase.spec, localFlags, "native"); + + // The embedded SQL is sent verbatim. + expect(ctx.querySql).toBe(testCase.spec.sql); + + // Query parameters match the subcommand's shape. + if (testCase.params === "none") { + expect(ctx.queryParams).toEqual([]); + } else { + const params = ctx.queryParams ?? []; + expect(params[0]).toHaveLength(LEGACY_INTERNAL_SCHEMAS.length); + if (testCase.params === "schemas2") { + expect(params).toHaveLength(2); + expect(params[1]).toBe("postgres"); + } else { + expect(params).toHaveLength(1); + } + } + + // The rendered table contains the headers and the projected cells. + for (const header of testCase.spec.headers) { + expect(ctx.out.stdoutText).toContain(header); + } + for (const cell of testCase.expect) { + expect(ctx.out.stdoutText).toContain(cell); + } + for (const missing of testCase.absent ?? []) { + expect(ctx.out.stdoutText).not.toContain(missing); + } + }).pipe(Effect.provide(ctx.layer)); + }); + } +}); diff --git a/apps/cli/src/legacy/commands/inspect/db/locks/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/locks/SIDE_EFFECTS.md deleted file mode 100644 index 2c81be5f25..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/locks/SIDE_EFFECTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# `supabase inspect db locks` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Runs the locks query directly against the linked (or local) Postgres database and -prints a table showing queries that have taken out exclusive locks on a relation. - -### `--output-format json` - -Not applicable — this command queries Postgres directly and outputs tabular text. - -### `--output-format stream-json` - -Not applicable — this command queries Postgres directly and outputs tabular text. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/locks/locks.command.ts b/apps/cli/src/legacy/commands/inspect/db/locks/locks.command.ts index 696170451b..0aef50c792 100644 --- a/apps/cli/src/legacy/commands/inspect/db/locks/locks.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/locks/locks.command.ts @@ -1,22 +1,14 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbLocks } from "./locks.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbLocksFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbLocksCommand = Command.make("locks", config).pipe( +export const legacyInspectDbLocksCommand = Command.make("locks", LEGACY_INSPECT_DB_FLAGS).pipe( Command.withDescription("Show queries which have taken out an exclusive lock on a relation."), Command.withShortDescription("Show exclusive locks"), - Command.withHandler((flags) => legacyInspectDbLocks(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbLocks)), + Command.provide(legacyInspectDbRuntimeLayer("locks")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/locks/locks.handler.ts b/apps/cli/src/legacy/commands/inspect/db/locks/locks.handler.ts index 93e446fd90..7ded020bcc 100644 --- a/apps/cli/src/legacy/commands/inspect/db/locks/locks.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/locks/locks.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbLocksFlags } from "./locks.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyLocksSpec } from "./locks.query.ts"; -export const legacyInspectDbLocks = Effect.fn("legacy.inspect.db.locks")(function* ( - flags: LegacyInspectDbLocksFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "locks"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbLocks = legacyMakeInspectDbHandler( + legacyLocksSpec, + "legacy.inspect.db.locks", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/locks/locks.query.ts b/apps/cli/src/legacy/commands/inspect/db/locks/locks.query.ts new file mode 100644 index 0000000000..06de1ec927 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/locks/locks.query.ts @@ -0,0 +1,41 @@ +import { + legacyInspectBool, + legacyInspectInt, + legacyInspectStmt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/locks/locks.sql`. +const SQL = `SELECT + pg_stat_activity.pid, + COALESCE(pg_class.relname, 'null') AS relname, + COALESCE(pg_locks.transactionid::text, 'null') AS transactionid, + pg_locks.granted, + pg_stat_activity.query AS stmt, + age(now(), pg_stat_activity.query_start)::text AS age +FROM pg_stat_activity, pg_locks LEFT OUTER JOIN pg_class ON (pg_locks.relation = pg_class.oid) +WHERE pg_stat_activity.query <> '' +AND pg_locks.pid = pg_stat_activity.pid +AND pg_locks.mode = 'ExclusiveLock' +ORDER BY query_start`; + +/** + * `inspect db locks` — queries holding an exclusive lock on a relation. + * Port of `apps/cli-go/internal/inspect/locks/locks.go`. The `stmt` column is + * whitespace-collapsed; the rest render via their `fmt` verbs. + */ +export const legacyLocksSpec: LegacyInspectQuerySpec = { + name: "locks", + sql: SQL, + params: () => [], + headers: ["pid", "relname", "transaction id", "granted", "stmt", "age"], + project: (row) => [ + legacyInspectInt(row["pid"]), + legacyInspectText(row["relname"]), + legacyInspectText(row["transactionid"]), + legacyInspectBool(row["granted"]), + legacyInspectStmt(row["stmt"]), + legacyInspectText(row["age"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/long-running-queries/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/long-running-queries/SIDE_EFFECTS.md deleted file mode 100644 index 240515b144..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/long-running-queries/SIDE_EFFECTS.md +++ /dev/null @@ -1,44 +0,0 @@ -# `supabase inspect db long-running-queries` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table of currently running queries that have been running for longer than 5 minutes. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.command.ts b/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.command.ts index 431ca67fe3..7f4bb19ca8 100644 --- a/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.command.ts @@ -1,25 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbLongRunningQueries } from "./long-running-queries.handler.ts"; - -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbLongRunningQueriesFlags = CliCommand.Command.Config.Infer; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; export const legacyInspectDbLongRunningQueriesCommand = Command.make( "long-running-queries", - config, + LEGACY_INSPECT_DB_FLAGS, ).pipe( Command.withDescription("Show currently running queries running for longer than 5 minutes."), Command.withShortDescription("Show long-running queries"), - Command.withHandler((flags) => legacyInspectDbLongRunningQueries(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbLongRunningQueries)), + Command.provide(legacyInspectDbRuntimeLayer("long-running-queries")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.handler.ts b/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.handler.ts index ab43a6e89a..6dd8ab4318 100644 --- a/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbLongRunningQueriesFlags } from "./long-running-queries.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyLongRunningQueriesSpec } from "./long-running-queries.query.ts"; -export const legacyInspectDbLongRunningQueries = Effect.fn( +export const legacyInspectDbLongRunningQueries = legacyMakeInspectDbHandler( + legacyLongRunningQueriesSpec, "legacy.inspect.db.long-running-queries", -)(function* (flags: LegacyInspectDbLongRunningQueriesFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "long-running-queries"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +); diff --git a/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.query.ts b/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.query.ts new file mode 100644 index 0000000000..da7f22a8b4 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/long-running-queries/long-running-queries.query.ts @@ -0,0 +1,37 @@ +import { + legacyInspectInt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/long_running_queries/long_running_queries.sql`. +const SQL = `SELECT + pid, + age(now(), pg_stat_activity.query_start)::text AS duration, + query AS query +FROM + pg_stat_activity +WHERE + pg_stat_activity.query <> ''::text + AND state <> 'idle' + AND age(now(), pg_stat_activity.query_start) > interval '5 minutes' +ORDER BY + age(now(), pg_stat_activity.query_start) DESC`; + +/** + * `inspect db long-running-queries` — queries running longer than 5 minutes. + * Port of `apps/cli-go/internal/inspect/long_running_queries/long_running_queries.go`. + * Note: unlike locks/blocking/outliers/calls, the `query` column is NOT + * whitespace-collapsed in Go (`%s` directly), so it uses `legacyInspectText`. + */ +export const legacyLongRunningQueriesSpec: LegacyInspectQuerySpec = { + name: "long-running-queries", + sql: SQL, + params: () => [], + headers: ["pid", "Duration", "Query"], + project: (row) => [ + legacyInspectInt(row["pid"]), + legacyInspectText(row["duration"]), + legacyInspectText(row["query"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/outliers/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/outliers/SIDE_EFFECTS.md deleted file mode 100644 index 0c8d095977..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/outliers/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db outliers` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table of queries from pg_stat_statements ordered by total execution time. - -## Notes - -- Requires `pg_stat_statements` extension to be enabled on the database. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.command.ts b/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.command.ts index 0a63a67242..c8e39079a0 100644 --- a/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.command.ts @@ -1,22 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbOutliers } from "./outliers.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbOutliersFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbOutliersCommand = Command.make("outliers", config).pipe( +export const legacyInspectDbOutliersCommand = Command.make( + "outliers", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription("Show queries from pg_stat_statements ordered by total execution time."), Command.withShortDescription("Show query outliers by time"), - Command.withHandler((flags) => legacyInspectDbOutliers(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbOutliers)), + Command.provide(legacyInspectDbRuntimeLayer("outliers")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.handler.ts b/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.handler.ts index eef30688ac..5aa241f89f 100644 --- a/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbOutliersFlags } from "./outliers.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyOutliersSpec } from "./outliers.query.ts"; -export const legacyInspectDbOutliers = Effect.fn("legacy.inspect.db.outliers")(function* ( - flags: LegacyInspectDbOutliersFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "outliers"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbOutliers = legacyMakeInspectDbHandler( + legacyOutliersSpec, + "legacy.inspect.db.outliers", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.query.ts b/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.query.ts new file mode 100644 index 0000000000..19f2c33259 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/outliers/outliers.query.ts @@ -0,0 +1,52 @@ +import { + legacyInspectBacktickStmt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/outliers/outliers.sql`. +const SQL = `SELECT + (interval '1 millisecond' * total_exec_time)::text AS total_exec_time, + to_char((total_exec_time/sum(total_exec_time) OVER()) * 100, 'FM90D0') || '%' AS prop_exec_time, + to_char(calls, 'FM999G999G999G990') AS ncalls, + /* + Handle column names for 15 and 17 + */ + ( + interval '1 millisecond' * ( + COALESCE( + (to_jsonb(s) ->> 'shared_blk_read_time')::double precision, + (to_jsonb(s) ->> 'blk_read_time')::double precision, + 0 + ) + + + COALESCE( + (to_jsonb(s) ->> 'shared_blk_write_time')::double precision, + (to_jsonb(s) ->> 'blk_write_time')::double precision, + 0 + ) + ) + )::text AS sync_io_time, + query +FROM extensions.pg_stat_statements s WHERE userid = (SELECT usesysid FROM pg_user WHERE usename = current_user LIMIT 1) +ORDER BY total_exec_time DESC +LIMIT 10`; + +/** + * `inspect db outliers` — pg_stat_statements ordered by total execution time. + * Port of `apps/cli-go/internal/inspect/outliers/outliers.go`. The `query` + * column is whitespace-collapsed and rendered first. + */ +export const legacyOutliersSpec: LegacyInspectQuerySpec = { + name: "outliers", + sql: SQL, + params: () => [], + headers: ["Query", "Execution Time", "Proportion of exec time", "Number Calls", "Sync IO time"], + project: (row) => [ + legacyInspectBacktickStmt(row["query"]), + legacyInspectText(row["total_exec_time"]), + legacyInspectText(row["prop_exec_time"]), + legacyInspectText(row["ncalls"]), + legacyInspectText(row["sync_io_time"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/replication-slots/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/replication-slots/SIDE_EFFECTS.md deleted file mode 100644 index c5d447bb58..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/replication-slots/SIDE_EFFECTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# `supabase inspect db replication-slots` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Runs the replication-slots query directly against the linked (or local) Postgres database -and prints a table showing information about replication slots. - -### `--output-format json` - -Not applicable — this command queries Postgres directly and outputs tabular text. - -### `--output-format stream-json` - -Not applicable — this command queries Postgres directly and outputs tabular text. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.command.ts b/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.command.ts index b18a77b056..4ea87d0a0a 100644 --- a/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.command.ts @@ -1,25 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbReplicationSlots } from "./replication-slots.handler.ts"; - -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbReplicationSlotsFlags = CliCommand.Command.Config.Infer; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; export const legacyInspectDbReplicationSlotsCommand = Command.make( "replication-slots", - config, + LEGACY_INSPECT_DB_FLAGS, ).pipe( Command.withDescription("Show information about replication slots on the database."), Command.withShortDescription("Show replication slots"), - Command.withHandler((flags) => legacyInspectDbReplicationSlots(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbReplicationSlots)), + Command.provide(legacyInspectDbRuntimeLayer("replication-slots")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.handler.ts b/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.handler.ts index e640915bea..2b28cb3007 100644 --- a/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbReplicationSlotsFlags } from "./replication-slots.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyReplicationSlotsSpec } from "./replication-slots.query.ts"; -export const legacyInspectDbReplicationSlots = Effect.fn("legacy.inspect.db.replication-slots")( - function* (flags: LegacyInspectDbReplicationSlotsFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "replication-slots"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbReplicationSlots = legacyMakeInspectDbHandler( + legacyReplicationSlotsSpec, + "legacy.inspect.db.replication-slots", ); diff --git a/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.query.ts b/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.query.ts new file mode 100644 index 0000000000..3940bff05b --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/replication-slots/replication-slots.query.ts @@ -0,0 +1,36 @@ +import { + legacyInspectBool, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/replication_slots/replication_slots.sql`. +const SQL = `SELECT + s.slot_name, + s.active, + COALESCE(r.state, 'N/A') as state, + CASE WHEN r.client_addr IS NULL + THEN 'N/A' + ELSE r.client_addr::text + END replication_client_address, + GREATEST(0, ROUND((redo_lsn-restart_lsn)/1024/1024/1024, 2)) as replication_lag_gb +FROM pg_control_checkpoint(), pg_replication_slots s +LEFT JOIN pg_stat_replication r ON (r.pid = s.active_pid)`; + +/** + * `inspect db replication-slots` — replication slot status. + * Port of `apps/cli-go/internal/inspect/replication_slots/replication_slots.go`. + */ +export const legacyReplicationSlotsSpec: LegacyInspectQuerySpec = { + name: "replication-slots", + sql: SQL, + params: () => [], + headers: ["Name", "Active", "State", "Replication Client Address", "Replication Lag GB"], + project: (row) => [ + legacyInspectText(row["slot_name"]), + legacyInspectBool(row["active"]), + legacyInspectText(row["state"]), + legacyInspectText(row["replication_client_address"]), + legacyInspectText(row["replication_lag_gb"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/role-configs/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/role-configs/SIDE_EFFECTS.md deleted file mode 100644 index 67a28e2179..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/role-configs/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db role-configs` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `role-stats` internally. Prints configuration settings for modified database roles. - -## Notes - -- Deprecated: use `role-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.command.ts b/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.command.ts index af9591c352..dc20c7734e 100644 --- a/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbRoleConfigs } from "./role-configs.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbRoleConfigsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbRoleConfigsCommand = Command.make("role-configs", config).pipe( +export const legacyInspectDbRoleConfigsCommand = Command.make( + "role-configs", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show configuration settings for database roles when they have been modified. Deprecated: use "role-stats" instead.', ), Command.withShortDescription("Show role configs (deprecated)"), - Command.withHandler((flags) => legacyInspectDbRoleConfigs(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbRoleConfigs)), + Command.provide(legacyInspectDbRuntimeLayer("role-configs")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.handler.ts b/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.handler.ts index 460b7c8581..b83a4730ef 100644 --- a/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/role-configs/role-configs.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbRoleConfigsFlags } from "./role-configs.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyRoleStatsSpec } from "../role-stats/role-stats.query.ts"; -export const legacyInspectDbRoleConfigs = Effect.fn("legacy.inspect.db.role-configs")(function* ( - flags: LegacyInspectDbRoleConfigsFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "role-configs"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbRoleConfigs = legacyMakeInspectDbHandler( + legacyRoleStatsSpec, + "legacy.inspect.db.role-configs", + legacyInspectDeprecationNotice("role-configs", "role-stats"), +); diff --git a/apps/cli/src/legacy/commands/inspect/db/role-connections/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/role-connections/SIDE_EFFECTS.md deleted file mode 100644 index af05990162..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/role-connections/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db role-connections` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `role-stats` internally. Prints number of active connections for all database roles. - -## Notes - -- Deprecated: use `role-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.command.ts b/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.command.ts index ef07c5b68e..de872572e8 100644 --- a/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbRoleConnections } from "./role-connections.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbRoleConnectionsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbRoleConnectionsCommand = Command.make("role-connections", config).pipe( +export const legacyInspectDbRoleConnectionsCommand = Command.make( + "role-connections", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show number of active connections for all database roles. Deprecated: use "role-stats" instead.', ), Command.withShortDescription("Show role connections (deprecated)"), - Command.withHandler((flags) => legacyInspectDbRoleConnections(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbRoleConnections)), + Command.provide(legacyInspectDbRuntimeLayer("role-connections")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.handler.ts b/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.handler.ts index f6a2aea631..c81c164f3d 100644 --- a/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/role-connections/role-connections.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbRoleConnectionsFlags } from "./role-connections.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyRoleStatsSpec } from "../role-stats/role-stats.query.ts"; -export const legacyInspectDbRoleConnections = Effect.fn("legacy.inspect.db.role-connections")( - function* (flags: LegacyInspectDbRoleConnectionsFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "role-connections"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbRoleConnections = legacyMakeInspectDbHandler( + legacyRoleStatsSpec, + "legacy.inspect.db.role-connections", + legacyInspectDeprecationNotice("role-connections", "role-stats"), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/role-stats/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/role-stats/SIDE_EFFECTS.md deleted file mode 100644 index b432e4ce88..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/role-stats/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db role-stats` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table of information about roles on the database, including configurations -and active connections. Supersedes `role-configs` and `role-connections`. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.command.ts b/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.command.ts index da138ffa8d..2abe3da1dd 100644 --- a/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.command.ts @@ -1,22 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbRoleStats } from "./role-stats.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbRoleStatsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbRoleStatsCommand = Command.make("role-stats", config).pipe( +export const legacyInspectDbRoleStatsCommand = Command.make( + "role-stats", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription("Show information about roles on the database."), Command.withShortDescription("Show role stats"), - Command.withHandler((flags) => legacyInspectDbRoleStats(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbRoleStats)), + Command.provide(legacyInspectDbRuntimeLayer("role-stats")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.handler.ts b/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.handler.ts index 4a3aaa9d50..e0ec930ecd 100644 --- a/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbRoleStatsFlags } from "./role-stats.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyRoleStatsSpec } from "./role-stats.query.ts"; -export const legacyInspectDbRoleStats = Effect.fn("legacy.inspect.db.role-stats")(function* ( - flags: LegacyInspectDbRoleStatsFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "role-stats"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbRoleStats = legacyMakeInspectDbHandler( + legacyRoleStatsSpec, + "legacy.inspect.db.role-stats", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.query.ts b/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.query.ts new file mode 100644 index 0000000000..b13f74bda1 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/role-stats/role-stats.query.ts @@ -0,0 +1,43 @@ +import { + legacyInspectInt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/role_stats/role_stats.sql`. +const SQL = `SELECT + rolname as role_name, + ( + SELECT + count(*) + FROM + pg_stat_activity + WHERE + pg_roles.rolname = pg_stat_activity.usename + ) AS active_connections, + CASE WHEN rolconnlimit = -1 + THEN current_setting('max_connections')::int8 + ELSE rolconnlimit + END AS connection_limit, + array_to_string(rolconfig, ',', '*') as custom_config +FROM + pg_roles +ORDER BY 1 DESC`; + +/** + * `inspect db role-stats` — roles, connection counts/limits, and custom config. + * Port of `apps/cli-go/internal/inspect/role_stats/role_stats.go`. Also the + * routed query for the deprecated `role-configs` / `role-connections` aliases. + */ +export const legacyRoleStatsSpec: LegacyInspectQuerySpec = { + name: "role-stats", + sql: SQL, + params: () => [], + headers: ["Role name", "Active connections", "Connection limit", "Custom config"], + project: (row) => [ + legacyInspectText(row["role_name"]), + legacyInspectInt(row["active_connections"]), + legacyInspectInt(row["connection_limit"]), + legacyInspectText(row["custom_config"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/seq-scans/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/seq-scans/SIDE_EFFECTS.md deleted file mode 100644 index c976b6a7fe..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/seq-scans/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db seq-scans` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `index-stats` internally. Prints number of sequential scans recorded against all tables. - -## Notes - -- Deprecated: use `index-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.command.ts b/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.command.ts index 33259d4016..bd5a8178b8 100644 --- a/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbSeqScans } from "./seq-scans.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbSeqScansFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbSeqScansCommand = Command.make("seq-scans", config).pipe( +export const legacyInspectDbSeqScansCommand = Command.make( + "seq-scans", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show number of sequential scans recorded against all tables. Deprecated: use "index-stats" instead.', ), Command.withShortDescription("Show sequential scans (deprecated)"), - Command.withHandler((flags) => legacyInspectDbSeqScans(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbSeqScans)), + Command.provide(legacyInspectDbRuntimeLayer("seq-scans")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.handler.ts b/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.handler.ts index ea043d5d47..cd650bbc67 100644 --- a/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/seq-scans/seq-scans.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbSeqScansFlags } from "./seq-scans.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyIndexStatsSpec } from "../index-stats/index-stats.query.ts"; -export const legacyInspectDbSeqScans = Effect.fn("legacy.inspect.db.seq-scans")(function* ( - flags: LegacyInspectDbSeqScansFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "seq-scans"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbSeqScans = legacyMakeInspectDbHandler( + legacyIndexStatsSpec, + "legacy.inspect.db.seq-scans", + legacyInspectDeprecationNotice("seq-scans", "index-stats"), +); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/SIDE_EFFECTS.md deleted file mode 100644 index 5cf75342bd..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db table-index-sizes` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `table-stats` internally. Prints index sizes of individual tables. - -## Notes - -- Deprecated: use `table-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts b/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts index be89950354..184bdf3ca8 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbTableIndexSizes } from "./table-index-sizes.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbTableIndexSizesFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbTableIndexSizesCommand = Command.make("table-index-sizes", config).pipe( +export const legacyInspectDbTableIndexSizesCommand = Command.make( + "table-index-sizes", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show index sizes of individual tables. Deprecated: use "table-stats" instead.', ), Command.withShortDescription("Show table index sizes (deprecated)"), - Command.withHandler((flags) => legacyInspectDbTableIndexSizes(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbTableIndexSizes)), + Command.provide(legacyInspectDbRuntimeLayer("table-index-sizes")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.handler.ts b/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.handler.ts index 3acb166cee..6efb7f3375 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-index-sizes/table-index-sizes.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbTableIndexSizesFlags } from "./table-index-sizes.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyTableStatsSpec } from "../table-stats/table-stats.query.ts"; -export const legacyInspectDbTableIndexSizes = Effect.fn("legacy.inspect.db.table-index-sizes")( - function* (flags: LegacyInspectDbTableIndexSizesFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "table-index-sizes"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbTableIndexSizes = legacyMakeInspectDbHandler( + legacyTableStatsSpec, + "legacy.inspect.db.table-index-sizes", + legacyInspectDeprecationNotice("table-index-sizes", "table-stats"), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-record-counts/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/table-record-counts/SIDE_EFFECTS.md deleted file mode 100644 index af29fb6f8d..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/table-record-counts/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db table-record-counts` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `table-stats` internally. Prints estimated number of rows per table. - -## Notes - -- Deprecated: use `table-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.command.ts b/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.command.ts index 546d1ef0cb..a7d119b26a 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.command.ts @@ -1,27 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbTableRecordCounts } from "./table-record-counts.handler.ts"; - -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbTableRecordCountsFlags = CliCommand.Command.Config.Infer; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; export const legacyInspectDbTableRecordCountsCommand = Command.make( "table-record-counts", - config, + LEGACY_INSPECT_DB_FLAGS, ).pipe( Command.withDescription( 'Show estimated number of rows per table. Deprecated: use "table-stats" instead.', ), Command.withShortDescription("Show table record counts (deprecated)"), - Command.withHandler((flags) => legacyInspectDbTableRecordCounts(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbTableRecordCounts)), + Command.provide(legacyInspectDbRuntimeLayer("table-record-counts")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.handler.ts b/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.handler.ts index 58b1e90c82..9ff4dc6a20 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-record-counts/table-record-counts.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbTableRecordCountsFlags } from "./table-record-counts.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyIndexStatsSpec } from "../index-stats/index-stats.query.ts"; -export const legacyInspectDbTableRecordCounts = Effect.fn("legacy.inspect.db.table-record-counts")( - function* (flags: LegacyInspectDbTableRecordCountsFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "table-record-counts"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbTableRecordCounts = legacyMakeInspectDbHandler( + legacyIndexStatsSpec, + "legacy.inspect.db.table-record-counts", + legacyInspectDeprecationNotice("table-record-counts", "table-stats"), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-sizes/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/table-sizes/SIDE_EFFECTS.md deleted file mode 100644 index f88eaeab9f..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/table-sizes/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db table-sizes` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `table-stats` internally. Prints table sizes without index sizes. - -## Notes - -- Deprecated: use `table-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.command.ts b/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.command.ts index 42dbb107ca..eafedaf16f 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbTableSizes } from "./table-sizes.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbTableSizesFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbTableSizesCommand = Command.make("table-sizes", config).pipe( +export const legacyInspectDbTableSizesCommand = Command.make( + "table-sizes", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show table sizes of individual tables without their index sizes. Deprecated: use "table-stats" instead.', ), Command.withShortDescription("Show table sizes (deprecated)"), - Command.withHandler((flags) => legacyInspectDbTableSizes(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbTableSizes)), + Command.provide(legacyInspectDbRuntimeLayer("table-sizes")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.handler.ts b/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.handler.ts index c000c5539b..4fcc35da0d 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-sizes/table-sizes.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbTableSizesFlags } from "./table-sizes.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyTableStatsSpec } from "../table-stats/table-stats.query.ts"; -export const legacyInspectDbTableSizes = Effect.fn("legacy.inspect.db.table-sizes")(function* ( - flags: LegacyInspectDbTableSizesFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "table-sizes"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbTableSizes = legacyMakeInspectDbHandler( + legacyTableStatsSpec, + "legacy.inspect.db.table-sizes", + legacyInspectDeprecationNotice("table-sizes", "table-stats"), +); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-stats/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/table-stats/SIDE_EFFECTS.md deleted file mode 100644 index 63ca95b91e..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/table-stats/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db table-stats` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table showing combined table size, index size, and estimated row count. -Supersedes `table-sizes`, `table-index-sizes`, `total-table-sizes`, and `table-record-counts`. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.command.ts b/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.command.ts index bb215a7cec..7a50ac50d0 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.command.ts @@ -1,22 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbTableStats } from "./table-stats.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbTableStatsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbTableStatsCommand = Command.make("table-stats", config).pipe( +export const legacyInspectDbTableStatsCommand = Command.make( + "table-stats", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription("Show combined table size, index size, and estimated row count."), Command.withShortDescription("Show table stats"), - Command.withHandler((flags) => legacyInspectDbTableStats(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbTableStats)), + Command.provide(legacyInspectDbRuntimeLayer("table-stats")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.handler.ts b/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.handler.ts index 8df1f951de..f65ecd6b2c 100644 --- a/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbTableStatsFlags } from "./table-stats.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyTableStatsSpec } from "./table-stats.query.ts"; -export const legacyInspectDbTableStats = Effect.fn("legacy.inspect.db.table-stats")(function* ( - flags: LegacyInspectDbTableStatsFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "table-stats"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbTableStats = legacyMakeInspectDbHandler( + legacyTableStatsSpec, + "legacy.inspect.db.table-stats", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.query.ts b/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.query.ts new file mode 100644 index 0000000000..a002ad8111 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/table-stats/table-stats.query.ts @@ -0,0 +1,57 @@ +import { + legacyInspectInt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "../legacy-inspect-schemas.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/table_stats/table_stats.sql`. +const SQL = `SELECT + ts.name, + pg_size_pretty(ts.table_size_bytes) AS table_size, + pg_size_pretty(ts.index_size_bytes) AS index_size, + pg_size_pretty(ts.total_size_bytes) AS total_size, + COALESCE(rc.estimated_row_count, 0) AS estimated_row_count, + COALESCE(rc.seq_scans, 0) AS seq_scans +FROM ( + SELECT + FORMAT('%I.%I', n.nspname, c.relname) AS name, + pg_table_size(c.oid) AS table_size_bytes, + pg_indexes_size(c.oid) AS index_size_bytes, + pg_total_relation_size(c.oid) AS total_size_bytes + FROM pg_class c + LEFT JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE NOT n.nspname LIKE ANY($1) + AND c.relkind = 'r' +) ts +LEFT JOIN ( + SELECT + FORMAT('%I.%I', schemaname, relname) AS name, + n_live_tup AS estimated_row_count, + seq_scan AS seq_scans + FROM pg_stat_user_tables + WHERE NOT schemaname LIKE ANY($1) +) rc ON rc.name = ts.name +ORDER BY ts.total_size_bytes DESC`; + +/** + * `inspect db table-stats` — combined table size, index size, and row count. + * Port of `apps/cli-go/internal/inspect/table_stats/table_stats.go`. Also the + * routed query for the deprecated `table-sizes` / `table-index-sizes` / + * `total-table-sizes` aliases (but NOT `table-record-counts`, which Go routes to + * index-stats — preserved in that alias's handler). + */ +export const legacyTableStatsSpec: LegacyInspectQuerySpec = { + name: "table-stats", + sql: SQL, + params: () => [legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS)], + headers: ["Name", "Table size", "Index size", "Total size", "Estimated row count", "Seq scans"], + project: (row) => [ + legacyInspectText(row["name"]), + legacyInspectText(row["table_size"]), + legacyInspectText(row["index_size"]), + legacyInspectText(row["total_size"]), + legacyInspectInt(row["estimated_row_count"]), + legacyInspectInt(row["seq_scans"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/total-index-size/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/total-index-size/SIDE_EFFECTS.md deleted file mode 100644 index 80f38d9a6e..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/total-index-size/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db total-index-size` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `index-stats` internally. Prints total size of all indexes. - -## Notes - -- Deprecated: use `index-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.command.ts b/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.command.ts index b5aa1da48e..ba580315a6 100644 --- a/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.command.ts @@ -1,22 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbTotalIndexSize } from "./total-index-size.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbTotalIndexSizeFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbTotalIndexSizeCommand = Command.make("total-index-size", config).pipe( +export const legacyInspectDbTotalIndexSizeCommand = Command.make( + "total-index-size", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription('Show total size of all indexes. Deprecated: use "index-stats" instead.'), Command.withShortDescription("Show total index size (deprecated)"), - Command.withHandler((flags) => legacyInspectDbTotalIndexSize(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbTotalIndexSize)), + Command.provide(legacyInspectDbRuntimeLayer("total-index-size")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.handler.ts b/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.handler.ts index 75ab34b2d1..94aa8736c2 100644 --- a/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/total-index-size/total-index-size.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbTotalIndexSizeFlags } from "./total-index-size.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyIndexStatsSpec } from "../index-stats/index-stats.query.ts"; -export const legacyInspectDbTotalIndexSize = Effect.fn("legacy.inspect.db.total-index-size")( - function* (flags: LegacyInspectDbTotalIndexSizeFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "total-index-size"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbTotalIndexSize = legacyMakeInspectDbHandler( + legacyIndexStatsSpec, + "legacy.inspect.db.total-index-size", + legacyInspectDeprecationNotice("total-index-size", "index-stats"), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/SIDE_EFFECTS.md deleted file mode 100644 index 10ee5db205..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db total-table-sizes` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `table-stats` internally. Prints total table sizes including index sizes. - -## Notes - -- Deprecated: use `table-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts b/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts index eb86a368d2..ac90b4bf03 100644 --- a/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.command.ts @@ -1,24 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbTotalTableSizes } from "./total-table-sizes.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbTotalTableSizesFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbTotalTableSizesCommand = Command.make("total-table-sizes", config).pipe( +export const legacyInspectDbTotalTableSizesCommand = Command.make( + "total-table-sizes", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( 'Show total table sizes, including table index sizes. Deprecated: use "table-stats" instead.', ), Command.withShortDescription("Show total table sizes (deprecated)"), - Command.withHandler((flags) => legacyInspectDbTotalTableSizes(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbTotalTableSizes)), + Command.provide(legacyInspectDbRuntimeLayer("total-table-sizes")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.handler.ts b/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.handler.ts index be2f2b8895..72e04160a9 100644 --- a/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/total-table-sizes/total-table-sizes.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbTotalTableSizesFlags } from "./total-table-sizes.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyTableStatsSpec } from "../table-stats/table-stats.query.ts"; -export const legacyInspectDbTotalTableSizes = Effect.fn("legacy.inspect.db.total-table-sizes")( - function* (flags: LegacyInspectDbTotalTableSizesFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "total-table-sizes"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbTotalTableSizes = legacyMakeInspectDbHandler( + legacyTableStatsSpec, + "legacy.inspect.db.total-table-sizes", + legacyInspectDeprecationNotice("total-table-sizes", "table-stats"), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/traffic-profile/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/traffic-profile/SIDE_EFFECTS.md deleted file mode 100644 index b20a3ca827..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/traffic-profile/SIDE_EFFECTS.md +++ /dev/null @@ -1,44 +0,0 @@ -# `supabase inspect db traffic-profile` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table showing read/write activity ratio for tables based on block I/O operations. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.command.ts b/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.command.ts index 0ab03e7b51..3100c214cc 100644 --- a/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.command.ts @@ -1,21 +1,19 @@ -import { Command, Flag } from "effect/unstable/cli"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbTrafficProfile } from "./traffic-profile.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export const legacyInspectDbTrafficProfileCommand = Command.make("traffic-profile", config).pipe( +export const legacyInspectDbTrafficProfileCommand = Command.make( + "traffic-profile", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription( "Show read/write activity ratio for tables based on block I/O operations.", ), Command.withShortDescription("Show traffic profile"), - Command.withHandler((flags) => legacyInspectDbTrafficProfile(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbTrafficProfile)), + Command.provide(legacyInspectDbRuntimeLayer("traffic-profile")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.handler.ts b/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.handler.ts index 854b0a8f30..95d8520db3 100644 --- a/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.handler.ts @@ -1,19 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyTrafficProfileSpec } from "./traffic-profile.query.ts"; -interface LegacyInspectDbTrafficProfileFlags { - readonly dbUrl: Option.Option; - readonly linked: boolean; - readonly local: boolean; -} - -export const legacyInspectDbTrafficProfile = Effect.fn("legacy.inspect.db.traffic-profile")( - function* (flags: LegacyInspectDbTrafficProfileFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "traffic-profile"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbTrafficProfile = legacyMakeInspectDbHandler( + legacyTrafficProfileSpec, + "legacy.inspect.db.traffic-profile", ); diff --git a/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.query.ts b/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.query.ts new file mode 100644 index 0000000000..5d211eddd4 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/traffic-profile/traffic-profile.query.ts @@ -0,0 +1,73 @@ +import { + legacyInspectFloat1, + legacyInspectInt, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/traffic_profile/traffic_profile.sql`. +const SQL = ` -- Query adapted from Crunchy Data blog: "Is Postgres Read Heavy or Write Heavy? (And Why You Should Care)" by David Christensen +WITH +ratio_target AS (SELECT 5 AS ratio), +table_list AS (SELECT + s.schemaname, + s.relname AS table_name, + si.heap_blks_read + si.idx_blks_read AS blocks_read, +s.n_tup_ins + s.n_tup_upd + s.n_tup_del AS write_tuples, +relpages * (s.n_tup_ins + s.n_tup_upd + s.n_tup_del ) / (case when reltuples = 0 then 1 else reltuples end) as blocks_write +FROM + pg_stat_user_tables AS s +JOIN pg_statio_user_tables AS si ON s.relid = si.relid +JOIN pg_class c ON c.oid = s.relid +WHERE +(s.n_tup_ins + s.n_tup_upd + s.n_tup_del) > 0 +AND + (si.heap_blks_read + si.idx_blks_read) > 0 + ) +SELECT + schemaname, + table_name, + blocks_read, + write_tuples, + blocks_write, + CASE + WHEN blocks_read = 0 and blocks_write = 0 THEN + 'No Activity' + WHEN blocks_write * ratio > blocks_read THEN + CASE + WHEN blocks_read = 0 THEN 'Write-Only' + ELSE + ROUND(blocks_write :: numeric / blocks_read :: numeric, 1)::text || ':1 (Write-Heavy)' + END + WHEN blocks_read > blocks_write * ratio THEN + CASE + WHEN blocks_write = 0 THEN 'Read-Only' + ELSE + '1:' || ROUND(blocks_read::numeric / blocks_write :: numeric, 1)::text || ' (Read-Heavy)' + END + ELSE + '1:1 (Balanced)' + END AS activity_ratio +FROM table_list, ratio_target +ORDER BY + (blocks_read + blocks_write) DESC`; + +/** + * `inspect db traffic-profile` — read/write activity ratio per table. + * Port of `apps/cli-go/internal/inspect/traffic_profile/traffic_profile.go`. The + * `blocks_write` column is formatted with one decimal place (`%.1f`). + */ +export const legacyTrafficProfileSpec: LegacyInspectQuerySpec = { + name: "traffic-profile", + sql: SQL, + params: () => [], + headers: ["Schema", "Table", "Blocks Read", "Write Tuples", "Blocks Write", "Activity Ratio"], + project: (row) => [ + legacyInspectText(row["schemaname"]), + legacyInspectText(row["table_name"]), + legacyInspectInt(row["blocks_read"]), + legacyInspectInt(row["write_tuples"]), + legacyInspectFloat1(row["blocks_write"]), + legacyInspectText(row["activity_ratio"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/inspect/db/unused-indexes/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/unused-indexes/SIDE_EFFECTS.md deleted file mode 100644 index 542c3ffbe0..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/unused-indexes/SIDE_EFFECTS.md +++ /dev/null @@ -1,45 +0,0 @@ -# `supabase inspect db unused-indexes` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Deprecated. Delegates to `index-stats` internally. Prints indexes with low usage. - -## Notes - -- Deprecated: use `index-stats` instead. -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.command.ts b/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.command.ts index d965692fa8..c588b352db 100644 --- a/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.command.ts @@ -1,22 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbUnusedIndexes } from "./unused-indexes.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbUnusedIndexesFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbUnusedIndexesCommand = Command.make("unused-indexes", config).pipe( +export const legacyInspectDbUnusedIndexesCommand = Command.make( + "unused-indexes", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription('Show indexes with low usage. Deprecated: use "index-stats" instead.'), Command.withShortDescription("Show unused indexes (deprecated)"), - Command.withHandler((flags) => legacyInspectDbUnusedIndexes(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbUnusedIndexes)), + Command.provide(legacyInspectDbRuntimeLayer("unused-indexes")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.handler.ts b/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.handler.ts index fa66456571..58351fec2e 100644 --- a/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/unused-indexes/unused-indexes.handler.ts @@ -1,14 +1,11 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbUnusedIndexesFlags } from "./unused-indexes.command.ts"; +import { + legacyInspectDeprecationNotice, + legacyMakeInspectDbHandler, +} from "../legacy-inspect-query.ts"; +import { legacyIndexStatsSpec } from "../index-stats/index-stats.query.ts"; -export const legacyInspectDbUnusedIndexes = Effect.fn("legacy.inspect.db.unused-indexes")( - function* (flags: LegacyInspectDbUnusedIndexesFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "unused-indexes"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); - }, +export const legacyInspectDbUnusedIndexes = legacyMakeInspectDbHandler( + legacyIndexStatsSpec, + "legacy.inspect.db.unused-indexes", + legacyInspectDeprecationNotice("unused-indexes", "index-stats"), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/SIDE_EFFECTS.md deleted file mode 100644 index 03ee718430..0000000000 --- a/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/SIDE_EFFECTS.md +++ /dev/null @@ -1,44 +0,0 @@ -# `supabase inspect db vacuum-stats` - -## Files Read - -| Path | Format | When | -| --------------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/.supabase/config.json` | JSON | always, to resolve linked project ref | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -Queries are run directly against the Postgres database (not via Management API). - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | --------------------------------------------- | -| `0` | success — query results printed to stdout | -| `1` | database connection failure | -| `1` | missing `--project-ref` and no linked project | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints a table of statistics related to vacuum operations per table. - -## Notes - -- Queries the Postgres database directly using `--db-url`, `--linked` (default), or `--local`. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary via `LegacyGoProxy`. diff --git a/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts b/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts index 43fe22ffd0..6bed816117 100644 --- a/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts +++ b/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.command.ts @@ -1,22 +1,17 @@ -import { Command, Flag } from "effect/unstable/cli"; -import type * as CliCommand from "effect/unstable/cli/Command"; +import { Command } from "effect/unstable/cli"; import { legacyInspectDbVacuumStats } from "./vacuum-stats.handler.ts"; +import { + LEGACY_INSPECT_DB_FLAGS, + legacyInspectDbCommandHandler, +} from "../legacy-inspect-db-command.ts"; +import { legacyInspectDbRuntimeLayer } from "../db.layers.ts"; -const config = { - dbUrl: Flag.string("db-url").pipe( - Flag.withDescription( - "Inspect the database specified by the connection string (must be percent-encoded).", - ), - Flag.optional, - ), - linked: Flag.boolean("linked").pipe(Flag.withDescription("Inspect the linked project.")), - local: Flag.boolean("local").pipe(Flag.withDescription("Inspect the local database.")), -} as const; - -export type LegacyInspectDbVacuumStatsFlags = CliCommand.Command.Config.Infer; - -export const legacyInspectDbVacuumStatsCommand = Command.make("vacuum-stats", config).pipe( +export const legacyInspectDbVacuumStatsCommand = Command.make( + "vacuum-stats", + LEGACY_INSPECT_DB_FLAGS, +).pipe( Command.withDescription("Show statistics related to vacuum operations per table."), Command.withShortDescription("Show vacuum stats"), - Command.withHandler((flags) => legacyInspectDbVacuumStats(flags)), + Command.withHandler(legacyInspectDbCommandHandler(legacyInspectDbVacuumStats)), + Command.provide(legacyInspectDbRuntimeLayer("vacuum-stats")), ); diff --git a/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.handler.ts b/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.handler.ts index 03f625453c..c3113847a3 100644 --- a/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.handler.ts +++ b/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.handler.ts @@ -1,14 +1,7 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../../shared/legacy/go-proxy.service.ts"; -import type { LegacyInspectDbVacuumStatsFlags } from "./vacuum-stats.command.ts"; +import { legacyMakeInspectDbHandler } from "../legacy-inspect-query.ts"; +import { legacyVacuumStatsSpec } from "./vacuum-stats.query.ts"; -export const legacyInspectDbVacuumStats = Effect.fn("legacy.inspect.db.vacuum-stats")(function* ( - flags: LegacyInspectDbVacuumStatsFlags, -) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["inspect", "db", "vacuum-stats"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - yield* proxy.exec(args); -}); +export const legacyInspectDbVacuumStats = legacyMakeInspectDbHandler( + legacyVacuumStatsSpec, + "legacy.inspect.db.vacuum-stats", +); diff --git a/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.query.ts b/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.query.ts new file mode 100644 index 0000000000..62151524f9 --- /dev/null +++ b/apps/cli/src/legacy/commands/inspect/db/vacuum-stats/vacuum-stats.query.ts @@ -0,0 +1,110 @@ +import { + legacyInspectPlainText, + legacyInspectText, + type LegacyInspectQuerySpec, +} from "../legacy-inspect-query.ts"; +import { LEGACY_INTERNAL_SCHEMAS, legacyLikeEscapeSchema } from "../legacy-inspect-schemas.ts"; + +// Verbatim from `apps/cli-go/internal/inspect/vacuum_stats/vacuum_stats.sql`. +const SQL = `WITH table_opts AS ( + SELECT + pg_class.oid, relname, nspname, array_to_string(reloptions, '') AS relopts + FROM + pg_class INNER JOIN pg_namespace ns ON relnamespace = ns.oid +), vacuum_settings AS ( + SELECT + oid, relname, nspname, + CASE + WHEN relopts LIKE '%autovacuum_vacuum_threshold%' + THEN substring(relopts, '.*autovacuum_vacuum_threshold=([0-9.]+).*')::integer + ELSE current_setting('autovacuum_vacuum_threshold')::integer + END AS autovacuum_vacuum_threshold, + CASE + WHEN relopts LIKE '%autovacuum_vacuum_scale_factor%' + THEN substring(relopts, '.*autovacuum_vacuum_scale_factor=([0-9.]+).*')::real + ELSE current_setting('autovacuum_vacuum_scale_factor')::real + END AS autovacuum_vacuum_scale_factor, + CASE + WHEN relopts LIKE '%autovacuum_analyze_threshold%' + THEN substring(relopts, '.*autovacuum_analyze_threshold=([0-9.]+).*')::integer + ELSE current_setting('autovacuum_analyze_threshold')::integer + END AS autovacuum_analyze_threshold, + CASE + WHEN relopts LIKE '%autovacuum_analyze_scale_factor%' + THEN substring(relopts, '.*autovacuum_analyze_scale_factor=([0-9.]+).*')::real + ELSE current_setting('autovacuum_analyze_scale_factor')::real + END AS autovacuum_analyze_scale_factor + FROM + table_opts +) +SELECT + FORMAT('%I.%I', vacuum_settings.nspname, vacuum_settings.relname) AS name, + coalesce(to_char(psut.last_vacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_vacuum, + coalesce(to_char(psut.last_autovacuum, 'YYYY-MM-DD HH24:MI'), '') AS last_autovacuum, + coalesce(to_char(psut.last_analyze, 'YYYY-MM-DD HH24:MI'), '') AS last_analyze, + coalesce(to_char(psut.last_autoanalyze, 'YYYY-MM-DD HH24:MI'), '') AS last_autoanalyze, + to_char(pg_class.reltuples, '9G999G999G999') AS rowcount, + to_char(psut.n_dead_tup, '9G999G999G999') AS dead_rowcount, + to_char(autovacuum_vacuum_threshold + + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples), '9G999G999G999') AS autovacuum_threshold, + CASE + WHEN autovacuum_vacuum_threshold + (autovacuum_vacuum_scale_factor::numeric * pg_class.reltuples) < psut.n_dead_tup + THEN 'yes' + ELSE 'no' + END AS expect_autovacuum, + to_char(autovacuum_analyze_threshold + + (autovacuum_analyze_scale_factor::numeric * pg_class.reltuples), '9G999G999G999') AS autoanalyze_threshold, + CASE + WHEN autovacuum_analyze_threshold + (autovacuum_analyze_scale_factor::numeric * pg_class.reltuples) < psut.n_dead_tup + THEN 'yes' + ELSE 'no' + END AS expect_autoanalyze +FROM + pg_stat_user_tables psut INNER JOIN pg_class ON psut.relid = pg_class.oid +INNER JOIN vacuum_settings ON pg_class.oid = vacuum_settings.oid +WHERE NOT vacuum_settings.nspname LIKE ANY($1) +ORDER BY + case + when pg_class.reltuples = -1 then 1 + else 0 + end, + 1`; + +/** + * `inspect db vacuum-stats` — per-table vacuum statistics. + * Port of `apps/cli-go/internal/inspect/vacuum_stats/vacuum_stats.go`. The query + * returns 11 columns but only 9 are rendered (Go drops `autovacuum_threshold` + * and `autoanalyze_threshold`). The `rowcount` cell has a one-shot `-1` → `No + * stats` replacement (Go's `strings.Replace(..., 1)`). + */ +export const legacyVacuumStatsSpec: LegacyInspectQuerySpec = { + name: "vacuum-stats", + sql: SQL, + params: () => [legacyLikeEscapeSchema(LEGACY_INTERNAL_SCHEMAS)], + headers: [ + "Table", + "Last Vacuum", + "Last Auto Vacuum", + "Last Analyze", + "Last Auto Analyze", + "Row count", + "Dead row count", + "Expect autovacuum?", + "Expect autoanalyze?", + ], + project: (row) => [ + legacyInspectText(row["name"]), + // Go writes these four timestamp columns as bare `%s|` (no backtick code span, + // `vacuum_stats.go:53`), so an empty value stays empty rather than `` `` ``. + legacyInspectPlainText(row["last_vacuum"]), + legacyInspectPlainText(row["last_autovacuum"]), + legacyInspectPlainText(row["last_analyze"]), + legacyInspectPlainText(row["last_autoanalyze"]), + // One-shot `-1` → `No stats` (JS String.replace with a string replaces only + // the first occurrence, matching Go's `strings.Replace(..., 1)`). + legacyInspectText(row["rowcount"]).replace("-1", "No stats"), + legacyInspectText(row["dead_rowcount"]), + legacyInspectText(row["expect_autovacuum"]), + legacyInspectText(row["expect_autoanalyze"]), + ], +}; diff --git a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md index 46350c6f81..0726aaa348 100644 --- a/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/create/SIDE_EFFECTS.md @@ -14,10 +14,10 @@ ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------- | ------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------- | -| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — interactive org prompt only | -| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?}` (JSON) | `{id, ref, name, organization_slug, region, created_at, status}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------- | ------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------- | +| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — interactive org prompt only | +| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?, high_availability?}` (JSON) | `{id, ref, name, organization_slug, region, created_at, status}` | ## Environment Variables @@ -40,21 +40,22 @@ ## Telemetry Events Fired -| Event | When | Notable properties / groups | -| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------ | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id` is telemetry-safe) | +| Event | When | Notable properties / groups | +| ---------------------- | ------------------------------------------ | ------------------------------------------------------------------------------------------ | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--org-id`, `--high-availability` are telemetry-safe) | ## Flags -| Flag | Type | Required (non-interactive) | Description | -| ---------------- | ------ | -------------------------- | ----------------------------------------------- | -| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | -| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | -| `--db-password` | string | yes (non-interactive) | Database password for the project | -| `--region` | enum | yes (non-interactive) | AWS region for the project | -| `--size` | enum | no | Desired instance size | -| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | -| `--plan` | string | no | Plan selection (hidden flag) | +| Flag | Type | Required (non-interactive) | Description | +| --------------------- | ------ | -------------------------- | ----------------------------------------------- | +| `[project name]` | arg | yes (non-interactive) | Name of the project (positional argument) | +| `--org-id` | string | yes (non-interactive) | Organization ID (slug) to create the project in | +| `--db-password` | string | yes (non-interactive) | Database password for the project | +| `--region` | enum | yes (non-interactive) | AWS region for the project | +| `--size` | enum | no | Desired instance size | +| `--high-availability` | bool | no | Enable high availability for the project | +| `--interactive` | bool | no (default: true) | Enable interactive mode (hidden flag) | +| `--plan` | string | no | Plan selection (hidden flag) | ## Output @@ -91,4 +92,5 @@ One `result` event on success. - In non-interactive mode (when stdin is not a TTY or `--interactive=false`), all three flags and the positional project name argument are required. - The `--size` flag, when provided, sets the `desired_instance_size` field in the request body. +- The `--high-availability` flag, when provided, sets the `high_availability` field in the request body. - The `--plan` flag is hidden and reserved. diff --git a/apps/cli/src/legacy/commands/projects/create/create.command.ts b/apps/cli/src/legacy/commands/projects/create/create.command.ts index acbca6b148..2486931973 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.command.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.command.ts @@ -69,6 +69,10 @@ const config = { Flag.withDescription("Select a desired instance size for your project."), Flag.optional, ), + highAvailability: Flag.boolean("high-availability").pipe( + Flag.withDescription("Enable high availability for the project."), + Flag.optional, + ), interactive: Flag.boolean("interactive").pipe( Flag.withDescription("Enables interactive mode."), Flag.withAlias("i"), @@ -95,7 +99,7 @@ export const legacyProjectsCreateCommand = Command.make("create", config).pipe( ]), Command.withHandler((flags) => legacyProjectsCreate(flags).pipe( - withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id"] }), + withLegacyCommandInstrumentation({ flags, safeFlags: ["org-id", "high-availability"] }), withJsonErrorHandling, ), ), diff --git a/apps/cli/src/legacy/commands/projects/create/create.handler.ts b/apps/cli/src/legacy/commands/projects/create/create.handler.ts index 7ad4f783c1..0e5056692f 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.handler.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.handler.ts @@ -30,6 +30,7 @@ export const legacyProjectsCreate = Effect.fn("legacy.projects.create")(function const region = Option.getOrUndefined(flags.region); const dbPassword = Option.getOrElse(flags.dbPassword, () => ""); const size = Option.getOrUndefined(flags.size); + const highAvailability = Option.getOrUndefined(flags.highAvailability); // Non-interactive: Go's PreRunE marks `--org-id`, `--db-password`, // `--region` required and the project name positional `ExactArgs(1)`. @@ -52,6 +53,7 @@ export const legacyProjectsCreate = Effect.fn("legacy.projects.create")(function dbPassword, region, size, + highAvailability, templateUrl: undefined, emitStructuredResult: true, }); diff --git a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts index bac779f8f0..f21138df6c 100644 --- a/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/create/create.integration.test.ts @@ -37,6 +37,7 @@ const BASE_FLAGS: LegacyProjectsCreateFlags = { dbPassword: Option.none(), region: Option.none(), size: Option.none(), + highAvailability: Option.none(), interactive: Option.none(), plan: Option.none(), }; @@ -133,6 +134,36 @@ describe("legacy projects create integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("includes high_availability only when --high-availability is set", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + highAvailability: Option.some(true), + }); + expect(postBody(api)?.high_availability).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --high-availability=false when explicitly set", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyProjectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + highAvailability: Option.some(false), + }); + expect(postBody(api)?.high_availability).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.live("ignores the hidden --plan flag (no-op)", () => { const { layer, api } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/services/services.command.ts b/apps/cli/src/legacy/commands/services/services.command.ts index 3084983d67..f453760add 100644 --- a/apps/cli/src/legacy/commands/services/services.command.ts +++ b/apps/cli/src/legacy/commands/services/services.command.ts @@ -1,9 +1,9 @@ import { Command } from "effect/unstable/cli"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { legacyManagementApiRuntimeLayer } from "../../shared/legacy-management-api-runtime.layer.ts"; import { withLegacyCommandInstrumentation } from "../../telemetry/legacy-command-instrumentation.ts"; import type * as CliCommand from "effect/unstable/cli/Command"; import { legacyServices } from "./services.handler.ts"; +import { legacyServicesRuntimeLayer } from "./services.layers.ts"; const config = {}; export type LegacyServicesFlags = CliCommand.Command.Config.Infer; @@ -14,5 +14,5 @@ export const legacyServicesCommand = Command.make("services", config).pipe( Command.withHandler((flags) => legacyServices(flags).pipe(withLegacyCommandInstrumentation({ flags }), withJsonErrorHandling), ), - Command.provide(legacyManagementApiRuntimeLayer(["services"])), + Command.provide(legacyServicesRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/services/services.integration.test.ts b/apps/cli/src/legacy/commands/services/services.integration.test.ts index 61d2d27161..8ec3101f7e 100644 --- a/apps/cli/src/legacy/commands/services/services.integration.test.ts +++ b/apps/cli/src/legacy/commands/services/services.integration.test.ts @@ -1,14 +1,28 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import { CliOutput, Command } from "effect/unstable/cli"; +import { Stdio } from "effect"; import { Cause, Effect, Exit, Layer, Option } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; -import { LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { LEGACY_GLOBAL_FLAGS, LegacyOutputFlag } from "../../../shared/legacy/global-flags.ts"; +import { + mockAnalytics, + mockOutput, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../tests/helpers/mocks.ts"; import { mockLegacyTelemetryStateTracked } from "../../../../tests/helpers/legacy-mocks.ts"; import { listLocalServiceVersions } from "../../../shared/services/services.shared.ts"; +import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; +import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; +import { legacyServicesCommand } from "./services.command.ts"; import { legacyServices } from "./services.handler.ts"; const LOCAL_POSTGRES_SERVICE = listLocalServiceVersions().find( @@ -50,6 +64,7 @@ function setup( profile: "supabase", apiUrl: "https://api.supabase.com", projectHost: "supabase.co", + poolerHost: "supabase.com", accessToken: Option.none(), projectId: Option.none(), workdir: process.cwd(), @@ -78,6 +93,11 @@ const legacyCredentialsMock = { deleteProjectCredential: () => Effect.succeed(false), }; +const legacyTestRoot = Command.make("supabase").pipe( + Command.withGlobalFlags(LEGACY_GLOBAL_FLAGS), + Command.withSubcommands([legacyServicesCommand]), +); + function expectFailureTag(exit: Exit.Exit, tag: string) { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { @@ -92,6 +112,56 @@ function expectFailureTag(exit: Exit.Exit, tag: string) { } describe("legacy services", () => { + it.effect("runs tokenless local service listing through command wiring", () => + Effect.tryPromise({ + try: async () => { + const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); + const out = mockOutput({ format: "text", interactive: false }); + const analytics = mockAnalytics(); + const args = ["services"]; + const layer = Layer.mergeAll( + BunServices.layer, + CliOutput.layer(textCliOutputFormatter()), + out.layer, + analytics.layer, + processEnvLayer({ SUPABASE_HOME: workdir }), + mockRuntimeInfo({ cwd: workdir, homeDir: workdir }), + mockTty({ stdinIsTty: false, stdoutIsTty: false }), + Stdio.layerTest({ args: Effect.succeed(args) }), + Layer.succeed( + TelemetryRuntime, + TelemetryRuntime.of({ + configDir: join(workdir, ".supabase"), + tracesDir: join(workdir, ".supabase", "traces"), + consent: "granted", + showDebug: false, + deviceId: "test-device-id", + sessionId: "test-session-id", + distinctId: undefined, + isFirstRun: false, + isTty: false, + isCi: false, + os: "linux", + arch: "x64", + cliVersion: "0.1.0", + }), + ), + ); + + await Effect.runPromise( + Command.runWith(legacyTestRoot, { version: "0.0.0-test" })(args).pipe( + Effect.provide(layer), + ) as Effect.Effect, + ); + + expect(out.stdoutText).toContain("supabase/postgres"); + expect(out.stdoutText).toContain("supabase/gotrue"); + expect(out.stderrText).not.toContain("Access token not provided"); + }, + catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), + }), + ); + it.live("prints the services table by default", () => { const { layer, out } = setup(); diff --git a/apps/cli/src/legacy/commands/services/services.layers.ts b/apps/cli/src/legacy/commands/services/services.layers.ts new file mode 100644 index 0000000000..b645780f41 --- /dev/null +++ b/apps/cli/src/legacy/commands/services/services.layers.ts @@ -0,0 +1,60 @@ +import { FetchHttpClient } from "effect/unstable/http"; +import { Layer } from "effect"; +import type * as HttpClient from "effect/unstable/http/HttpClient"; + +import { legacyCredentialsLayer } from "../../auth/legacy-credentials.layer.ts"; +import { LegacyCredentials } from "../../auth/legacy-credentials.service.ts"; +import { legacyCliConfigLayer } from "../../config/legacy-cli-config.layer.ts"; +import { LegacyCliConfig } from "../../config/legacy-cli-config.service.ts"; +import { legacyDebugLoggerLayer } from "../../shared/legacy-debug-logger.layer.ts"; +import { LegacyDebugLogger } from "../../shared/legacy-debug-logger.service.ts"; +import { legacyHttpClientLayer } from "../../auth/legacy-http-debug.layer.ts"; +import { legacyLinkedProjectCacheLayer } from "../../telemetry/legacy-linked-project-cache.layer.ts"; +import { LegacyLinkedProjectCache } from "../../telemetry/legacy-linked-project-cache.service.ts"; +import { legacyTelemetryStateLayer } from "../../telemetry/legacy-telemetry-state.layer.ts"; +import { LegacyTelemetryState } from "../../telemetry/legacy-telemetry-state.service.ts"; +import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; +import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; + +/** + * `services` always prints the local service matrix and only performs linked + * version checks when both a linked project ref and an access token are present. + * Keep this runtime lean so a tokenless local invocation does not fail before + * the handler can choose the local-only path. + */ +export const legacyServicesRuntimeLayer = (() => { + const cliConfig = legacyCliConfigLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const httpClient = legacyHttpClientLayer.pipe(Layer.provide(legacyDebugLoggerLayer)); + const credentials = legacyCredentialsLayer.pipe( + Layer.provide(cliConfig), + Layer.provide(legacyDebugLoggerLayer), + ); + + const built = Layer.mergeAll( + httpClient, + credentials, + cliConfig, + legacyDebugLoggerLayer, + legacyLinkedProjectCacheLayer.pipe( + Layer.provide(credentials), + Layer.provide(cliConfig), + Layer.provide(httpClient), + ), + legacyTelemetryStateLayer, + commandRuntimeLayer(["services"]), + ).pipe(Layer.provide(FetchHttpClient.layer)); + + const _serviceCoverageCheck: Layer.Layer = built; + void _serviceCoverageCheck; + + return built; +})(); + +type LegacyServicesServices = + | HttpClient.HttpClient + | LegacyCredentials + | LegacyCliConfig + | LegacyDebugLogger + | LegacyLinkedProjectCache + | LegacyTelemetryState + | CommandRuntime; diff --git a/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md index 6ae3974ec1..5936a0cfb7 100644 --- a/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/test/db/SIDE_EFFECTS.md @@ -1,11 +1,14 @@ -# `supabase test db [path] ...` +# `supabase test db [path...]` ## Files Read -| Path | Format | When | -| -------------------------------- | ---------- | ------------------------------------------------- | -| `/supabase/tests/*.sql` | SQL | always (test files to run) | -| `~/.supabase/access-token` | plain text | when `--linked` and `SUPABASE_ACCESS_TOKEN` unset | +| Path | Format | When | +| ------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/tests/**/*.{sql,pg}` | SQL | default test discovery when no `[path]` given | +| `` | SQL | when explicit test files/dirs are passed | +| `/supabase/config.toml` | TOML | always: `db.port`, `db.shadow_port`, `db.password`, `project_id`. Absent → defaults; **present but malformed → command fails** (Go's `config.Load` parity) | +| `/supabase/.temp/pooler-url` | text | `--linked` pooler fallback only — the connection-pooler URL written by `supabase link` (Go reads it here, not from config.toml) | +| `~/.supabase/access-token` | text | `--linked` only, when `SUPABASE_ACCESS_TOKEN` unset | ## Files Written @@ -13,46 +16,110 @@ | ---- | ------ | ---- | | — | — | — | -## API Routes +## Database -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---- | ---- | ------------ | ---------------------- | -| — | — | — | — | — | +| Statement | When | +| ------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | +| `select 1 from pg_extension where extname = 'pgtap'` | always, before enabling — pre-existence check (by extension name, any schema) | +| `set session role postgres` | after connect when the user is `supabase_admin` / `cli_login_*` (remote linked temp role) | +| `create extension if not exists pgtap with schema extensions` | always, before running tests | +| `drop extension if exists pgtap` | only if pgTAP did not already exist; failure is logged to stderr, non-fatal | + +## Docker + +One-shot `docker run --rm `, where the image is `supabase/pg_prove:3.36` resolved through the registry (`legacyGetRegistryImageUrl`, mirroring Go's `GetRegistryImageUrl`): `SUPABASE_INTERNAL_IMAGE_REGISTRY` overrides the registry, `docker.io` pulls from Docker Hub unchanged, and the default is `public.ecr.aws/supabase/pg_prove:3.36`. + +- `-v ::ro` for each test path +- `--security-opt label:disable` +- `--network supabase_network_` (local) with env `PGHOST=db PGPORT=5432`, or `--network host` (db-url / linked) with the resolved host/port. `` is sanitized exactly as Go's `config.Load` does (`sanitizeProjectId`), so an invalid configured value (e.g. `"my project"`) joins the same network the local stack created +- `-e PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE` +- cmd `pg_prove --ext .pg --ext .sql -r [--verbose]` (`--verbose` when `--debug`) + +## API Routes (`--linked` only) + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ----------------------------------------- | --------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | access token | `{ read_only: false }` | `{ role, password }` | +| GET | `/v1/projects/{ref}/network-bans` | access token | — | `{ banned_ipv4_addresses }` | +| DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token for `--linked` mode | no (falls back to keyring → `~/.supabase/access-token`) | -| `DB_PASSWORD` | password for direct database connection | no | +| Variable | Purpose | Required? | +| ---------------------------- | -------------------------------------------------------------------- | --------------------------------------------- | +| `SUPABASE_DB_PASSWORD` | `--linked`: skip temporary login-role creation | no | +| `SUPABASE_ACCESS_TOKEN` | `--linked`: Management API auth | no (falls back to keyring/file) | +| `SUPABASE_SERVICES_HOSTNAME` | `--local`: overrides the local DB host (dev-container/remote Docker) | no (defaults via `DOCKER_HOST` → `127.0.0.1`) | +| `DOCKER_HOST` | `--local`: tcp daemon host used when no services-hostname override | no | +| `BITBUCKET_CLONE_DIR` | when set, omit `--security-opt label:disable` (Bitbucket rejects it) | no | +| `DEBUG` / `--debug` | append `--verbose` to `pg_prove` | no | ## Exit Codes -| Code | Condition | -| ---- | --------------------------------- | -| `0` | all tests passed | -| `1` | one or more tests failed | -| `1` | database connection failure | -| `1` | Docker not running or unavailable | +| Code | Condition | +| ---- | ---------------------------------------------------------------------------------------------------- | +| `0` | all pgTAP tests pass | +| `1` | `pg_prove` exits non-zero (test failures) — `error running container: exit N` | +| `1` | `--db-url` / `--linked` / `--local` set together (mutually exclusive) | +| `1` | database connection failure / pgTAP enable failure / docker failure / `--linked` auth or IPv6 errors | ## Output -### `--output-format text` (Go CLI compatible) - -Prints pgTAP test output to stdout, including TAP-format test results. +`pg_prove`'s TAP output streams to **stdout in every output format** (the docker +subprocess inherits stdout), exactly as the Go CLI does — `test db` is a live test +stream with no structured equivalent. -### `--output-format json` +### `--output-format text` (Go CLI compatible) -Not applicable (proxied to Go binary). +TAP streams to stdout. The connection diagnostic `Connecting to {local|remote} database...` +is written to **stderr** (matching Go's `ConnectByConfigStream`), never to stdout — no +spinner is used, so stdout carries only the raw TAP bytes. -### `--output-format stream-json` +### `--output-format json` / `stream-json` -Not applicable (proxied to Go binary). +No machine envelope is emitted (Go has none). stdout carries the raw TAP stream only; the +connection diagnostic still goes to stderr (no task JSON-log events are written to stdout, +which would otherwise corrupt the TAP stream). A non-zero `pg_prove` exit still fails the +command (exit 1). ## Notes -- Runs pgTAP tests on the local (default) or linked database. -- `--local` (default `true`) runs tests on the local database. -- `--linked` runs tests on the linked project database. -- `--db-url` targets a specific database URL directly. -- Phase 0 proxy: all invocations are forwarded to the bundled Go binary. +- Native TypeScript port (Phase 1+); no Go proxy. Hidden command (matches Go). +- Postgres TLS matches Go (`internal/utils/connect.go`): local connections disable TLS + (`ConnectLocalPostgres` sets `cc.TLSConfig = nil`); remote (`--db-url` / `--linked`) + connections honor the URL's `sslmode` (`pgconn.ParseConfig` → `ConnectByUrl`) — + `disable` → plaintext, `verify-ca` / `verify-full` → TLS **with** certificate + verification, and everything else (`prefer` / `require` / unset) → TLS **without** + verification (pgx's default for `prefer`/`require`, non-TLS fallbacks stripped). +- `--db-url` accepts both the WHATWG `postgres(ql)://…` URL form and the libpq + keyword/value DSN form (`host=… dbname=… user=…`, incl. unix-socket paths), matching + Go's `pgconn.ParseConfig`. The `sslmode` and libpq `options` (Supavisor + `?options=reference=`) parameters are preserved on both forms. A malformed URL or + percent escape surfaces as a redacted `failed to parse connection string` error, never + an unhandled defect. +- Multi-host failover connection strings (`postgres://h1:5432,h2:5433/db`, + `host=h1,h2 port=5432,5433`) are supported on both forms, matching pgconn + (`config.go:326-362`): the primary host is dialed first, then each fallback in order, + reusing the first port when a host omits one. +- Password precedence matches pgconn/libpq (`config.go:264-379`): a password supplied by + the connection string — **even an explicit empty one** (`user:@host`, `?password=`, + `password=`) — overrides `PGPASSWORD`; an empty resolved value then falls through to + `.pgpass`. A connection string with no password key at all uses `PGPASSWORD` then + `.pgpass`. +- `--dns-resolver https` (global flag, Go's `utils.DNSResolver`): for remote connections + the DB host is resolved via Cloudflare DNS-over-HTTPS (`https://1.1.1.1/dns-query`) + before dialing, mirroring Go's `cc.LookupFunc = FallbackLookupIP` (`connect.go:211`). + TLS verification still targets the original hostname (via `ssl.servername`). The native + resolver is used for local connections and when the flag is `native` (the default). +- Postgres access uses `@effect/sql-pg`. Go detects "pgTAP already installed" via a + `pgx` `OnNotice` (code 42710 `duplicate_object`) callback, which `@effect/sql-pg` + does not expose; the port instead checks `pg_extension` by extension name (any + schema) before enabling — same observable drop-skip behavior, including when the + user pre-installed pgTAP in a non-`extensions` schema such as `public`. +- The linked connection pooler URL is read from `supabase/.temp/pooler-url` (written by + `supabase link`), matching Go — the `[db.pooler]` config.toml field is `toml:"-"` in Go + and is intentionally ignored. The pooler's `?options=reference=` startup param is + carried through to the connection for the legacy pooler-URL format. +- pg_prove image is fixed at `supabase/pg_prove:3.36`; Go's `[images] pgprove` config + override is not modeled by the TS config schema (documented divergence). +- Go's hidden `--network-id` override is not declared on the TS command (documented divergence). diff --git a/apps/cli/src/legacy/commands/test/db/db.command.ts b/apps/cli/src/legacy/commands/test/db/db.command.ts index 0628a03339..e15aac87f0 100644 --- a/apps/cli/src/legacy/commands/test/db/db.command.ts +++ b/apps/cli/src/legacy/commands/test/db/db.command.ts @@ -1,5 +1,32 @@ +import { Effect, Option } from "effect"; import { Argument, Command, Flag } from "effect/unstable/cli"; +import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { ProcessControl } from "../../../../shared/runtime/process-control.service.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; +import { LegacyTestDbRunError } from "./db.errors.ts"; import { legacyTestDb } from "./db.handler.ts"; +import { legacyTestDbRuntimeLayer } from "../test.layers.ts"; + +/** + * `test db` has no machine-format envelope: its entire output is the streamed + * pg_prove TAP on stdout (Go has no `--output-format` for it). On a *run* failure + * (failing tests), the default `withJsonErrorHandling` would append a JSON error + * object to stdout — after the TAP already streamed — corrupting machine consumers. + * So in json/stream-json mode, send the diagnostic to stderr and exit 1 instead, + * matching Go's `recoverAndExit` (stderr, exit 1). Text mode keeps the normal error + * rendering; pre-stream errors still flow through `withJsonErrorHandling`. + */ +const onRunFailure = (error: LegacyTestDbRunError) => + Effect.gen(function* () { + const output = yield* Output; + if (output.format === "text") return yield* Effect.fail(error); + const processControl = yield* ProcessControl; + yield* output.raw(`${error.message}\n`, "stderr"); + yield* processControl.setExitCode(1); + }); const config = { paths: Argument.string("path").pipe( @@ -20,15 +47,31 @@ const config = { ), } as const; +export interface LegacyTestDbFlags { + readonly paths: ReadonlyArray; + readonly dbUrl: Option.Option; + readonly linked: boolean; + readonly local: boolean; +} + export const legacyTestDbCommand = Command.make("db", config).pipe( Command.withDescription("Run pgTAP tests on the local or linked database."), Command.withShortDescription("Run pgTAP tests"), - Command.withHandler((flags) => + Command.withHandler((flags: CliCommand.Command.Config.Infer) => legacyTestDb({ - paths: flags.paths.map(String), + paths: flags.paths, dbUrl: flags.dbUrl, linked: flags.linked, local: flags.local, - }), + }).pipe( + withLegacyCommandInstrumentation({ + flags: { "db-url": flags.dbUrl, linked: flags.linked, local: flags.local }, + }), + // Run failures (failing tests) must not corrupt the TAP stream on stdout in + // machine modes; other errors (pre-stream) still get the JSON envelope. + Effect.catchTag("LegacyTestDbRunError", onRunFailure), + withJsonErrorHandling, + ), ), + Command.provide(legacyTestDbRuntimeLayer), ); diff --git a/apps/cli/src/legacy/commands/test/db/db.errors.ts b/apps/cli/src/legacy/commands/test/db/db.errors.ts new file mode 100644 index 0000000000..63e3c87d7b --- /dev/null +++ b/apps/cli/src/legacy/commands/test/db/db.errors.ts @@ -0,0 +1,29 @@ +import { Data } from "effect"; + +/** + * `create extension if not exists pgtap` failed. Byte-matches Go's + * `"failed to enable pgTAP: " + err` (`apps/cli-go/internal/db/test/test.go:70`). + */ +export class LegacyTestDbEnablePgtapError extends Data.TaggedError("LegacyTestDbEnablePgtapError")<{ + readonly message: string; +}> {} + +/** + * `pg_prove` exited non-zero (test failures or a container error). Byte-matches + * Go's `"error running container: exit " + code` (`apps/cli-go/internal/utils/docker.go` + * `DockerStreamLogs`). The TAP failure detail is already on stdout. + */ +export class LegacyTestDbRunError extends Data.TaggedError("LegacyTestDbRunError")<{ + readonly message: string; +}> {} + +/** + * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces + * cobra's `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error from + * `apps/cli-go/cmd/db.go:485`, byte-for-byte. + */ +export class LegacyTestDbMutuallyExclusiveFlagsError extends Data.TaggedError( + "LegacyTestDbMutuallyExclusiveFlagsError", +)<{ + readonly message: string; +}> {} diff --git a/apps/cli/src/legacy/commands/test/db/db.handler.ts b/apps/cli/src/legacy/commands/test/db/db.handler.ts index 220973f8be..8b509607e4 100644 --- a/apps/cli/src/legacy/commands/test/db/db.handler.ts +++ b/apps/cli/src/legacy/commands/test/db/db.handler.ts @@ -1,19 +1,200 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; - -interface LegacyTestDbFlags { - readonly paths: ReadonlyArray; - readonly dbUrl: Option.Option; - readonly linked: boolean; - readonly local: boolean; +import * as nodePath from "node:path"; +import { Effect, FileSystem, Option, Path } from "effect"; + +import { LegacyCliConfig } from "../../../config/legacy-cli-config.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { legacyReadDbToml } from "../../../shared/legacy-db-config.toml-read.ts"; +import { LegacyDbConnection } from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDockerRun } from "../../../shared/legacy-docker-run.service.ts"; +import { legacyGetRegistryImageUrl } from "../../../shared/legacy-docker-registry.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, +} from "../../../../shared/legacy/global-flags.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import type { LegacyTestDbFlags } from "./db.command.ts"; +import { + LegacyTestDbEnablePgtapError, + LegacyTestDbMutuallyExclusiveFlagsError, + LegacyTestDbRunError, +} from "./db.errors.ts"; +import { buildLegacyPgProveArgs } from "./db.pg-prove-args.ts"; + +// Go: `apps/cli-go/internal/db/test/test.go:24-25`. +const ENABLE_PGTAP = "create extension if not exists pgtap with schema extensions"; +const DISABLE_PGTAP = "drop extension if exists pgtap"; +// Go bakes this default into the Dockerfile (`pkg/config/templates/Dockerfile:20`). +// The TS config schema does not model an `[images]` override, so it is fixed here. +// Go resolves it through `GetRegistryImageUrl` (`DockerStart`), honoring +// `SUPABASE_INTERNAL_IMAGE_REGISTRY` / the default ECR mirror, so do the same +// before passing it to `docker run`. +const LEGACY_PG_PROVE_IMAGE = "supabase/pg_prove:3.36"; +const MAX_PROJECT_ID_LENGTH = 40; + +/** Port of Go's `sanitizeProjectId` (`pkg/config/config.go:1037`). */ +function sanitizeProjectId(src: string): string { + return src + .replace(/[^a-zA-Z0-9_.-]+/g, "_") + .replace(/^[_.-]+/, "") + .slice(0, MAX_PROJECT_ID_LENGTH); } export const legacyTestDb = Effect.fn("legacy.test.db")(function* (flags: LegacyTestDbFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["test", "db"]; - if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); - if (flags.linked) args.push("--linked"); - if (flags.local) args.push("--local"); - args.push(...flags.paths); - yield* proxy.exec(args); + const output = yield* Output; + const resolver = yield* LegacyDbConfigResolver; + const dbConn = yield* LegacyDbConnection; + const docker = yield* LegacyDockerRun; + const cliConfig = yield* LegacyCliConfig; + const runtimeInfo = yield* RuntimeInfo; + const telemetryState = yield* LegacyTelemetryState; + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const debug = yield* LegacyDebugFlag; + const networkIdFlag = yield* LegacyNetworkIdFlag; + const dnsResolver = yield* LegacyDnsResolverFlag; + + yield* Effect.gen(function* () { + // Reproduce cobra's MarkFlagsMutuallyExclusive("db-url","linked","local") + // (`apps/cli-go/cmd/db.go:485`). `--local` defaults to false in the TS flag + // surface, so a `true` value means it was explicitly passed — matching + // cobra's `Changed` semantics. + const setFlags: Array = []; + if (Option.isSome(flags.dbUrl)) setFlags.push("db-url"); + if (flags.linked) setFlags.push("linked"); + if (flags.local) setFlags.push("local"); + if (setFlags.length > 1) { + return yield* Effect.fail( + new LegacyTestDbMutuallyExclusiveFlagsError({ + message: `if any flags in the group [db-url linked local] are set none of the others can be; [${setFlags.join(" ")}] were all set`, + }), + ); + } + + const { conn, isLocal } = yield* resolver.resolve({ + dbUrl: flags.dbUrl, + linked: flags.linked, + local: flags.local, + dnsResolver, + }); + + const args = buildLegacyPgProveArgs({ + paths: flags.paths, + cwd: runtimeInfo.cwd, + workdir: cliConfig.workdir, + debug, + }); + + // For a local database the pg_prove container joins the supabase docker + // network and reaches postgres via the internal `db:5432` alias; otherwise + // it uses host networking (Go: test.go:79-87). + const runEnv = { + PGHOST: isLocal ? "db" : conn.host, + PGPORT: isLocal ? "5432" : String(conn.port), + PGUSER: conn.user, + PGPASSWORD: conn.password, + PGDATABASE: conn.database, + }; + + // Network selection mirrors Go's DockerRunOnceWithConfig: a non-empty + // `--network-id` overrides everything (even host mode); otherwise local uses + // the generated `supabase_network_` network and remote uses host + // networking (`apps/cli-go/internal/utils/docker.go:267-271`, `test.go:79-87`). + const networkId = Option.getOrUndefined(networkIdFlag); + const network = + networkId !== undefined && networkId.length > 0 + ? { _tag: "named" as const, name: networkId } + : isLocal + ? yield* Effect.gen(function* () { + const toml = yield* legacyReadDbToml(fs, path, cliConfig.workdir); + // Go sanitizes `c.ProjectId` unconditionally (`config.go:471`) — + // whether it came from `config.toml` or the cwd-basename fallback — + // before deriving the network name `supabase_network_` + // (`config.go:57-58`, `GetId`). A configured `project_id` like + // "my project" must join the same sanitized network the local stack + // created, not the literal raw value. + const projectId = sanitizeProjectId( + Option.getOrElse(toml.projectId, () => nodePath.basename(cliConfig.workdir)), + ); + return { _tag: "named" as const, name: `supabase_network_${projectId}` }; + }) + : { _tag: "host" as const }; + + const exitCode = yield* Effect.scoped( + Effect.gen(function* () { + // stdout is reserved for the pg_prove TAP stream (the docker subprocess + // writes it there directly), so connection diagnostics must go to stderr — + // exactly as Go does (`ConnectByConfigStream` writes "Connecting to … + // database…" to `os.Stderr`, `connect.go:205-228`). A `Output.task` + // spinner would corrupt the TAP stream: clack writes spinner ANSI to + // stdout in text mode, and the stream-json layer emits task JSON log + // events to stdout. Go has no "Running pgTAP tests…" line at all. + yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); + const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); + + // Detect pre-existence before enabling so the drop is skipped when pgTAP + // was already installed (Go keys this off an OnNotice 42710 callback, + // which @effect/sql-pg does not expose — equivalent observable result). + // Checked by extension name only, regardless of schema: Go's duplicate-object + // notice fires for any pre-existing pgTAP, so a pgTAP the user installed in + // e.g. `public` must also be detected and left untouched. + const alreadyExists = yield* session.extensionExists("pgtap"); + yield* session.exec(ENABLE_PGTAP).pipe( + Effect.mapError( + (cause) => + new LegacyTestDbEnablePgtapError({ + message: `failed to enable pgTAP: ${cause.message}`, + }), + ), + ); + if (!alreadyExists) { + yield* Effect.addFinalizer(() => + session + .exec(DISABLE_PGTAP) + .pipe( + Effect.catch((cause) => + output.raw(`failed to disable pgTAP: ${cause.message}\n`, "stderr"), + ), + ), + ); + } + + // Bitbucket Pipelines rejects `--security-opt`, so Go clears + // `hostConfig.SecurityOpt` when `BITBUCKET_CLONE_DIR` is set + // (`apps/cli-go/internal/utils/docker.go:288-293`). Match that exactly: + // omit the option in Bitbucket CI, where it would abort container creation. + const inBitbucket = (process.env["BITBUCKET_CLONE_DIR"] ?? "") !== ""; + // Go adds `host.docker.internal:host-gateway` to every container's + // ExtraHosts on Linux (`apps/cli-go/internal/utils/docker_linux.go`); macOS/ + // Windows Docker Desktop provide the mapping natively (empty there). + const extraHosts = + runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; + return yield* docker.run({ + image: legacyGetRegistryImageUrl(LEGACY_PG_PROVE_IMAGE), + cmd: args.cmd, + env: runEnv, + binds: args.binds, + workingDir: args.workingDir, + securityOpt: inBitbucket ? [] : ["label:disable"], + extraHosts, + network, + }); + }), + ); + + // No machine-format envelope: Go has no `--output-format` for `test db`; its + // entire output is the streaming pg_prove TAP, which is emitted to stdout in + // every mode (the docker subprocess inherits stdout). Appending a JSON object + // here would corrupt that stream for `--output-format json` consumers. + + // Non-zero pg_prove exit → fail (exit 1), matching Go's cobra error return. + // The TAP failure detail has already streamed to stdout. + if (exitCode !== 0) { + return yield* Effect.fail( + new LegacyTestDbRunError({ message: `error running container: exit ${exitCode}` }), + ); + } + }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/test/db/db.integration.test.ts b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts new file mode 100644 index 0000000000..06e7316c90 --- /dev/null +++ b/apps/cli/src/legacy/commands/test/db/db.integration.test.ts @@ -0,0 +1,440 @@ +import { mkdirSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BunServices } from "@effect/platform-bun"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + mockLegacyCliConfig, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { + LegacyDebugFlag, + LegacyDnsResolverFlag, + LegacyNetworkIdFlag, +} from "../../../../shared/legacy/global-flags.ts"; +import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; +import { LegacyDbConfigResolver } from "../../../shared/legacy-db-config.service.ts"; +import { + LegacyDbConnectError, + LegacyDbExecError, +} from "../../../shared/legacy-db-connection.errors.ts"; +import { + LegacyDbConnection, + type LegacyDbSession, + type LegacyPgConnInput, +} from "../../../shared/legacy-db-connection.service.ts"; +import { LegacyDockerRunError } from "../../../shared/legacy-docker-run.errors.ts"; +import { + LegacyDockerRun, + type LegacyDockerRunOpts, +} from "../../../shared/legacy-docker-run.service.ts"; +import { legacyTestDb } from "./db.handler.ts"; + +const LOCAL_CONN: LegacyPgConnInput = { + host: "127.0.0.1", + port: 54322, + user: "postgres", + password: "postgres", + database: "postgres", +}; +const REMOTE_CONN: LegacyPgConnInput = { + host: "db.abcdefghijklmnopqrst.supabase.co", + port: 5432, + user: "postgres", + password: "secret", + database: "postgres", +}; + +function mockResolver(opts: { conn?: LegacyPgConnInput; isLocal?: boolean } = {}) { + return Layer.succeed(LegacyDbConfigResolver, { + resolve: () => Effect.succeed({ conn: opts.conn ?? LOCAL_CONN, isLocal: opts.isLocal ?? true }), + }); +} + +function mockDbConnection(opts: { + existed?: boolean; + connectFails?: boolean; + enableFails?: boolean; + dropFails?: boolean; +}) { + const execCalls: string[] = []; + const session: LegacyDbSession = { + exec: (sql) => + Effect.gen(function* () { + execCalls.push(sql); + if (opts.enableFails === true && sql.includes("create extension")) { + return yield* Effect.fail(new LegacyDbExecError({ message: "permission denied" })); + } + if (opts.dropFails === true && sql.includes("drop extension")) { + return yield* Effect.fail(new LegacyDbExecError({ message: "cannot drop" })); + } + }), + extensionExists: () => Effect.succeed(opts.existed ?? false), + query: () => Effect.succeed([]), + }; + const connectCalls: Array<{ + cfg: LegacyPgConnInput; + isLocal: boolean; + dnsResolver: "native" | "https"; + }> = []; + const layer = Layer.succeed(LegacyDbConnection, { + connect: (cfg, options) => { + connectCalls.push({ cfg, isLocal: options.isLocal, dnsResolver: options.dnsResolver }); + return opts.connectFails === true + ? Effect.fail( + new LegacyDbConnectError({ message: "failed to connect to postgres: refused" }), + ) + : Effect.succeed(session); + }, + }); + return { + layer, + get execCalls() { + return execCalls; + }, + get connectCalls() { + return connectCalls; + }, + }; +} + +function mockDockerRun(opts: { exitCode?: number; runFails?: boolean }) { + let lastOpts: LegacyDockerRunOpts | undefined; + const layer = Layer.succeed(LegacyDockerRun, { + run: (runOpts) => { + lastOpts = runOpts; + return opts.runFails === true + ? Effect.fail(new LegacyDockerRunError({ message: "failed to run docker: not found" })) + : Effect.succeed(opts.exitCode ?? 0); + }, + }); + return { + layer, + get lastOpts() { + return lastOpts; + }, + }; +} + +const runtimeInfoLayer = Layer.succeed(RuntimeInfo, { + cwd: "/work/project", + platform: "linux", + arch: "x64", + homeDir: "/home/user", + execPath: "/usr/bin/supabase", + pid: 1234, +}); + +interface SetupOpts { + format?: "text" | "json" | "stream-json"; + conn?: LegacyPgConnInput; + isLocal?: boolean; + existed?: boolean; + connectFails?: boolean; + enableFails?: boolean; + dropFails?: boolean; + exitCode?: number; + runFails?: boolean; + debug?: boolean; + networkId?: string; + workdir?: string; + dnsResolver?: "native" | "https"; +} + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const telemetry = mockLegacyTelemetryStateTracked(); + const resolver = mockResolver({ conn: opts.conn, isLocal: opts.isLocal }); + const connection = mockDbConnection(opts); + const docker = mockDockerRun(opts); + const layer = Layer.mergeAll( + out.layer, + resolver, + connection.layer, + docker.layer, + mockLegacyCliConfig({ workdir: opts.workdir ?? "/work/project", projectId: Option.none() }), + telemetry.layer, + runtimeInfoLayer, + Layer.succeed(LegacyDebugFlag, opts.debug ?? false), + Layer.succeed( + LegacyNetworkIdFlag, + opts.networkId === undefined ? Option.none() : Option.some(opts.networkId), + ), + Layer.succeed(LegacyDnsResolverFlag, opts.dnsResolver ?? "native"), + BunServices.layer, + ); + return { layer, out, telemetry, connection, docker }; +} + +const flags = (over: Partial[0]> = {}) => ({ + paths: over.paths ?? [], + dbUrl: over.dbUrl ?? Option.none(), + linked: over.linked ?? false, + local: over.local ?? true, +}); + +describe("legacy test db integration", () => { + it.live("runs pgTAP on the local db: enables then drops pgtap, exits 0", () => { + const { layer, connection, docker } = setup(); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(connection.execCalls).toEqual([ + "create extension if not exists pgtap with schema extensions", + "drop extension if exists pgtap", + ]); + const run = docker.lastOpts; + expect(run?.network).toEqual({ _tag: "named", name: "supabase_network_project" }); + expect(run?.env["PGHOST"]).toBe("db"); + expect(run?.env["PGPORT"]).toBe("5432"); + expect(run?.securityOpt).toEqual(["label:disable"]); + expect(run?.cmd.slice(0, 5)).toEqual(["pg_prove", "--ext", ".pg", "--ext", ".sql"]); + // The setup connection must be told it is local so the driver disables TLS + // (Go's `ConnectLocalPostgres` sets `cc.TLSConfig = nil`). + expect(connection.connectCalls[0]?.isLocal).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("adds the host.docker.internal host-gateway mapping on Linux", () => { + // Go populates HostConfig.ExtraHosts with this on Linux (docker_linux.go); the + // test RuntimeInfo mock reports platform "linux". + const { layer, docker } = setup(); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(docker.lastOpts?.extraHosts).toEqual(["host.docker.internal:host-gateway"]); + }).pipe(Effect.provide(layer)); + }); + + it.live("omits --security-opt inside Bitbucket Pipelines (BITBUCKET_CLONE_DIR set)", () => { + // Go clears hostConfig.SecurityOpt when BITBUCKET_CLONE_DIR is set, because + // Bitbucket rejects --security-opt (apps/cli-go/internal/utils/docker.go:288-293). + const { layer, docker } = setup(); + const prev = process.env["BITBUCKET_CLONE_DIR"]; + process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(docker.lastOpts?.securityOpt).toEqual([]); + }).pipe( + Effect.provide(layer), + Effect.ensuring( + Effect.sync(() => { + if (prev === undefined) delete process.env["BITBUCKET_CLONE_DIR"]; + else process.env["BITBUCKET_CLONE_DIR"] = prev; + }), + ), + ); + }); + + it.live("skips dropping pgtap when it already existed", () => { + const { layer, connection } = setup({ existed: true }); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(connection.execCalls).toEqual([ + "create extension if not exists pgtap with schema extensions", + ]); + }).pipe(Effect.provide(layer)); + }); + + it.live("logs to stderr but still succeeds when dropping pgtap fails", () => { + const { layer, out } = setup({ dropFails: true }); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(out.stderrText).toContain("failed to disable pgTAP: cannot drop"); + }).pipe(Effect.provide(layer)); + }); + + it.live("defaults to supabase/tests and mounts it read-only when no paths given", () => { + const { layer, docker } = setup(); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + const run = docker.lastOpts; + expect(run?.binds).toEqual(["/work/project/supabase/tests:/work/project/supabase/tests:ro"]); + expect(Option.getOrNull(run?.workingDir ?? Option.none())).toBe( + "/work/project/supabase/tests", + ); + }).pipe(Effect.provide(layer)); + }); + + it.live("passes explicit paths as read-only binds", () => { + const { layer, docker } = setup(); + return Effect.gen(function* () { + yield* legacyTestDb(flags({ paths: ["/abs/a_test.sql"] })); + const run = docker.lastOpts; + expect(run?.binds).toEqual(["/abs/a_test.sql:/abs/a_test.sql:ro"]); + expect(run?.cmd).toContain("/abs/a_test.sql"); + }).pipe(Effect.provide(layer)); + }); + + it.live("appends --verbose when --debug is set", () => { + const { layer, docker } = setup({ debug: true }); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(docker.lastOpts?.cmd).toContain("--verbose"); + }).pipe(Effect.provide(layer)); + }); + + it.live("db-url mode: uses host networking and the resolved host/port", () => { + const { layer, docker, connection } = setup({ conn: REMOTE_CONN, isLocal: false }); + return Effect.gen(function* () { + yield* legacyTestDb(flags({ dbUrl: Option.some("postgres://x"), local: false })); + const run = docker.lastOpts; + expect(run?.network).toEqual({ _tag: "host" }); + expect(run?.env["PGHOST"]).toBe(REMOTE_CONN.host); + expect(run?.env["PGPORT"]).toBe("5432"); + // Remote connection → driver must enable TLS (Go strips non-TLS fallbacks + // in `ConnectByUrl`); the handler signals this via `isLocal: false`. + expect(connection.connectCalls[0]?.isLocal).toBe(false); + // Default DNS resolver flows through to the driver unchanged. + expect(connection.connectCalls[0]?.dnsResolver).toBe("native"); + }).pipe(Effect.provide(layer)); + }); + + it.live("forwards --dns-resolver https to the driver for the connection", () => { + const { layer, connection } = setup({ + conn: REMOTE_CONN, + isLocal: false, + dnsResolver: "https", + }); + return Effect.gen(function* () { + yield* legacyTestDb(flags({ dbUrl: Option.some("postgres://x"), local: false })); + // Go installs the DoH fallback resolver for remote connects when + // `--dns-resolver https` is set (`connect.go:211-213`); the handler must + // hand the same value to the driver rather than silently using OS DNS. + expect(connection.connectCalls[0]?.dnsResolver).toBe("https"); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyTestDbEnablePgtapError when enabling pgTAP fails", () => { + const { layer } = setup({ enableFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyTestDb(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to enable pgTAP: permission denied"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with LegacyDbConnectError when the connection fails", () => { + const { layer } = setup({ connectFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyTestDb(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to connect to postgres"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with exit-N error when pg_prove exits non-zero", () => { + const { layer } = setup({ exitCode: 3 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyTestDb(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("error running container: exit 3"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when docker itself cannot run", () => { + const { layer } = setup({ runFails: true }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyTestDb(flags())); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("failed to run docker"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("json mode: streams TAP only — emits no result envelope (Go parity)", () => { + const { layer, out } = setup({ format: "json", exitCode: 0 }); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + // Go has no machine output for `test db`; the TS port must not append a + // JSON object that would corrupt the pg_prove TAP stream on stdout. + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("stream-json mode: emits no result envelope (Go parity)", () => { + const { layer, out } = setup({ format: "stream-json", exitCode: 0 }); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); + + it.live("rejects mutually exclusive connection flags (--linked + --local)", () => { + const { layer } = setup(); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyTestDb(flags({ linked: true, local: true }))); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", + ); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("honors --network-id, overriding the generated local network name", () => { + const { layer, docker } = setup({ networkId: "my-custom-net" }); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(docker.lastOpts?.network).toEqual({ _tag: "named", name: "my-custom-net" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("flushes telemetry via ensuring", () => { + const { layer, telemetry } = setup(); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(telemetry.flushed).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes the connection diagnostic to stderr, keeping stdout for TAP (Go parity)", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + // Go writes "Connecting to local database..." to os.Stderr and reserves + // stdout for the pg_prove TAP stream. A spinner/task on stdout would corrupt + // that stream (and stream-json task events would too), so the port must emit + // this on stderr and produce no stdout bytes of its own. + expect(out.stderrText).toContain("Connecting to local database..."); + expect(out.stdoutText).toBe(""); + // Go has no "Running pgTAP tests..." line and no spinner task messages. + expect(out.messages).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("labels the connection diagnostic 'remote' for non-local connections", () => { + const { layer, out } = setup({ conn: REMOTE_CONN, isLocal: false }); + return Effect.gen(function* () { + yield* legacyTestDb(flags({ dbUrl: Option.some("postgres://x"), local: false })); + expect(out.stderrText).toContain("Connecting to remote database..."); + }).pipe(Effect.provide(layer)); + }); + + const tempWorkdir = useLegacyTempWorkdir(); + it.live("sanitizes a configured project_id when naming the local network (Go parity)", () => { + const workdir = tempWorkdir.current; + mkdirSync(join(workdir, "supabase"), { recursive: true }); + // Go auto-fixes an invalid project_id via sanitizeProjectId (config.go:471, + // 803-805); the local stack network is created from the sanitized id, so + // `test db --local` must join `supabase_network_My_Project`, not the raw value. + writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "My Project"\n'); + const { layer, docker } = setup({ workdir }); + return Effect.gen(function* () { + yield* legacyTestDb(flags()); + expect(docker.lastOpts?.network).toEqual({ + _tag: "named", + name: "supabase_network_My_Project", + }); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/test/db/db.pg-prove-args.ts b/apps/cli/src/legacy/commands/test/db/db.pg-prove-args.ts new file mode 100644 index 0000000000..932aadb35d --- /dev/null +++ b/apps/cli/src/legacy/commands/test/db/db.pg-prove-args.ts @@ -0,0 +1,63 @@ +import * as nodePath from "node:path"; +import { Option } from "effect"; + +export interface LegacyPgProveArgs { + /** Full `pg_prove` argv (without the leading binary, which the image provides). */ + readonly cmd: ReadonlyArray; + /** Docker volume binds, each `hostpath:dockerpath:ro`. */ + readonly binds: ReadonlyArray; + /** Container working directory (dir of the first test path). */ + readonly workingDir: Option.Option; +} + +/** + * Translate an absolute host path to its in-container mount path. Mirrors Go's + * `utils.ToDockerPath` (`apps/cli-go/internal/utils/deno.go:268`): strip a + * Windows volume name (`C:`) and convert backslashes to forward slashes. + */ +export function legacyToDockerPath(absHostPath: string): string { + const slashed = absHostPath.replaceAll("\\", "/"); + const volumeMatch = /^[A-Za-z]:/.exec(absHostPath); + return volumeMatch === null ? slashed : slashed.slice(volumeMatch[0].length); +} + +/** + * Build the `pg_prove` command, volume binds, and working directory for a + * `test db` run. Pure port of the loop in `apps/cli-go/internal/db/test/test.go:29-56`. + * + * - No paths → default to `/supabase/tests` (Go's `filepath.Abs(DbTestsDir)` + * after chdir to the project root). + * - Relative paths resolve against `cwd` (Go's `utils.CurrentDirAbs`, the original + * invocation directory). + * - `--verbose` is appended when debug logging is enabled (Go's `viper.GetBool("DEBUG")`). + */ +export function buildLegacyPgProveArgs(opts: { + readonly paths: ReadonlyArray; + readonly cwd: string; + readonly workdir: string; + readonly debug: boolean; +}): LegacyPgProveArgs { + const testFiles = + opts.paths.length > 0 ? opts.paths : [nodePath.resolve(opts.workdir, "supabase", "tests")]; + + const cmd: string[] = ["pg_prove", "--ext", ".pg", "--ext", ".sql", "-r"]; + const binds: string[] = []; + // `testFiles` is never empty (it defaults to supabase/tests), so the first + // iteration always sets this; Go derives workingDir from the first path only. + let workingDir = ""; + + for (const candidate of testFiles) { + const fp = nodePath.isAbsolute(candidate) ? candidate : nodePath.join(opts.cwd, candidate); + const dockerPath = legacyToDockerPath(fp); + cmd.push(dockerPath); + binds.push(`${fp}:${dockerPath}:ro`); + if (workingDir === "") { + workingDir = + nodePath.posix.extname(dockerPath) !== "" ? nodePath.posix.dirname(dockerPath) : dockerPath; + } + } + + if (opts.debug) cmd.push("--verbose"); + + return { cmd, binds, workingDir: Option.some(workingDir) }; +} diff --git a/apps/cli/src/legacy/commands/test/db/db.pg-prove-args.unit.test.ts b/apps/cli/src/legacy/commands/test/db/db.pg-prove-args.unit.test.ts new file mode 100644 index 0000000000..ed7f30e98a --- /dev/null +++ b/apps/cli/src/legacy/commands/test/db/db.pg-prove-args.unit.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; +import { Option } from "effect"; + +import { buildLegacyPgProveArgs, legacyToDockerPath } from "./db.pg-prove-args.ts"; + +describe("legacyToDockerPath", () => { + test("leaves a posix path unchanged", () => { + expect(legacyToDockerPath("/work/project/supabase/tests")).toBe("/work/project/supabase/tests"); + }); + + test("strips a Windows volume and converts backslashes", () => { + expect(legacyToDockerPath("C:\\Users\\me\\tests\\a_test.sql")).toBe( + "/Users/me/tests/a_test.sql", + ); + }); +}); + +describe("buildLegacyPgProveArgs", () => { + test("defaults to /supabase/tests when no paths are given", () => { + const result = buildLegacyPgProveArgs({ + paths: [], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.cmd).toEqual([ + "pg_prove", + "--ext", + ".pg", + "--ext", + ".sql", + "-r", + "/work/supabase/tests", + ]); + expect(result.binds).toEqual(["/work/supabase/tests:/work/supabase/tests:ro"]); + expect(Option.getOrNull(result.workingDir)).toBe("/work/supabase/tests"); + }); + + test("resolves relative paths against cwd and mounts them read-only", () => { + const result = buildLegacyPgProveArgs({ + paths: ["nested"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.binds).toEqual(["/cwd/nested:/cwd/nested:ro"]); + expect(Option.getOrNull(result.workingDir)).toBe("/cwd/nested"); + }); + + test("uses the parent directory as workingDir when the first path is a file", () => { + const result = buildLegacyPgProveArgs({ + paths: ["/abs/dir/a_test.sql"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(Option.getOrNull(result.workingDir)).toBe("/abs/dir"); + }); + + test("keeps the first path's workingDir when multiple paths are given", () => { + const result = buildLegacyPgProveArgs({ + paths: ["/abs/first_test.sql", "/abs/second/dir"], + cwd: "/cwd", + workdir: "/work", + debug: false, + }); + expect(result.binds).toEqual([ + "/abs/first_test.sql:/abs/first_test.sql:ro", + "/abs/second/dir:/abs/second/dir:ro", + ]); + // workingDir is derived from the first path only (a file → its parent). + expect(Option.getOrNull(result.workingDir)).toBe("/abs"); + }); + + test("appends --verbose when debug is enabled", () => { + const result = buildLegacyPgProveArgs({ + paths: [], + cwd: "/cwd", + workdir: "/work", + debug: true, + }); + expect(result.cmd.at(-1)).toBe("--verbose"); + }); +}); diff --git a/apps/cli/src/legacy/commands/test/new/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/test/new/SIDE_EFFECTS.md index 40b08db3a5..372c32767f 100644 --- a/apps/cli/src/legacy/commands/test/new/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/test/new/SIDE_EFFECTS.md @@ -8,9 +8,11 @@ ## Files Written -| Path | Format | When | -| ------------------------------------- | ------ | ---------------------------------- | -| `/supabase/tests/.sql` | SQL | always (creates new test scaffold) | +| Path | Format | When | +| ------------------------------------------ | ------ | -------------------------------------- | +| `/supabase/tests/_test.sql` | SQL | always, unless the file already exists | + +The parent directory `/supabase/tests/` is created if missing. ## API Routes @@ -26,28 +28,31 @@ ## Exit Codes -| Code | Condition | -| ---- | ------------------------ | -| `0` | success | -| `1` | invalid test name | -| `1` | test file already exists | +| Code | Condition | +| ---- | -------------------------------------- | +| `0` | success | +| `1` | test file already exists | +| `1` | write failure (e.g. permission denied) | ## Output ### `--output-format text` (Go CLI compatible) -Prints a success message with the path to the created test file. +Prints `Created new