github.com/supabase/cli@v1.168.1/internal/migration/list/list.go (about)

     1  package list
     2  
     3  import (
     4  	"context"
     5  	"fmt"
     6  	"math"
     7  	"os"
     8  	"regexp"
     9  	"strconv"
    10  	"time"
    11  
    12  	"github.com/charmbracelet/glamour"
    13  	"github.com/go-errors/errors"
    14  	"github.com/jackc/pgconn"
    15  	"github.com/jackc/pgerrcode"
    16  	"github.com/jackc/pgx/v4"
    17  	"github.com/spf13/afero"
    18  	"github.com/spf13/viper"
    19  	"github.com/supabase/cli/internal/utils"
    20  	"github.com/supabase/cli/internal/utils/pgxv5"
    21  )
    22  
    23  const LIST_MIGRATION_VERSION = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"
    24  
    25  var initSchemaPattern = regexp.MustCompile(`([0-9]{14})_init\.sql`)
    26  
    27  func Run(ctx context.Context, config pgconn.Config, fsys afero.Fs, options ...func(*pgx.ConnConfig)) error {
    28  	remoteVersions, err := loadRemoteVersions(ctx, config, options...)
    29  	if err != nil {
    30  		return err
    31  	}
    32  	localVersions, err := LoadLocalVersions(fsys)
    33  	if err != nil {
    34  		return err
    35  	}
    36  	table := makeTable(remoteVersions, localVersions)
    37  	return RenderTable(table)
    38  }
    39  
    40  func loadRemoteVersions(ctx context.Context, config pgconn.Config, options ...func(*pgx.ConnConfig)) ([]string, error) {
    41  	conn, err := utils.ConnectByConfig(ctx, config, options...)
    42  	if err != nil {
    43  		return nil, err
    44  	}
    45  	defer conn.Close(context.Background())
    46  	return LoadRemoteMigrations(ctx, conn)
    47  }
    48  
    49  func LoadRemoteMigrations(ctx context.Context, conn *pgx.Conn) ([]string, error) {
    50  	versions, err := listMigrationVersions(ctx, conn)
    51  	if err != nil {
    52  		var pgErr *pgconn.PgError
    53  		if errors.As(err, &pgErr) && pgErr.Code == pgerrcode.UndefinedTable {
    54  			// If migration history table is undefined, the remote project has no migrations
    55  			return nil, nil
    56  		}
    57  	}
    58  	return versions, err
    59  }
    60  
    61  func listMigrationVersions(ctx context.Context, conn *pgx.Conn) ([]string, error) {
    62  	rows, err := conn.Query(ctx, LIST_MIGRATION_VERSION)
    63  	if err != nil {
    64  		return nil, errors.Errorf("failed to query rows: %w", err)
    65  	}
    66  	return pgxv5.CollectStrings(rows)
    67  }
    68  
    69  const (
    70  	layoutVersion = "20060102150405"
    71  	layoutHuman   = "2006-01-02 15:04:05"
    72  )
    73  
    74  func formatTimestamp(version string) string {
    75  	timestamp, err := time.Parse(layoutVersion, version)
    76  	if err != nil {
    77  		if viper.GetBool("DEBUG") {
    78  			fmt.Fprintln(os.Stderr, err)
    79  		}
    80  		return version
    81  	}
    82  	return timestamp.Format(layoutHuman)
    83  }
    84  
    85  func makeTable(remoteMigrations, localMigrations []string) string {
    86  	var err error
    87  	table := "|Local|Remote|Time (UTC)|\n|-|-|-|\n"
    88  	for i, j := 0, 0; i < len(remoteMigrations) || j < len(localMigrations); {
    89  		remoteTimestamp := math.MaxInt
    90  		if i < len(remoteMigrations) {
    91  			if remoteTimestamp, err = strconv.Atoi(remoteMigrations[i]); err != nil {
    92  				i++
    93  				continue
    94  			}
    95  		}
    96  		localTimestamp := math.MaxInt
    97  		if j < len(localMigrations) {
    98  			if localTimestamp, err = strconv.Atoi(localMigrations[j]); err != nil {
    99  				j++
   100  				continue
   101  			}
   102  		}
   103  		// Top to bottom chronological order
   104  		if localTimestamp < remoteTimestamp {
   105  			table += fmt.Sprintf("|`%s`|` `|`%s`|\n", localMigrations[j], formatTimestamp(localMigrations[j]))
   106  			j++
   107  		} else if remoteTimestamp < localTimestamp {
   108  			table += fmt.Sprintf("|` `|`%s`|`%s`|\n", remoteMigrations[i], formatTimestamp(remoteMigrations[i]))
   109  			i++
   110  		} else {
   111  			table += fmt.Sprintf("|`%s`|`%s`|`%s`|\n", localMigrations[j], remoteMigrations[i], formatTimestamp(remoteMigrations[i]))
   112  			i++
   113  			j++
   114  		}
   115  	}
   116  	return table
   117  }
   118  
   119  func RenderTable(markdown string) error {
   120  	r, err := glamour.NewTermRenderer(
   121  		glamour.WithAutoStyle(),
   122  		glamour.WithWordWrap(-1),
   123  	)
   124  	if err != nil {
   125  		return errors.Errorf("failed to initialise terminal renderer: %w", err)
   126  	}
   127  	out, err := r.Render(markdown)
   128  	if err != nil {
   129  		return errors.Errorf("failed to render markdown: %w", err)
   130  	}
   131  	fmt.Print(out)
   132  	return nil
   133  }
   134  
   135  func LoadLocalVersions(fsys afero.Fs) ([]string, error) {
   136  	names, err := LoadLocalMigrations(fsys)
   137  	if err != nil {
   138  		return nil, err
   139  	}
   140  	var versions []string
   141  	for _, filename := range names {
   142  		// LoadLocalMigrations guarantees we always have a match
   143  		version := utils.MigrateFilePattern.FindStringSubmatch(filename)[1]
   144  		versions = append(versions, version)
   145  	}
   146  	return versions, nil
   147  }
   148  
   149  func LoadLocalMigrations(fsys afero.Fs) ([]string, error) {
   150  	return LoadPartialMigrations("", fsys)
   151  }
   152  
   153  func LoadPartialMigrations(version string, fsys afero.Fs) ([]string, error) {
   154  	localMigrations, err := afero.ReadDir(fsys, utils.MigrationsDir)
   155  	if err != nil && !errors.Is(err, os.ErrNotExist) {
   156  		return nil, errors.Errorf("failed to read directory: %w", err)
   157  	}
   158  	var names []string
   159  	for i, migration := range localMigrations {
   160  		filename := migration.Name()
   161  		if i == 0 && shouldSkip(filename) {
   162  			fmt.Fprintln(os.Stderr, "Skipping migration "+utils.Bold(filename)+`... (replace "init" with a different file name to apply this migration)`)
   163  			continue
   164  		}
   165  		matches := utils.MigrateFilePattern.FindStringSubmatch(filename)
   166  		if len(matches) == 0 {
   167  			fmt.Fprintln(os.Stderr, "Skipping migration "+utils.Bold(filename)+`... (file name must match pattern "<timestamp>_name.sql")`)
   168  			continue
   169  		}
   170  		names = append(names, filename)
   171  		if matches[1] == version {
   172  			break
   173  		}
   174  	}
   175  	return names, nil
   176  }
   177  
   178  func shouldSkip(name string) bool {
   179  	// NOTE: To handle backward-compatibility. `<timestamp>_init.sql` as
   180  	// the first migration (prev versions of the CLI) is deprecated.
   181  	matches := initSchemaPattern.FindStringSubmatch(name)
   182  	if len(matches) == 2 {
   183  		if timestamp, err := strconv.ParseUint(matches[1], 10, 64); err == nil && timestamp < 20211209000000 {
   184  			return true
   185  		}
   186  	}
   187  	return false
   188  }