201 lines
4.9 KiB
Go
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
|
|
}
|