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

+106 -26
View File
@@ -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
+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;
+7 -4
View File
@@ -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 {