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
This commit was merged in pull request #18.
This commit is contained in:
1 parent
0034a561ba
commit
a99e364ac3
12 files changed
+1678
-30
No files matched your search
+106
-26
@@ -5,21 +5,60 @@
|
||||
It returns `*goose.Provider` directly: Goose owns execution, version tracking,
|
||||
status, and errors. There is no lifecycle dependency or background work.
|
||||
|
||||
Create SQL migration files with Goose's
|
||||
[`create` command](https://pressly.github.io/goose/documentation/cli-commands/#create).
|
||||
For timestamp-to-sequential reconciliation before deployment, use its
|
||||
[`fix` command](https://pressly.github.io/goose/documentation/cli-commands/#fix)
|
||||
as described in [hybrid versioning](https://github.com/pressly/goose#hybrid-versioning).
|
||||
These commands edit source files before embedding; `fix` does not update database
|
||||
history. Never renumber migrations already applied to shared databases.
|
||||
Use the standalone `corestack-goose` development tool for SQL and Go creation
|
||||
and timestamp-to-sequential reconciliation. Its generated Go migrations work
|
||||
unchanged with this package's provider-local registration:
|
||||
|
||||
**Goose's default Go template is not compatible unchanged with this package.**
|
||||
It registers migrations globally from `init`, while this package uses explicit
|
||||
provider-local registration. Until the compatible authoring CLI planned in
|
||||
[#9](https://gitea.auvem.com/corestack/go/issues/9) is available, Go migrations
|
||||
require the adaptation described below; do not treat stock `goose create ... go`
|
||||
output as ready to use. That follow-up must supply and document the supported
|
||||
creation/reconciliation workflow before it is complete.
|
||||
```sh
|
||||
go install go.auvem.com/corestack/cmd/corestack-goose@<release>
|
||||
mkdir -p migrations
|
||||
cd migrations
|
||||
corestack-goose create create_events sql
|
||||
corestack-goose fix
|
||||
corestack-goose create populate_events go
|
||||
corestack-goose fix
|
||||
# Or use the version selected by your consumer module:
|
||||
go run go.auvem.com/corestack/cmd/corestack-goose create add_index sql
|
||||
```
|
||||
|
||||
Both commands default to the current working directory; use `--dir <dir>` to
|
||||
select another existing directory. Paths must have no symlink components (on
|
||||
macOS use `/private/tmp`, rather than `/tmp`), and migration files must be regular.
|
||||
Names are lowercase ASCII words separated by underscores. Templates are stock
|
||||
Goose SQL or transactional Go. The Go suffix `<version>_<name>_migration.go`
|
||||
keeps names such as `test`, `create_test` and `windows` buildable on every platform.
|
||||
Edit up/down bodies; leave factories and generated `register.go` intact.
|
||||
Its `GoMigrations()` returns fresh registrations, even for SQL-only sets.
|
||||
|
||||
Versions must be unique across SQL and Go. Goose timestamps have second
|
||||
resolution: fix each new migration before creating another in the same second,
|
||||
or wait for a distinct timestamp. `fix` preserves sequential IDs and migration
|
||||
bodies, allocates timestamps after the highest sequential ID with at least
|
||||
five-digit padding, and updates filenames and registration literals together.
|
||||
Preflight rejects unsafe paths, collisions, unsupported Go layouts, edited
|
||||
aggregates, duplicate versions/factories, overflow and lexical/numeric order
|
||||
disagreement. Stock Goose Go output uses an incompatible global `init` registry.
|
||||
For concepts, see Goose's [hybrid versioning](https://github.com/pressly/goose#hybrid-versioning)
|
||||
and [SQL annotations](https://pressly.github.io/goose/documentation/annotations/).
|
||||
|
||||
Before `fix`, commit or separately back up the **complete source set**, including
|
||||
untracked migrations and tracked edits. Use one authoring process without concurrent
|
||||
editor changes. New destinations are exclusive; staged destinations are fully
|
||||
published before old sources are removed. This is **not an atomic multi-file
|
||||
operation**: interruption can leave duplicates, aggregate drift or staging files,
|
||||
with no automatic rollback or journal. On failure inspect the reported completed,
|
||||
partial/current and outstanding paths, then restore the complete source set and
|
||||
aggregate from backup before retrying. Ordinary Git restore cannot recover
|
||||
untracked files or unsaved edits.
|
||||
|
||||
Source reconciliation never rewrites database history. Use it before those IDs
|
||||
reach a shared database; preserve deployed IDs and add migrations afterward. The
|
||||
tool cannot determine deployment history.
|
||||
|
||||
The standalone tool needs no database or build tags. Production imports `migrate`
|
||||
and optionally `migrate/lifecycle`; neither imports authoring code. Generated
|
||||
migrations import only Goose and the standard library. Artifact tests verify the
|
||||
runtime dependency and executable boundary for the tested build.
|
||||
|
||||
Embed migrations in the application's binary and run them before serving traffic:
|
||||
|
||||
@@ -56,21 +95,62 @@ not defer it when sharing the pool with the application. Use `"."` for SQL files
|
||||
at the embedded root. Files in the selected directory are discovered directly,
|
||||
without recursively searching subdirectories.
|
||||
|
||||
Preserve Goose's creator/fix filenames and ship the complete migration history.
|
||||
Preserve the tool's creator/fix filenames and ship the complete migration history.
|
||||
The pinned Goose SQL-only provider relies on lexical filename order matching
|
||||
numeric version order. Timestamp versions and gaps are supported; never derive
|
||||
versions from file counts. For operations, errors, and SQL annotations, use the
|
||||
[Provider API](https://pkg.go.dev/github.com/pressly/goose/v3#Provider) and
|
||||
[SQL migration docs](https://pressly.github.io/goose/documentation/annotations/).
|
||||
versions from file counts. For operations and errors, use the
|
||||
[Provider API](https://pkg.go.dev/github.com/pressly/goose/v3#Provider).
|
||||
|
||||
For Go functions, use Goose's
|
||||
[provider-local registration](https://pressly.github.io/goose/documentation/provider/#withgomigrations):
|
||||
pass fresh `goose.NewGoMigration` values as the trailing arguments to `migrate.New`.
|
||||
The creator's default Go template instead calls `goose.AddMigrationContext` from
|
||||
`init`; replace that registration with provider-local construction. Global
|
||||
registration is disabled to keep each provider's migration set isolated and avoid
|
||||
import side effects. If `fix` renames Go migrations, update their explicit
|
||||
registration versions to match. Versions must be unique across SQL and Go files.
|
||||
For generated Go functions, import the consumer's migration package and pass
|
||||
`migrations.GoMigrations()...` as the trailing arguments to `migrate.New`.
|
||||
Embed the complete directory with `//go:embed migrations` (and use a different
|
||||
variable name, such as `migrationFiles`, from the imported package). The directory
|
||||
must exist even for Go-only sets. Global registration is disabled; never share
|
||||
mutable registration objects across providers. See Goose's
|
||||
[provider-local registration](https://pressly.github.io/goose/documentation/provider/#withgomigrations).
|
||||
|
||||
## Lifecycle startup and native commands
|
||||
|
||||
`go.auvem.com/corestack/migrate/lifecycle` is a thin adapter; `migrate` itself has
|
||||
no lifecycle dependency. Alias it `migratelifecycle` to distinguish the core
|
||||
lifecycle package. Its resolver runs after the caller's database setup and should
|
||||
construct a fresh provider with fresh registrations:
|
||||
|
||||
```go
|
||||
resolve := func(ctx context.Context) (*goose.Provider, error) {
|
||||
return migrate.New(db, goose.DialectPostgres, migrationFiles,
|
||||
"migrations", logger, migrations.GoMigrations()...)
|
||||
}
|
||||
startup, err := migratelifecycle.Module("migrations", migratelifecycle.Require, resolve)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
|
||||
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{database, startup, server})},
|
||||
migratelifecycle.Commands(cfg, []lifecycle.Module{database}, resolve),
|
||||
}}
|
||||
code, err := cli.Run(ctx, root, args) // args includes argv[0]; caller owns process exit.
|
||||
```
|
||||
|
||||
Here `database.Setup` opens `db` and cleans up its partial acquisition on failure;
|
||||
`database.Teardown` closes it. Construct the tree and captured resource bindings
|
||||
fresh for independent invocations. Declaration does no work. Select `Apply` to run
|
||||
Up during setup, or `Require` to fail before later setup/services when migrations
|
||||
are pending. Require is a point-in-time check without a deployment lock. It and
|
||||
status may initialize Goose's version table; neither promises read-only DB access.
|
||||
The migration module never closes the provider or shared database.
|
||||
|
||||
The native group supplies `migrate up`, `up-to <version>` (inclusive), `down`
|
||||
(one migration), `down-to <version>` and `status` (version/state/source table).
|
||||
Pass exactly the database modules, excluding the startup migration module and
|
||||
unrelated modules. Services are suppressed; the existing `lifecycle/cli.Action`
|
||||
owns signals, cancellation, errors and reverse cleanup. Help and invalid arguments
|
||||
never run setup. Target versions must be nonnegative decimal int64 values, with
|
||||
no extra arguments; malformed usage returns exit 2 through `cli.Run`. Zero follows
|
||||
Goose: `down-to 0` rolls everything back; `up-to 0` succeeds if nothing is pending,
|
||||
but returns a runtime error when pending work reaches Goose's version validation.
|
||||
Resolver/Goose/cleanup and status Writer failures return runtime errors (exit 1).
|
||||
Supply the native command Writer and configured Goose logger for output.
|
||||
|
||||
Postgres providers automatically use Goose's default session advisory locker.
|
||||
Concurrent startup calls from separate providers/processes serialize migration
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
+7
-4
@@ -23,10 +23,13 @@ import (
|
||||
// across providers/processes. PostgreSQL connections must preserve session
|
||||
// affinity (transaction-pooling proxies cannot safely hold session locks).
|
||||
//
|
||||
// Use the returned Provider's Up, UpTo, DownTo, Status, GetDBVersion, GetVersions,
|
||||
// and HasPending methods directly. Create migration files with Goose's create
|
||||
// command; lexical filename order must match numeric version order. Go migrations
|
||||
// are registered per provider; global Go registration is ignored.
|
||||
// Use the returned Provider's Up, UpTo, Down, DownTo, Status, GetDBVersion, GetVersions,
|
||||
// and HasPending methods directly. Create and reconcile sources with the standalone
|
||||
// corestack-goose tool (go run go.auvem.com/corestack/cmd/corestack-goose).
|
||||
// Pass migrations.GoMigrations()... from its generated package; stock Goose Go
|
||||
// output uses an incompatible global init registry. Lexical filename order must
|
||||
// match numeric version order. Each provider needs fresh Go registrations;
|
||||
// global Go registration is ignored.
|
||||
func New(db *sql.DB, dialect goose.Dialect, files embed.FS, dir string, logger *slog.Logger, migrations ...*goose.Migration) (*goose.Provider, error) {
|
||||
for i, migration := range migrations {
|
||||
if migration == nil {
|
||||
|
||||
Reference in new issue
Block a user