feat(migrate): add lifecycle commands and standalone authoring
This commit is contained in:
1 parent
3cd3f49c11
commit
2727ac83db
12 files changed
+1553
-31
No files matched your search
@@ -0,0 +1,146 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in new issue
Block a user