Files
job-search/internal/store/migrate.go
T

201 lines
4.9 KiB
Go

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
}