From 161410aeeaf4f487615936a3ddbbddd7a3da0695 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 18:58:38 +0100 Subject: [PATCH 1/9] feat(migration): add ParseVersion/MigrationName helpers and update NewMigrationFromFile Add foundational helpers for Drizzle v1 folder-based migration support: - ParseVersion: extracts version/name from flat files or folder-based paths - MigrationName: returns human-readable display name for migration paths - migrateDirPattern: regex for matching directory names - NewMigrationFromFile: now uses ParseVersion for both flat and folder paths --- pkg/migration/file.go | 40 +++++++++++++++++---- pkg/migration/file_test.go | 72 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 106 insertions(+), 6 deletions(-) diff --git a/pkg/migration/file.go b/pkg/migration/file.go index 540c129e33..2668bec17e 100644 --- a/pkg/migration/file.go +++ b/pkg/migration/file.go @@ -28,6 +28,7 @@ type MigrationFile struct { var ( migrateFilePattern = regexp.MustCompile(`^([0-9]+)_(.*)\.sql$`) + migrateDirPattern = regexp.MustCompile(`^([0-9]+)_(.+)$`) typeNamePattern = regexp.MustCompile(`type "([^"]+)" does not exist`) ) @@ -37,16 +38,43 @@ func NewMigrationFromFile(path string, fsys fs.FS) (*MigrationFile, error) { return nil, err } file := MigrationFile{Statements: lines} - // Parse version from file name - filename := filepath.Base(path) - matches := migrateFilePattern.FindStringSubmatch(filename) - if len(matches) > 2 { - file.Version = matches[1] - file.Name = matches[2] + // Parse version from file path (supports both flat files and folder-based migrations) + if version, name, ok := ParseVersion(path); ok { + file.Version = version + file.Name = name } return &file, nil } +// ParseVersion extracts the version and name from a migration path. +// Handles both flat files (20220727064247_create_table.sql) and +// folder-based migrations (20242409125510_premium_mister_fear/migration.sql). +func ParseVersion(path string) (version, name string, ok bool) { + filename := filepath.Base(path) + if matches := migrateFilePattern.FindStringSubmatch(filename); len(matches) > 2 { + return matches[1], matches[2], true + } + // Try parent directory for folder-based migrations (e.g. Drizzle v1) + dirName := filepath.Base(filepath.Dir(path)) + if matches := migrateDirPattern.FindStringSubmatch(dirName); len(matches) > 2 { + return matches[1], matches[2], true + } + return "", "", false +} + +// MigrationName returns a human-readable display name for a migration path. +// For flat files: "20220727064247_create_table.sql" +// For folder migrations: "20242409125510_premium_mister_fear/migration.sql" +func MigrationName(path string) string { + filename := filepath.Base(path) + if migrateFilePattern.MatchString(filename) { + return filename + } + // For folder-based migrations, show "dirname/filename" + dir := filepath.Base(filepath.Dir(path)) + return filepath.Join(dir, filename) +} + func parseFile(path string, fsys fs.FS) ([]string, error) { sql, err := fsys.Open(path) if err != nil { diff --git a/pkg/migration/file_test.go b/pkg/migration/file_test.go index 703f26954c..671fcc3f11 100644 --- a/pkg/migration/file_test.go +++ b/pkg/migration/file_test.go @@ -100,6 +100,21 @@ func TestMigrationFile(t *testing.T) { assert.ErrorContains(t, err, "At statement: 0") }) + t.Run("new from folder-based migration", func(t *testing.T) { + // Setup in-memory fs + fsys := fs.MapFS{ + "20242409125510_premium_mister_fear/migration.sql": &fs.MapFile{Data: []byte("CREATE TABLE foo (id int)")}, + "20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{Data: []byte("{}")}, + } + // Run test + migration, err := NewMigrationFromFile("20242409125510_premium_mister_fear/migration.sql", fsys) + // Check error + assert.NoError(t, err) + assert.Equal(t, "20242409125510", migration.Version) + assert.Equal(t, "premium_mister_fear", migration.Name) + assert.Len(t, migration.Statements, 1) + }) + t.Run("skips hint for schema-qualified type errors", func(t *testing.T) { migration := MigrationFile{ Statements: []string{"CREATE TABLE test (path extensions.ltree NOT NULL)"}, @@ -158,3 +173,60 @@ func TestIsSchemaQualified(t *testing.T) { assert.False(t, IsSchemaQualified("ltree")) assert.False(t, IsSchemaQualified("")) } + +func TestParseVersion(t *testing.T) { + t.Run("extracts version from flat file", func(t *testing.T) { + version, name, ok := ParseVersion("20220727064247_create_table.sql") + assert.True(t, ok) + assert.Equal(t, "20220727064247", version) + assert.Equal(t, "create_table", name) + }) + + t.Run("extracts version from flat file with path", func(t *testing.T) { + version, name, ok := ParseVersion("supabase/migrations/20220727064247_create_table.sql") + assert.True(t, ok) + assert.Equal(t, "20220727064247", version) + assert.Equal(t, "create_table", name) + }) + + t.Run("extracts version from folder-based migration", func(t *testing.T) { + version, name, ok := ParseVersion("supabase/migrations/20242409125510_premium_mister_fear/migration.sql") + assert.True(t, ok) + assert.Equal(t, "20242409125510", version) + assert.Equal(t, "premium_mister_fear", name) + }) + + t.Run("extracts version from folder-based migration without parent path", func(t *testing.T) { + version, name, ok := ParseVersion("20242409125510_premium_mister_fear/migration.sql") + assert.True(t, ok) + assert.Equal(t, "20242409125510", version) + assert.Equal(t, "premium_mister_fear", name) + }) + + t.Run("returns false for non-matching path", func(t *testing.T) { + _, _, ok := ParseVersion("random_file.txt") + assert.False(t, ok) + }) + + t.Run("returns false for migration.sql without matching parent dir", func(t *testing.T) { + _, _, ok := ParseVersion("some_dir/migration.sql") + assert.False(t, ok) + }) +} + +func TestMigrationName(t *testing.T) { + t.Run("returns filename for flat migration", func(t *testing.T) { + assert.Equal(t, "20220727064247_create_table.sql", MigrationName("supabase/migrations/20220727064247_create_table.sql")) + }) + + t.Run("returns dir/file for folder-based migration", func(t *testing.T) { + assert.Equal(t, + "20242409125510_premium_mister_fear/migration.sql", + MigrationName("supabase/migrations/20242409125510_premium_mister_fear/migration.sql"), + ) + }) + + t.Run("returns filename when no parent directory", func(t *testing.T) { + assert.Equal(t, "20220727064247_create_table.sql", MigrationName("20220727064247_create_table.sql")) + }) +} From 8c843751a71a5c67067139a4b416d4fa35efec22 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 19:00:21 +0100 Subject: [PATCH 2/9] feat(migration): discover folder-based migrations in ListLocalMigrations Replace the unconditional `continue` on directories with logic that checks if the directory name matches the migration timestamp pattern and contains a `migration.sql` file (Drizzle v1 folder format). --- pkg/migration/list.go | 47 ++++++++++++++++++++++++++------------ pkg/migration/list_test.go | 41 +++++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+), 15 deletions(-) diff --git a/pkg/migration/list.go b/pkg/migration/list.go index 8972bb2594..5301f38efb 100644 --- a/pkg/migration/list.go +++ b/pkg/migration/list.go @@ -37,23 +37,40 @@ func ListLocalMigrations(migrationsDir string, fsys fs.FS, filter ...func(string } var clean []string OUTER: - for i, migration := range localMigrations { - if migration.IsDir() { - continue - } - filename := migration.Name() - if i == 0 && shouldSkip(filename) { - fmt.Fprintf(os.Stderr, "Skipping migration %s... (replace \"init\" with a different file name to apply this migration)\n", filename) - continue - } - matches := migrateFilePattern.FindStringSubmatch(filename) - if len(matches) == 0 { - fmt.Fprintf(os.Stderr, "Skipping migration %s... (file name must match pattern \"_name.sql\")\n", filename) - continue + for i, entry := range localMigrations { + var path, version string + + if entry.IsDir() { + dirName := entry.Name() + matches := migrateDirPattern.FindStringSubmatch(dirName) + if len(matches) == 0 { + continue + } + // Look for migration.sql inside the directory (e.g. Drizzle v1 format) + sqlPath := filepath.Join(migrationsDir, dirName, "migration.sql") + if _, err := fs.Stat(fsys, sqlPath); err != nil { + fmt.Fprintf(os.Stderr, "Skipping migration directory %s... (missing migration.sql)\n", dirName) + continue + } + path = sqlPath + version = matches[1] + } else { + filename := entry.Name() + if i == 0 && shouldSkip(filename) { + fmt.Fprintf(os.Stderr, "Skipping migration %s... (replace \"init\" with a different file name to apply this migration)\n", filename) + continue + } + matches := migrateFilePattern.FindStringSubmatch(filename) + if len(matches) == 0 { + fmt.Fprintf(os.Stderr, "Skipping migration %s... (file name must match pattern \"_name.sql\")\n", filename) + continue + } + path = filepath.Join(migrationsDir, filename) + version = matches[1] } - path := filepath.Join(migrationsDir, filename) + for _, keep := range filter { - if version := matches[1]; !keep(version) { + if !keep(version) { continue OUTER } } diff --git a/pkg/migration/list_test.go b/pkg/migration/list_test.go index 4654fa876a..87044c9841 100644 --- a/pkg/migration/list_test.go +++ b/pkg/migration/list_test.go @@ -89,4 +89,45 @@ func TestLocalMigrations(t *testing.T) { // Check error assert.ErrorContains(t, err, "failed to read directory:") }) + + t.Run("loads folder-based migrations", func(t *testing.T) { + // Setup in-memory fs + fsys := fs.MapFS{ + "20220727064246_test.sql": &fs.MapFile{}, + "20242409125510_premium_mister_fear/migration.sql": &fs.MapFile{}, + "20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{}, + } + // Run test + versions, err := ListLocalMigrations(".", fsys) + // Check error + assert.NoError(t, err) + assert.Equal(t, []string{ + "20220727064246_test.sql", + "20242409125510_premium_mister_fear/migration.sql", + }, versions) + }) + + t.Run("skips directory without migration.sql", func(t *testing.T) { + // Setup in-memory fs — directory with only snapshot.json, no migration.sql + fsys := fs.MapFS{ + "20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{}, + } + // Run test + versions, err := ListLocalMigrations(".", fsys) + // Check error + assert.NoError(t, err) + assert.Empty(t, versions) + }) + + t.Run("skips directory with non-matching name", func(t *testing.T) { + // Setup in-memory fs — directory name doesn't start with digits + fsys := fs.MapFS{ + "some_random_dir/migration.sql": &fs.MapFile{}, + } + // Run test + versions, err := ListLocalMigrations(".", fsys) + // Check error + assert.NoError(t, err) + assert.Empty(t, versions) + }) } From d63be9e325e2a6c7447b423eb185032c69b10a29 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 19:04:54 +0100 Subject: [PATCH 3/9] feat(migration): support folder-based migrations in FindPendingMigrations and ApplyMigrations Replace direct migrateFilePattern regex call with ParseVersion to handle both flat-file and folder-based migration paths; use MigrationName for display output. Removes path/filepath import no longer needed. --- pkg/migration/apply.go | 9 +++---- pkg/migration/apply_test.go | 49 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/pkg/migration/apply.go b/pkg/migration/apply.go index e40b58be2e..80cdebc487 100644 --- a/pkg/migration/apply.go +++ b/pkg/migration/apply.go @@ -5,7 +5,6 @@ import ( "fmt" "io/fs" "os" - "path/filepath" "github.com/go-errors/errors" "github.com/jackc/pgx/v4" @@ -23,9 +22,8 @@ func FindPendingMigrations(localMigrations, remoteMigrations []string) ([]string i, j := 0, 0 for i < len(remoteMigrations) && j < len(localMigrations) { remote := remoteMigrations[i] - filename := filepath.Base(localMigrations[j]) - // Check if migration has been applied before, LoadLocalMigrations guarantees a match - local := migrateFilePattern.FindStringSubmatch(filename)[1] + // Extract version from path, supporting both flat files and folder-based migrations + local, _, _ := ParseVersion(localMigrations[j]) if remote == local { j++ i++ @@ -60,8 +58,7 @@ func ApplyMigrations(ctx context.Context, pending []string, conn *pgx.Conn, fsys } } for _, path := range pending { - filename := filepath.Base(path) - fmt.Fprintf(os.Stderr, "Applying migration %s...\n", filename) + fmt.Fprintf(os.Stderr, "Applying migration %s...\n", MigrationName(path)) // Reset all connection settings that might have been modified by another statement on the same connection // eg: `SELECT pg_catalog.set_config('search_path', '', false);` if _, err := conn.Exec(ctx, "RESET ALL"); err != nil { diff --git a/pkg/migration/apply_test.go b/pkg/migration/apply_test.go index e6df97721f..583d2568b5 100644 --- a/pkg/migration/apply_test.go +++ b/pkg/migration/apply_test.go @@ -91,6 +91,55 @@ func TestPendingMigrations(t *testing.T) { assert.ErrorIs(t, err, ErrMissingLocal) assert.ElementsMatch(t, []string{remote[1], remote[3], remote[4]}, missing) }) + + t.Run("finds pending folder-based migrations", func(t *testing.T) { + local := []string{ + "20221201000000_test.sql", + "20221201000001_test.sql", + "20221201000002_drizzle_migration/migration.sql", + "20221201000003_another_drizzle/migration.sql", + } + remote := []string{ + "20221201000000", + "20221201000001", + } + // Run test + pending, err := FindPendingMigrations(local, remote) + // Check error + assert.NoError(t, err) + assert.ElementsMatch(t, local[2:], pending) + }) + + t.Run("matches remote with local folder-based migration", func(t *testing.T) { + local := []string{ + "20221201000000_test.sql", + "20221201000001_drizzle_migration/migration.sql", + } + remote := []string{ + "20221201000000", + "20221201000001", + } + // Run test + pending, err := FindPendingMigrations(local, remote) + // Check error + assert.NoError(t, err) + assert.Empty(t, pending) + }) + + t.Run("detects missing local for folder-based migrations", func(t *testing.T) { + local := []string{ + "20221201000000_drizzle_one/migration.sql", + } + remote := []string{ + "20221201000000", + "20221201000001", + } + // Run test + missing, err := FindPendingMigrations(local, remote) + // Check error + assert.ErrorIs(t, err, ErrMissingLocal) + assert.ElementsMatch(t, []string{"20221201000001"}, missing) + }) } var ( From e481e40718e82c6cba277b72d51acd0b76711e75 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 19:07:40 +0100 Subject: [PATCH 4/9] feat(migration): support folder-based migrations in repair command --- internal/migration/repair/repair.go | 16 +++++++-- internal/migration/repair/repair_test.go | 46 ++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/internal/migration/repair/repair.go b/internal/migration/repair/repair.go index a7dfee274e..1501fbb532 100644 --- a/internal/migration/repair/repair.go +++ b/internal/migration/repair/repair.go @@ -88,15 +88,25 @@ func UpdateMigrationTable(ctx context.Context, conn *pgx.Conn, version []string, } func GetMigrationFile(version string, fsys afero.Fs) (string, error) { + // Try flat file first: version_*.sql path := filepath.Join(utils.MigrationsDir, version+"_*.sql") matches, err := afero.Glob(fsys, path) if err != nil { return "", errors.Errorf("failed to glob migration files: %w", err) } - if len(matches) == 0 { - return "", errors.Errorf("glob %s: %w", path, os.ErrNotExist) + if len(matches) > 0 { + return matches[0], nil } - return matches[0], nil + // Try folder-based migration: version_*/migration.sql + dirPath := filepath.Join(utils.MigrationsDir, version+"_*", "migration.sql") + dirMatches, err := afero.Glob(fsys, dirPath) + if err != nil { + return "", errors.Errorf("failed to glob migration directories: %w", err) + } + if len(dirMatches) > 0 { + return dirMatches[0], nil + } + return "", errors.Errorf("glob %s: %w", path, os.ErrNotExist) } func NewMigrationFromVersion(version string, fsys afero.Fs) (*migration.MigrationFile, error) { diff --git a/internal/migration/repair/repair_test.go b/internal/migration/repair/repair_test.go index f8eb33cce8..27d94afa79 100644 --- a/internal/migration/repair/repair_test.go +++ b/internal/migration/repair/repair_test.go @@ -61,6 +61,23 @@ func TestRepairCommand(t *testing.T) { assert.NoError(t, err) }) + t.Run("applies folder-based migration version", func(t *testing.T) { + // Setup in-memory fs + fsys := afero.NewMemMapFs() + sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "migration.sql") + require.NoError(t, afero.WriteFile(fsys, sqlPath, []byte("select 1"), 0644)) + // Setup mock postgres + conn := pgtest.NewConn() + defer conn.Close(t) + helper.MockMigrationHistory(conn). + Query(migration.UPSERT_MIGRATION_VERSION, "20242409125510", "premium_mister_fear", []string{"select 1"}). + Reply("INSERT 0 1") + // Run test + err := Run(context.Background(), dbConfig, []string{"20242409125510"}, Applied, fsys, conn.Intercept) + // Check error + assert.NoError(t, err) + }) + t.Run("throws error on invalid version", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() @@ -97,6 +114,35 @@ func TestRepairCommand(t *testing.T) { }) } +func TestGetMigrationFile(t *testing.T) { + t.Run("finds flat migration file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + path := filepath.Join(utils.MigrationsDir, "0_test.sql") + require.NoError(t, afero.WriteFile(fsys, path, []byte("select 1"), 0644)) + // Run test + result, err := GetMigrationFile("0", fsys) + assert.NoError(t, err) + assert.Equal(t, path, result) + }) + + t.Run("finds folder-based migration file", func(t *testing.T) { + fsys := afero.NewMemMapFs() + sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "migration.sql") + require.NoError(t, afero.WriteFile(fsys, sqlPath, []byte("select 1"), 0644)) + // Run test + result, err := GetMigrationFile("20242409125510", fsys) + assert.NoError(t, err) + assert.Equal(t, sqlPath, result) + }) + + t.Run("returns error when version not found", func(t *testing.T) { + fsys := afero.NewMemMapFs() + // Run test + _, err := GetMigrationFile("99999", fsys) + assert.ErrorIs(t, err, os.ErrNotExist) + }) +} + func TestRepairAll(t *testing.T) { t.Run("repairs whole history", func(t *testing.T) { t.Cleanup(fstest.MockStdin(t, "y")) From c6f387c20f556b8733657f671e59c182e1bcef76 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 19:09:07 +0100 Subject: [PATCH 5/9] feat(migration): display folder-based migration names in push output --- internal/db/push/push.go | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/internal/db/push/push.go b/internal/db/push/push.go index 4084cd0800..79af124f33 100644 --- a/internal/db/push/push.go +++ b/internal/db/push/push.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "path/filepath" "github.com/go-errors/errors" "github.com/jackc/pgconn" @@ -118,8 +117,7 @@ func Run(ctx context.Context, dryRun, ignoreVersionMismatch bool, includeRoles, func confirmPushAll(pending []string) (msg string) { for _, path := range pending { - filename := filepath.Base(path) - msg += fmt.Sprintf(" • %s\n", utils.Bold(filename)) + msg += fmt.Sprintf(" • %s\n", utils.Bold(migration.MigrationName(path))) } return msg } From 2cede9f1bba534ec7d1dbdf966570924eaa7235d Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 19:09:08 +0100 Subject: [PATCH 6/9] feat(migration): handle folder removal when squashing folder-based migrations --- internal/migration/squash/squash.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/migration/squash/squash.go b/internal/migration/squash/squash.go index afc52c687e..e7159d7234 100644 --- a/internal/migration/squash/squash.go +++ b/internal/migration/squash/squash.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "os" + "path/filepath" "strconv" "time" @@ -73,6 +74,14 @@ func squashToVersion(ctx context.Context, version string, fsys afero.Fs, options for _, path := range migrations[:len(migrations)-1] { if err := fsys.Remove(path); err != nil { fmt.Fprintln(os.Stderr, err) + continue + } + // For folder-based migrations, remove the parent directory too (includes snapshot.json) + dir := filepath.Dir(path) + if dir != utils.MigrationsDir { + if err := fsys.RemoveAll(dir); err != nil { + fmt.Fprintln(os.Stderr, err) + } } } return nil From 24d0d4b39ae226e280a297346dd21fc95c0d83f6 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 19:17:00 +0100 Subject: [PATCH 7/9] fix(migration): use MigrationName in debug bundle for folder-based migrations filepath.Base would return just "migration.sql" for folder-based migrations, losing the version identity in debug output. --- internal/db/declarative/debug.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/db/declarative/debug.go b/internal/db/declarative/debug.go index f274ed39dd..30ffaa4c7b 100644 --- a/internal/db/declarative/debug.go +++ b/internal/db/declarative/debug.go @@ -104,9 +104,9 @@ func CollectMigrationsList(fsys afero.Fs) []string { if err != nil { return nil } - // Strip directory prefix to return just filenames + // Strip directory prefix to return display names for i, m := range migrations { - migrations[i] = filepath.Base(m) + migrations[i] = migration.MigrationName(m) } return migrations } From dfdea1cfcdc39c2071e8a9774e2a5150fb1a979c Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 19:17:26 +0100 Subject: [PATCH 8/9] fix(migration): improve error message in GetMigrationFile for clarity Show version number in error instead of only the flat-file glob pattern. --- internal/migration/repair/repair.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/migration/repair/repair.go b/internal/migration/repair/repair.go index 1501fbb532..c7c598b264 100644 --- a/internal/migration/repair/repair.go +++ b/internal/migration/repair/repair.go @@ -106,7 +106,7 @@ func GetMigrationFile(version string, fsys afero.Fs) (string, error) { if len(dirMatches) > 0 { return dirMatches[0], nil } - return "", errors.Errorf("glob %s: %w", path, os.ErrNotExist) + return "", errors.Errorf("no migration found for version %s: %w", version, os.ErrNotExist) } func NewMigrationFromVersion(version string, fsys afero.Fs) (*migration.MigrationFile, error) { From 085ebfc5ba581b8605dc0bdba8bc390c0d4e91a0 Mon Sep 17 00:00:00 2001 From: avallete Date: Fri, 20 Mar 2026 20:03:02 +0100 Subject: [PATCH 9/9] refactor(migration): match over *.sql and exactly one --- internal/migration/repair/repair.go | 9 ++++++--- internal/migration/repair/repair_test.go | 14 +++++++++++-- internal/migration/squash/squash.go | 2 +- pkg/migration/apply_test.go | 8 ++++---- pkg/migration/file.go | 6 +++--- pkg/migration/file_test.go | 18 ++++++++--------- pkg/migration/list.go | 25 +++++++++++++++++++----- pkg/migration/list_test.go | 25 ++++++++++++++++++------ 8 files changed, 74 insertions(+), 33 deletions(-) diff --git a/internal/migration/repair/repair.go b/internal/migration/repair/repair.go index c7c598b264..1ab6fd34d6 100644 --- a/internal/migration/repair/repair.go +++ b/internal/migration/repair/repair.go @@ -97,15 +97,18 @@ func GetMigrationFile(version string, fsys afero.Fs) (string, error) { if len(matches) > 0 { return matches[0], nil } - // Try folder-based migration: version_*/migration.sql - dirPath := filepath.Join(utils.MigrationsDir, version+"_*", "migration.sql") + // Try folder-based migration: version_*/*.sql + dirPath := filepath.Join(utils.MigrationsDir, version+"_*", "*.sql") dirMatches, err := afero.Glob(fsys, dirPath) if err != nil { return "", errors.Errorf("failed to glob migration directories: %w", err) } - if len(dirMatches) > 0 { + if len(dirMatches) == 1 { return dirMatches[0], nil } + if len(dirMatches) > 1 { + return "", errors.Errorf("multiple .sql files found for version %s", version) + } return "", errors.Errorf("no migration found for version %s: %w", version, os.ErrNotExist) } diff --git a/internal/migration/repair/repair_test.go b/internal/migration/repair/repair_test.go index 27d94afa79..b415069c5d 100644 --- a/internal/migration/repair/repair_test.go +++ b/internal/migration/repair/repair_test.go @@ -64,7 +64,7 @@ func TestRepairCommand(t *testing.T) { t.Run("applies folder-based migration version", func(t *testing.T) { // Setup in-memory fs fsys := afero.NewMemMapFs() - sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "migration.sql") + sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "schema.sql") require.NoError(t, afero.WriteFile(fsys, sqlPath, []byte("select 1"), 0644)) // Setup mock postgres conn := pgtest.NewConn() @@ -127,7 +127,7 @@ func TestGetMigrationFile(t *testing.T) { t.Run("finds folder-based migration file", func(t *testing.T) { fsys := afero.NewMemMapFs() - sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "migration.sql") + sqlPath := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear", "schema.sql") require.NoError(t, afero.WriteFile(fsys, sqlPath, []byte("select 1"), 0644)) // Run test result, err := GetMigrationFile("20242409125510", fsys) @@ -135,6 +135,16 @@ func TestGetMigrationFile(t *testing.T) { assert.Equal(t, sqlPath, result) }) + t.Run("returns error for multiple .sql files in directory", func(t *testing.T) { + fsys := afero.NewMemMapFs() + dir := filepath.Join(utils.MigrationsDir, "20242409125510_premium_mister_fear") + require.NoError(t, afero.WriteFile(fsys, filepath.Join(dir, "schema.sql"), []byte("select 1"), 0644)) + require.NoError(t, afero.WriteFile(fsys, filepath.Join(dir, "extra.sql"), []byte("select 2"), 0644)) + // Run test + _, err := GetMigrationFile("20242409125510", fsys) + assert.ErrorContains(t, err, "multiple .sql files found") + }) + t.Run("returns error when version not found", func(t *testing.T) { fsys := afero.NewMemMapFs() // Run test diff --git a/internal/migration/squash/squash.go b/internal/migration/squash/squash.go index e7159d7234..53e63e9494 100644 --- a/internal/migration/squash/squash.go +++ b/internal/migration/squash/squash.go @@ -76,7 +76,7 @@ func squashToVersion(ctx context.Context, version string, fsys afero.Fs, options fmt.Fprintln(os.Stderr, err) continue } - // For folder-based migrations, remove the parent directory too (includes snapshot.json) + // For folder-based migrations, remove the parent directory and all its contents dir := filepath.Dir(path) if dir != utils.MigrationsDir { if err := fsys.RemoveAll(dir); err != nil { diff --git a/pkg/migration/apply_test.go b/pkg/migration/apply_test.go index 583d2568b5..6fbe43e806 100644 --- a/pkg/migration/apply_test.go +++ b/pkg/migration/apply_test.go @@ -96,8 +96,8 @@ func TestPendingMigrations(t *testing.T) { local := []string{ "20221201000000_test.sql", "20221201000001_test.sql", - "20221201000002_drizzle_migration/migration.sql", - "20221201000003_another_drizzle/migration.sql", + "20221201000002_create_users/schema.sql", + "20221201000003_add_indexes/schema.sql", } remote := []string{ "20221201000000", @@ -113,7 +113,7 @@ func TestPendingMigrations(t *testing.T) { t.Run("matches remote with local folder-based migration", func(t *testing.T) { local := []string{ "20221201000000_test.sql", - "20221201000001_drizzle_migration/migration.sql", + "20221201000001_create_users/schema.sql", } remote := []string{ "20221201000000", @@ -128,7 +128,7 @@ func TestPendingMigrations(t *testing.T) { t.Run("detects missing local for folder-based migrations", func(t *testing.T) { local := []string{ - "20221201000000_drizzle_one/migration.sql", + "20221201000000_create_users/schema.sql", } remote := []string{ "20221201000000", diff --git a/pkg/migration/file.go b/pkg/migration/file.go index 2668bec17e..674d32bb3c 100644 --- a/pkg/migration/file.go +++ b/pkg/migration/file.go @@ -48,13 +48,13 @@ func NewMigrationFromFile(path string, fsys fs.FS) (*MigrationFile, error) { // ParseVersion extracts the version and name from a migration path. // Handles both flat files (20220727064247_create_table.sql) and -// folder-based migrations (20242409125510_premium_mister_fear/migration.sql). +// folder-based migrations (20242409125510_premium_mister_fear/.sql). func ParseVersion(path string) (version, name string, ok bool) { filename := filepath.Base(path) if matches := migrateFilePattern.FindStringSubmatch(filename); len(matches) > 2 { return matches[1], matches[2], true } - // Try parent directory for folder-based migrations (e.g. Drizzle v1) + // Try parent directory for folder-based migrations dirName := filepath.Base(filepath.Dir(path)) if matches := migrateDirPattern.FindStringSubmatch(dirName); len(matches) > 2 { return matches[1], matches[2], true @@ -64,7 +64,7 @@ func ParseVersion(path string) (version, name string, ok bool) { // MigrationName returns a human-readable display name for a migration path. // For flat files: "20220727064247_create_table.sql" -// For folder migrations: "20242409125510_premium_mister_fear/migration.sql" +// For folder migrations: "20242409125510_premium_mister_fear/.sql" func MigrationName(path string) string { filename := filepath.Base(path) if migrateFilePattern.MatchString(filename) { diff --git a/pkg/migration/file_test.go b/pkg/migration/file_test.go index 671fcc3f11..fd137725db 100644 --- a/pkg/migration/file_test.go +++ b/pkg/migration/file_test.go @@ -103,11 +103,11 @@ func TestMigrationFile(t *testing.T) { t.Run("new from folder-based migration", func(t *testing.T) { // Setup in-memory fs fsys := fs.MapFS{ - "20242409125510_premium_mister_fear/migration.sql": &fs.MapFile{Data: []byte("CREATE TABLE foo (id int)")}, - "20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{Data: []byte("{}")}, + "20242409125510_premium_mister_fear/schema.sql": &fs.MapFile{Data: []byte("CREATE TABLE foo (id int)")}, + "20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{Data: []byte("{}")}, } // Run test - migration, err := NewMigrationFromFile("20242409125510_premium_mister_fear/migration.sql", fsys) + migration, err := NewMigrationFromFile("20242409125510_premium_mister_fear/schema.sql", fsys) // Check error assert.NoError(t, err) assert.Equal(t, "20242409125510", migration.Version) @@ -190,14 +190,14 @@ func TestParseVersion(t *testing.T) { }) t.Run("extracts version from folder-based migration", func(t *testing.T) { - version, name, ok := ParseVersion("supabase/migrations/20242409125510_premium_mister_fear/migration.sql") + version, name, ok := ParseVersion("supabase/migrations/20242409125510_premium_mister_fear/schema.sql") assert.True(t, ok) assert.Equal(t, "20242409125510", version) assert.Equal(t, "premium_mister_fear", name) }) t.Run("extracts version from folder-based migration without parent path", func(t *testing.T) { - version, name, ok := ParseVersion("20242409125510_premium_mister_fear/migration.sql") + version, name, ok := ParseVersion("20242409125510_premium_mister_fear/schema.sql") assert.True(t, ok) assert.Equal(t, "20242409125510", version) assert.Equal(t, "premium_mister_fear", name) @@ -208,8 +208,8 @@ func TestParseVersion(t *testing.T) { assert.False(t, ok) }) - t.Run("returns false for migration.sql without matching parent dir", func(t *testing.T) { - _, _, ok := ParseVersion("some_dir/migration.sql") + t.Run("returns false for .sql without matching parent dir", func(t *testing.T) { + _, _, ok := ParseVersion("some_dir/schema.sql") assert.False(t, ok) }) } @@ -221,8 +221,8 @@ func TestMigrationName(t *testing.T) { t.Run("returns dir/file for folder-based migration", func(t *testing.T) { assert.Equal(t, - "20242409125510_premium_mister_fear/migration.sql", - MigrationName("supabase/migrations/20242409125510_premium_mister_fear/migration.sql"), + "20242409125510_premium_mister_fear/schema.sql", + MigrationName("supabase/migrations/20242409125510_premium_mister_fear/schema.sql"), ) }) diff --git a/pkg/migration/list.go b/pkg/migration/list.go index 5301f38efb..ce36312a3b 100644 --- a/pkg/migration/list.go +++ b/pkg/migration/list.go @@ -46,13 +46,28 @@ OUTER: if len(matches) == 0 { continue } - // Look for migration.sql inside the directory (e.g. Drizzle v1 format) - sqlPath := filepath.Join(migrationsDir, dirName, "migration.sql") - if _, err := fs.Stat(fsys, sqlPath); err != nil { - fmt.Fprintf(os.Stderr, "Skipping migration directory %s... (missing migration.sql)\n", dirName) + // Look for exactly one .sql file inside the directory + dirPath := filepath.Join(migrationsDir, dirName) + entries, err := fs.ReadDir(fsys, dirPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Skipping migration directory %s... (%v)\n", dirName, err) continue } - path = sqlPath + var sqlFiles []string + for _, e := range entries { + if !e.IsDir() && filepath.Ext(e.Name()) == ".sql" { + sqlFiles = append(sqlFiles, e.Name()) + } + } + if len(sqlFiles) != 1 { + if len(sqlFiles) == 0 { + fmt.Fprintf(os.Stderr, "Skipping migration directory %s... (no .sql file found)\n", dirName) + } else { + fmt.Fprintf(os.Stderr, "Skipping migration directory %s... (multiple .sql files found)\n", dirName) + } + continue + } + path = filepath.Join(migrationsDir, dirName, sqlFiles[0]) version = matches[1] } else { filename := entry.Name() diff --git a/pkg/migration/list_test.go b/pkg/migration/list_test.go index 87044c9841..afe0e61a0c 100644 --- a/pkg/migration/list_test.go +++ b/pkg/migration/list_test.go @@ -93,8 +93,8 @@ func TestLocalMigrations(t *testing.T) { t.Run("loads folder-based migrations", func(t *testing.T) { // Setup in-memory fs fsys := fs.MapFS{ - "20220727064246_test.sql": &fs.MapFile{}, - "20242409125510_premium_mister_fear/migration.sql": &fs.MapFile{}, + "20220727064246_test.sql": &fs.MapFile{}, + "20242409125510_premium_mister_fear/schema.sql": &fs.MapFile{}, "20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{}, } // Run test @@ -103,12 +103,12 @@ func TestLocalMigrations(t *testing.T) { assert.NoError(t, err) assert.Equal(t, []string{ "20220727064246_test.sql", - "20242409125510_premium_mister_fear/migration.sql", + "20242409125510_premium_mister_fear/schema.sql", }, versions) }) - t.Run("skips directory without migration.sql", func(t *testing.T) { - // Setup in-memory fs — directory with only snapshot.json, no migration.sql + t.Run("skips directory without .sql file", func(t *testing.T) { + // Setup in-memory fs — directory with only snapshot.json, no .sql file fsys := fs.MapFS{ "20242409125510_premium_mister_fear/snapshot.json": &fs.MapFile{}, } @@ -119,10 +119,23 @@ func TestLocalMigrations(t *testing.T) { assert.Empty(t, versions) }) + t.Run("skips directory with multiple .sql files", func(t *testing.T) { + // Setup in-memory fs — directory with more than one .sql file + fsys := fs.MapFS{ + "20242409125510_premium_mister_fear/schema.sql": &fs.MapFile{}, + "20242409125510_premium_mister_fear/extra.sql": &fs.MapFile{}, + } + // Run test + versions, err := ListLocalMigrations(".", fsys) + // Check error + assert.NoError(t, err) + assert.Empty(t, versions) + }) + t.Run("skips directory with non-matching name", func(t *testing.T) { // Setup in-memory fs — directory name doesn't start with digits fsys := fs.MapFS{ - "some_random_dir/migration.sql": &fs.MapFile{}, + "some_random_dir/schema.sql": &fs.MapFile{}, } // Run test versions, err := ListLocalMigrations(".", fsys)