feat(migrate): add lifecycle commands and standalone authoring (#18)
Documentation / docs (push) Successful in 22s
ci / check (push) Successful in 41s
release / release (push) Successful in 25s

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
This commit was merged in pull request #18.
This commit is contained in:
end committed 2026-10-28 16:10:40 -07:00
1 parent 0034a561ba
commit a99e364ac3
12 files changed
+1678 -30

No files matched your search

+146
View File
@@ -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)
}
+289
View File
@@ -0,0 +1,289 @@
package lifecycle_test
import (
"bytes"
"context"
"database/sql"
"embed"
"errors"
"io"
"log/slog"
"os"
"path/filepath"
"reflect"
"strings"
"syscall"
"testing"
"time"
"github.com/pressly/goose/v3"
urfavecli "github.com/urfave/cli/v3"
"go.auvem.com/corestack/lifecycle"
"go.auvem.com/corestack/lifecycle/cli"
"go.auvem.com/corestack/migrate"
adapter "go.auvem.com/corestack/migrate/lifecycle"
_ "modernc.org/sqlite"
)
//go:embed testdata
var files embed.FS
func config() lifecycle.Config {
return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
}
func database(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := db.Close(); err != nil {
t.Error(err)
}
})
return db
}
func provider(t *testing.T, db *sql.DB) *goose.Provider {
t.Helper()
p, err := migrate.New(db, goose.DialectSQLite3, files, "testdata/migrations", config().Logger)
if err != nil {
t.Fatal(err)
}
return p
}
func TestStartupPolicies(t *testing.T) {
for _, policy := range []adapter.Policy{adapter.Apply, adapter.Require} {
for _, applied := range []bool{false, true} {
t.Run(string(policy)+"/applied="+stringBool(applied), func(t *testing.T) {
db := database(t)
p := provider(t, db)
if applied {
if _, err := p.Up(t.Context()); err != nil {
t.Fatal(err)
}
}
var calls []string
var setupCtx context.Context
dbModule := lifecycle.Module{Name: "db", Setup: func(ctx context.Context) error { setupCtx = ctx; calls = append(calls, "db"); return nil }, Teardown: func(ctx context.Context) error {
if ctx.Err() != nil {
t.Error(ctx.Err())
}
calls = append(calls, "close")
return nil
}}
module, err := adapter.Module("migrations", policy, func(ctx context.Context) (*goose.Provider, error) {
if ctx != setupCtx {
t.Error("setup context changed")
}
calls = append(calls, "resolve")
return provider(t, db), nil
})
if err != nil {
t.Fatal(err)
}
if len(calls) != 0 || module.Run != nil {
t.Fatal("declaration started work")
}
app, err := lifecycle.New(config())
if err != nil {
t.Fatal(err)
}
later := lifecycle.Module{Name: "later", Setup: func(context.Context) error { calls = append(calls, "later"); return nil }, Run: func(context.Context) error { calls = append(calls, "service"); return nil }, Teardown: func(context.Context) error { calls = append(calls, "later-close"); return nil }}
for _, m := range []lifecycle.Module{dbModule, module, later} {
if err := app.Register(m); err != nil {
t.Fatal(err)
}
}
err = app.Run(t.Context())
if policy == adapter.Require && !applied {
if err == nil || !strings.Contains(err.Error(), "pending") {
t.Fatalf("pending Require: %v", err)
}
if !reflect.DeepEqual(calls, []string{"db", "resolve", "close"}) {
t.Fatal(calls)
}
} else {
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(calls, []string{"db", "resolve", "later", "service", "later-close", "close"}) {
t.Fatal(calls)
}
pending, err := p.HasPending(t.Context())
if err != nil || pending {
t.Fatalf("pending=%v err=%v", pending, err)
}
}
if err := db.PingContext(t.Context()); err != nil {
t.Fatalf("adapter closed caller DB: %v", err)
}
})
}
}
}
func stringBool(v bool) string {
if v {
return "true"
}
return "false"
}
func TestModuleValidationAndErrors(t *testing.T) {
sentinel := errors.New("resolve failure")
for _, tc := range []struct {
name string
policy adapter.Policy
resolver func(context.Context) (*goose.Provider, error)
}{{"", adapter.Apply, func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", "", func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", "invalid", func(context.Context) (*goose.Provider, error) { return nil, nil }}, {"m", adapter.Apply, nil}} {
if _, err := adapter.Module(tc.name, tc.policy, tc.resolver); err == nil {
t.Fatal("accepted invalid module")
}
}
for _, policy := range []adapter.Policy{adapter.Apply, adapter.Require} {
for _, resolver := range []func(context.Context) (*goose.Provider, error){func(context.Context) (*goose.Provider, error) { return nil, nil }, func(context.Context) (*goose.Provider, error) { return nil, sentinel }, func(context.Context) (*goose.Provider, error) {
db := database(t)
p := provider(t, db)
if err := db.Close(); err != nil {
t.Fatal(err)
}
return p, nil
}} {
m, err := adapter.Module("m", policy, resolver)
if err != nil {
t.Fatal(err)
}
if err := m.Setup(t.Context()); err == nil {
t.Fatal("accepted resolver/DB failure")
}
}
}
m, err := adapter.Module("m", adapter.Apply, func(context.Context) (*goose.Provider, error) { return nil, sentinel })
if err != nil {
t.Fatal(err)
}
if err := m.Setup(t.Context()); !errors.Is(err, sentinel) {
t.Fatal(err)
}
}
func run(t *testing.T, args []string, modules []lifecycle.Module, resolver func(context.Context) (*goose.Provider, error), writer io.Writer) (int, error) {
t.Helper()
root := &urfavecli.Command{Name: "app", Writer: writer, ErrWriter: io.Discard, Commands: []*urfavecli.Command{adapter.Commands(config(), modules, resolver)}}
return cli.Run(t.Context(), root, append([]string{"app", "migrate"}, args...))
}
func TestCommandsSQLite(t *testing.T) {
db := database(t)
var out bytes.Buffer
var calls []string
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { calls = append(calls, "setup"); return nil }, Run: func(context.Context) error { t.Error("one-off ran service"); return nil }, Teardown: func(context.Context) error { calls = append(calls, "cleanup"); return nil }}
resolver := func(context.Context) (*goose.Provider, error) {
calls = append(calls, "resolve")
return provider(t, db), nil
}
for _, step := range []struct {
args []string
version int64
}{{[]string{"up-to", "1"}, 1}, {[]string{"up"}, 2}, {[]string{"status"}, 2}, {[]string{"down"}, 1}, {[]string{"up-to", "2"}, 2}, {[]string{"down-to", "0"}, 0}} {
calls = nil
code, err := run(t, step.args, []lifecycle.Module{module}, resolver, &out)
if code != 0 || err != nil {
t.Fatalf("%v code=%d err=%v", step.args, code, err)
}
version, err := provider(t, db).GetDBVersion(t.Context())
if err != nil || version != step.version {
t.Fatalf("%v version=%d err=%v", step.args, version, err)
}
if !reflect.DeepEqual(calls, []string{"setup", "resolve", "cleanup"}) {
t.Fatal(calls)
}
}
// Zero is valid syntax; Goose UpTo rejects it after setup when migrations are pending.
code, err := run(t, []string{"up-to", "0"}, []lifecycle.Module{module}, resolver, &out)
if code != 1 || err == nil || !strings.Contains(err.Error(), "greater than 0") {
t.Fatalf("zero UpTo: code=%d err=%v", code, err)
}
if !strings.Contains(out.String(), "VERSION") || !strings.Contains(out.String(), "applied") || !strings.Contains(out.String(), "00002_second.sql") {
t.Fatal(out.String())
}
}
func TestHelpAndUsageWithoutSetup(t *testing.T) {
for _, leaf := range []string{"up", "up-to", "down", "down-to", "status"} {
code, err := run(t, []string{leaf, "--help"}, nil, nil, io.Discard)
if code != 0 || err != nil {
t.Fatalf("%s help: %d %v", leaf, code, err)
}
}
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { t.Error("usage opened DB"); return nil }, Teardown: func(context.Context) error { return nil }}
for _, tc := range []struct {
args []string
code int
}{{nil, 0}, {[]string{"--help"}, 0}, {[]string{"help", "up-to"}, 0}, {[]string{"missing"}, 2}, {[]string{"up", "extra"}, 2}, {[]string{"down", "extra"}, 2}, {[]string{"status", "extra"}, 2}, {[]string{"up-to"}, 2}, {[]string{"down-to"}, 2}, {[]string{"up-to", "1", "2"}, 2}, {[]string{"down-to", "--", "-1"}, 2}, {[]string{"up-to", "+1"}, 2}, {[]string{"up-to", "9223372036854775808"}, 2}, {[]string{"down-to", "1x"}, 2}, {[]string{"up-to", "1"}, 2}} {
code, err := run(t, tc.args, []lifecycle.Module{module}, nil, io.Discard)
if code != tc.code || (code == 0 && err != nil) || (code != 0 && err == nil) {
t.Fatalf("args=%v code=%d err=%v", tc.args, code, err)
}
}
}
type brokenWriter struct{ err error }
func (w brokenWriter) Write([]byte) (int, error) { return 0, w.err }
func TestCommandFailureCleanup(t *testing.T) {
db := database(t)
writeErr := errors.New("write failed")
resolveErr := errors.New("resolve failed")
cleanupErr := errors.New("cleanup failed")
for _, tc := range []struct {
name string
resolver func(context.Context) (*goose.Provider, error)
writer io.Writer
want error
}{{"writer", func(context.Context) (*goose.Provider, error) { return provider(t, db), nil }, brokenWriter{writeErr}, writeErr}, {"resolver", func(context.Context) (*goose.Provider, error) { return nil, resolveErr }, io.Discard, resolveErr}, {"nil", nil, io.Discard, nil}, {"nil-provider", func(context.Context) (*goose.Provider, error) { return nil, nil }, io.Discard, nil}} {
t.Run(tc.name, func(t *testing.T) {
cleaned := false
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { cleaned = true; return cleanupErr }}
code, err := run(t, []string{"status"}, []lifecycle.Module{module}, tc.resolver, tc.writer)
if code != 1 || err == nil || !cleaned || !errors.Is(err, cleanupErr) || (tc.want != nil && !errors.Is(err, tc.want)) {
t.Fatalf("code=%d err=%v cleaned=%v", code, err, cleaned)
}
})
}
}
func TestCommandSignalContext(t *testing.T) {
signals := make(chan os.Signal, 2)
cfg := config()
cfg.Signals = signals
entered := make(chan struct{})
done := make(chan error, 1)
cleaned := false
module := lifecycle.Module{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(ctx context.Context) error {
if ctx.Err() != nil {
t.Error(ctx.Err())
}
cleaned = true
return nil
}}
root := &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{adapter.Commands(cfg, []lifecycle.Module{module}, func(ctx context.Context) (*goose.Provider, error) {
close(entered)
<-ctx.Done()
return nil, ctx.Err()
})}}
go func() { _, err := cli.Run(t.Context(), root, []string{"app", "migrate", "up"}); done <- err }()
select {
case <-entered:
case <-time.After(5 * time.Second):
t.Fatal("resolver did not start")
}
signals <- syscall.SIGTERM
select {
case err := <-done:
if err != nil || !cleaned {
t.Fatalf("err=%v cleaned=%v", err, cleaned)
}
case <-time.After(5 * time.Second):
t.Fatal("signal did not cancel resolver")
}
}
+4
View File
@@ -0,0 +1,4 @@
-- +goose Up
CREATE TABLE events (id INTEGER PRIMARY KEY);
-- +goose Down
DROP TABLE events;
@@ -0,0 +1,4 @@
-- +goose Up
INSERT INTO events VALUES (2);
-- +goose Down
DELETE FROM events WHERE id = 2;