147 lines
4.8 KiB
Go
147 lines
4.8 KiB
Go
// Package lifecycle adapts Goose providers to startup policy and native commands.
|
|
// Callers own database modules and supply fresh, late-bound providers.
|
|
package lifecycle
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"text/tabwriter"
|
|
|
|
"github.com/pressly/goose/v3"
|
|
urfavecli "github.com/urfave/cli/v3"
|
|
"go.auvem.com/corestack/lifecycle"
|
|
"go.auvem.com/corestack/lifecycle/cli"
|
|
)
|
|
|
|
// Policy selects migration behavior during module setup.
|
|
type Policy string
|
|
|
|
const (
|
|
// Apply runs pending migrations before later modules start.
|
|
Apply Policy = "apply"
|
|
// Require fails setup when migrations are pending. This is a point-in-time
|
|
// check, not a deployment lock; Goose may initialize its version table.
|
|
Require Policy = "require"
|
|
)
|
|
|
|
// Module declares startup migration work without resolving provider yet.
|
|
// Register it after the database module and before dependent modules. The
|
|
// resolver must return fresh registrations and clean up any partial acquisition.
|
|
// Teardown never closes the provider: the database module owns its database.
|
|
func Module(name string, policy Policy, provider func(context.Context) (*goose.Provider, error)) (lifecycle.Module, error) {
|
|
if name == "" || (policy != Apply && policy != Require) || provider == nil {
|
|
return lifecycle.Module{}, errors.New("migrate: name, apply/require policy and provider resolver are required")
|
|
}
|
|
return lifecycle.Module{Name: name, Teardown: func(context.Context) error { return nil }, Setup: func(ctx context.Context) error {
|
|
p, err := resolve(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if policy == Apply {
|
|
_, err = p.Up(ctx)
|
|
} else {
|
|
var pending bool
|
|
pending, err = p.HasPending(ctx)
|
|
if err == nil && pending {
|
|
return errors.New("migrate: pending migrations; run migrate up before startup")
|
|
}
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("migrate: %s: %w", policy, err)
|
|
}
|
|
return nil
|
|
}}, nil
|
|
}
|
|
|
|
func resolve(ctx context.Context, provider func(context.Context) (*goose.Provider, error)) (*goose.Provider, error) {
|
|
if provider == nil {
|
|
return nil, errors.New("migrate: provider resolver is required")
|
|
}
|
|
p, err := provider(ctx)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("migrate: resolve provider: %w", err)
|
|
}
|
|
if p == nil {
|
|
return nil, errors.New("migrate: resolver returned nil provider")
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// Commands returns a fresh native migrate group using exactly modules, with
|
|
// services suppressed. Supply database modules only, excluding startup policy.
|
|
// Resolvers run after setup. Help/usage starts nothing; Action owns signals and
|
|
// reverse cleanup. Use fresh trees and bindings for independent invocations.
|
|
// Status may initialize the version table; it does not run migration bodies.
|
|
func Commands(config lifecycle.Config, modules []lifecycle.Module, provider func(context.Context) (*goose.Provider, error)) *urfavecli.Command {
|
|
group := &urfavecli.Command{Name: "migrate", Usage: "Manage database migrations"}
|
|
for _, name := range []string{"up", "up-to", "down", "down-to", "status"} {
|
|
targeted := strings.HasSuffix(name, "-to")
|
|
leaf := &urfavecli.Command{Name: name, ArgValidator: func(_ context.Context, cmd *urfavecli.Command) error {
|
|
if targeted {
|
|
if cmd.NArg() == 1 {
|
|
if _, err := version(cmd.Args().First()); err == nil {
|
|
return nil
|
|
}
|
|
}
|
|
return urfavecli.Exit("expected one nonnegative decimal int64 version", 2)
|
|
}
|
|
if cmd.NArg() != 0 {
|
|
return urfavecli.Exit("unexpected positional arguments", 2)
|
|
}
|
|
return nil
|
|
}}
|
|
if targeted {
|
|
leaf.ArgsUsage = "<version>"
|
|
}
|
|
leaf.Action = cli.Action(config, modules, func(ctx context.Context, cmd *urfavecli.Command) error {
|
|
p, err := resolve(ctx, provider)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
target, _ := version(cmd.Args().First()) // ArgValidator checked targeted leaves before setup.
|
|
switch name {
|
|
case "up":
|
|
_, err = p.Up(ctx)
|
|
case "up-to":
|
|
_, err = p.UpTo(ctx, target)
|
|
case "down":
|
|
_, err = p.Down(ctx)
|
|
case "down-to":
|
|
_, err = p.DownTo(ctx, target)
|
|
case "status":
|
|
var statuses []*goose.MigrationStatus
|
|
statuses, err = p.Status(ctx)
|
|
if err == nil {
|
|
writer := tabwriter.NewWriter(cmd.Writer, 0, 4, 2, ' ', 0)
|
|
if _, err = fmt.Fprintln(writer, "VERSION\tSTATE\tSOURCE"); err == nil {
|
|
for _, status := range statuses {
|
|
if _, err = fmt.Fprintf(writer, "%d\t%s\t%s\n", status.Source.Version, status.State, status.Source.Path); err != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if err == nil {
|
|
err = writer.Flush()
|
|
}
|
|
}
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("migrate %s: %w", name, err)
|
|
}
|
|
return nil
|
|
})
|
|
group.Commands = append(group.Commands, leaf)
|
|
}
|
|
return group
|
|
}
|
|
|
|
func version(raw string) (int64, error) {
|
|
if raw == "" || strings.IndexFunc(raw, func(r rune) bool { return r < '0' || r > '9' }) >= 0 {
|
|
return 0, errors.New("invalid version")
|
|
}
|
|
return strconv.ParseInt(raw, 10, 64)
|
|
}
|