// 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 = "" } 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) }