Files
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

69 lines
2.6 KiB
Go

// Package migrate configures Goose providers for embedded application migrations.
// Applications own their database, migration files, and startup ordering.
package migrate
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"log/slog"
"github.com/pressly/goose/v3"
"github.com/pressly/goose/v3/lock"
)
// New returns a provider for SQL files in dir and optional provider-local Go
// migrations. Pass "." for files at the embedded root and a nonnil logger.
// The selected directory must exist, including when using only Go migrations.
// Construction does not connect to db; the caller owns and closes db.
//
// PostgreSQL uses Goose's session advisory locker with its default lock ID and
// retry limits. Other dialects require the caller to serialize migration runs
// across providers/processes. PostgreSQL connections must preserve session
// affinity (transaction-pooling proxies cannot safely hold session locks).
//
// 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 {
return nil, fmt.Errorf("migrate: Go migration %d must not be nil", i)
}
}
root, err := fs.Sub(files, dir)
if err != nil {
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
}
info, err := fs.Stat(root, ".")
if err != nil {
return nil, fmt.Errorf("migrate: directory %q: %w", dir, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("migrate: directory %q: not a directory", dir)
}
opts := []goose.ProviderOption{
goose.WithDisableGlobalRegistry(true),
goose.WithGoMigrations(migrations...),
goose.WithSlog(logger),
goose.WithVerbose(true),
}
if dialect == goose.DialectPostgres {
locker, err := lock.NewPostgresSessionLocker()
if err != nil {
return nil, fmt.Errorf("migrate: postgres locker: %w", err)
}
opts = append(opts, goose.WithSessionLocker(locker))
}
provider, err := goose.NewProvider(dialect, db, root, opts...)
if err != nil {
return nil, fmt.Errorf("migrate: new provider: %w", err)
}
return provider, nil
}