Files
go/migrate/lifecycle/lifecycle.go
T
end a99e364ac3
Documentation / docs (push) Successful in 22s
ci / check (push) Successful in 41s
release / release (push) Successful in 25s
feat(migrate): add lifecycle commands and standalone authoring (#18)
Add apply/require startup policies and five database-only migration commands.

Provide standalone corestack-goose creation and reconciliation with fresh
provider-local Go registrations. Default optional --dir to the working directory.
Document the production boundary and source recovery requirements.

Closes #9
2026-10-28 16:10:40 -07:00

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)
}