Files
job-search/internal/store/migrate.go
T
bwilliams 465358609a Naming, layout and comments: descriptive full-word names, one item per line in wrapped calls and literals, and doc comments on exported names
Short and single-letter names in application code become full words (tests
keep theirs). Wrapped calls, signatures and composite literals put one item
per line, and one-line function bodies are expanded. Exported names and
groups of constants get doc comments, and comments refer to the renamed
identifiers.
2026-10-15 16:12:56 +01:00

211 lines
5.1 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(provider *goose.Provider) int64 {
sources := provider.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, provider *goose.Provider) error {
current, err := provider.GetDBVersion(ctx)
if err != nil {
return err
}
latest := LatestVersion(provider)
if current > latest {
return fmt.Errorf(
"%w (database is at version %d, this build knows up to %d)",
ErrSchemaTooNew,
current,
latest,
)
}
pending, err := provider.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, provider *goose.Provider) error {
current, err := provider.GetDBVersion(ctx)
if err != nil {
return err
}
if latest := LatestVersion(provider); 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, opts options) error {
provider, err := NewMigrator(db)
if err != nil {
return err
}
if opts.autoMigrate {
if _, err := provider.Up(ctx); err != nil {
return fmt.Errorf("store: migrating database: %w", err)
}
}
return checkVersion(ctx, provider)
}
// StepUp applies steps pending migrations, or all of them when steps is 0.
func StepUp(
ctx context.Context, provider *goose.Provider, steps int,
) ([]*goose.MigrationResult, error) {
var results []*goose.MigrationResult
if err := checkNotNewer(ctx, provider); err != nil {
return results, err
}
var err error
if steps == 0 {
results, err = provider.Up(ctx)
} else {
for range steps {
var result *goose.MigrationResult
result, err = provider.UpByOne(ctx)
if errors.Is(err, goose.ErrNoNextVersion) {
err = nil
break
}
if err != nil {
break
}
results = append(results, result)
}
}
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, provider *goose.Provider, steps int, confirmDrop bool,
) ([]*goose.MigrationResult, error) {
var results []*goose.MigrationResult
if err := checkNotNewer(ctx, provider); err != nil {
return results, err
}
current, err := provider.GetDBVersion(ctx)
if err != nil {
return results, err
}
if current == 0 {
return results, nil
}
statuses, err := provider.Status(ctx)
if err != nil {
return results, err
}
applied := 0
for _, status := range statuses {
if status.State == goose.StateApplied {
applied++
}
}
all := steps == 0 || steps >= applied
if all && !confirmDrop {
return results, ErrWouldEmptyDatabase
}
if all {
results, err = provider.DownTo(ctx, 0)
} else {
for range steps {
var result *goose.MigrationResult
result, err = provider.Down(ctx)
if errors.Is(err, goose.ErrNoNextVersion) {
err = nil
break
}
if err != nil {
break
}
results = append(results, result)
}
}
return results, err
}