package store import ( "context" "database/sql" "embed" "errors" "fmt" "io/fs" "github.com/pressly/goose/v3" ) // Each file holds both directions, marked with -- +goose Up / -- +goose Down. // Create new ones with: goose -dir internal/store/migrations create NAME sql // //go:embed migrations/*.sql var migrationsFS embed.FS // ErrNeedsMigration is returned by Open when the database has pending // migrations. Apply them with the migrate command, or pass // WithAutoMigrate. var ErrNeedsMigration = errors.New("store: database needs migrating") // ErrSchemaTooNew is returned by Open when the database has migrations // this build doesn't know about, i.e. it was migrated by a newer build. var ErrSchemaTooNew = errors.New("store: database schema is newer than this build") // ErrWouldEmptyDatabase is returned by StepDown when the migration would drop // all of the tables and thus empty the database. var ErrWouldEmptyDatabase = errors.New( "migration cancelled; database would be emptied by undoing all migrations", ) // NewMigrator returns a goose Provider over the store's embedded migrations, // for applying, rolling back or inspecting them. Each migration runs in its // own transaction, so a failed migration leaves the database at the previous // version (goose has no "dirty" state). func NewMigrator(db *sql.DB) (*goose.Provider, error) { fsys, err := fs.Sub(migrationsFS, "migrations") if err != nil { return nil, err } return goose.NewProvider(goose.DialectSQLite3, db, fsys) } // LatestVersion is the highest migration version in this build. func LatestVersion(p *goose.Provider) int64 { sources := p.ListSources() // sorted ascending if len(sources) == 0 { return 0 } return sources[len(sources)-1].Version } // checkVersion returns ErrNeedsMigration or ErrSchemaTooNew unless the // database is exactly at this build's schema. func checkVersion(ctx context.Context, p *goose.Provider) error { current, err := p.GetDBVersion(ctx) if err != nil { return err } latest := LatestVersion(p) if current > latest { return fmt.Errorf("%w (database is at version %d, this build knows up to %d)", ErrSchemaTooNew, current, latest) } pending, err := p.HasPending(ctx) if err != nil { return err } if pending { return fmt.Errorf("%w (database is at version %d, latest is %d)", ErrNeedsMigration, current, latest) } return nil } // checkNotNewer refuses to work on a database migrated by a newer build: // this build has neither the newer migrations nor their down steps. func checkNotNewer(ctx context.Context, m *goose.Provider) error { current, err := m.GetDBVersion(ctx) if err != nil { return err } if latest := LatestVersion(m); current > latest { return fmt.Errorf( "database is at version %d but this build only knows up to %d; use a newer build", current, latest) } return nil } // prepareSchema migrates (with WithAutoMigrate) or verifies the schema // version. func prepareSchema(ctx context.Context, db *sql.DB, o options) error { p, err := NewMigrator(db) if err != nil { return err } if o.autoMigrate { if _, err := p.Up(ctx); err != nil { return fmt.Errorf("store: migrating database: %w", err) } } return checkVersion(ctx, p) } // StepUp applies steps pending migrations, or all of them when steps is 0. func StepUp( ctx context.Context, m *goose.Provider, steps int, ) ([]*goose.MigrationResult, error) { var results []*goose.MigrationResult if err := checkNotNewer(ctx, m); err != nil { return results, err } var err error if steps == 0 { results, err = m.Up(ctx) } else { for range steps { var r *goose.MigrationResult r, err = m.UpByOne(ctx) if errors.Is(err, goose.ErrNoNextVersion) { err = nil break } if err != nil { break } results = append(results, r) } } return results, err } // StepDown rolls back steps applied migrations, or all of them when steps // is 0. Rolling everything back deletes all data, so confirmDrop is checked // first in that case. func StepDown( ctx context.Context, m *goose.Provider, steps int, confirmDrop bool, ) ([]*goose.MigrationResult, error) { var results []*goose.MigrationResult if err := checkNotNewer(ctx, m); err != nil { return results, err } current, err := m.GetDBVersion(ctx) if err != nil { return results, err } if current == 0 { return results, nil } statuses, err := m.Status(ctx) if err != nil { return results, err } applied := 0 for _, s := range statuses { if s.State == goose.StateApplied { applied++ } } all := steps == 0 || steps >= applied if all && !confirmDrop { return results, ErrWouldEmptyDatabase } if all { results, err = m.DownTo(ctx, 0) } else { for range steps { var r *goose.MigrationResult r, err = m.Down(ctx) if errors.Is(err, goose.ErrNoNextVersion) { err = nil break } if err != nil { break } results = append(results, r) } } return results, err }