feat(migrate): configure Goose providers for embedded migrations #14

Merged
end merged 6 commits from feat/migrate into main 2026-10-27 23:07:23 -07:00
5 changed files with 77 additions and 9 deletions
Showing only changes of commit d912b475b5 - Show all commits

No files matched your search

+13 -5
View File
@@ -40,11 +40,19 @@ 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.
Use unique positive versions in filenames such as
`20261027114500_create_users.sql`; sequential versions such as `00007_users.sql`
also work. Never derive a version from a file count or add one to the current
version. Keep applied migrations immutable and ship the complete history.
Goose rejects newly introduced migrations below the current version by default.
Use unique positive versions and Goose's migration creator naming convention:
zero-padded sequential names such as `00007_users.sql` and `00010_events.sql`, or
fixed-width timestamps such as `20261027114500_create_users.sql`. Filenames must
sort lexically in numeric version order, including when mixing naming schemes;
choose padding wide enough for the complete history. Goose v3.29.0 preserves
lexical source order for SQL-only providers, so arbitrary unpadded names such as
`7_users.sql` and `10_events.sql` are unsupported by this house convention: they
can produce incorrect latest versions and source/status ordering. This package
does not rename or normalize migration files.
Never derive a version from a file count or add one to the current version.
Keep applied migrations immutable and ship the complete history. Goose rejects
newly introduced migrations below the current version by default.
```sql
-- +goose Up
+3 -2
View File
@@ -23,8 +23,9 @@ import (
// affinity (transaction-pooling proxies cannot safely hold session locks).
//
// Use the returned Provider's Up, UpTo, DownTo, Status, GetDBVersion, GetVersions,
// and HasPending methods directly. Versions may be sequential or timestamps;
// Goose orders and tracks them. Global Go migration registration is ignored.
// and HasPending methods directly. Filenames must sort lexically in numeric
// version order: use zero-padded sequential or fixed-width timestamp prefixes,
// as produced by Goose's migration creator. 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 {
+50 -2
View File
@@ -34,7 +34,7 @@ func TestSQLite(t *testing.T) {
name string
run func(*testing.T, *sql.DB, goose.Dialect)
}{
{"flow", testFlow}, {"failure", testFailure},
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
} {
t.Run(test.name, func(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migrate.db"))
@@ -56,7 +56,7 @@ func TestPostgres(t *testing.T) {
name string
run func(*testing.T, *sql.DB, goose.Dialect)
}{
{"flow", testFlow}, {"failure", testFailure},
{"flow", testFlow}, {"sql-only", testSQLOnly}, {"failure", testFailure},
} {
t.Run(test.name, func(t *testing.T) { test.run(t, postgresDatabase(t)(), goose.DialectPostgres) })
}
@@ -133,6 +133,47 @@ func testFlow(t *testing.T, db *sql.DB, dialect goose.Dialect) {
}
}
func testSQLOnly(t *testing.T, db *sql.DB, dialect goose.Dialect) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
for _, test := range []struct {
dir string
versions []int64
}{
{"testdata/numeric", []int64{7, 10}},
{"testdata/migrations", []int64{7, 20261026093000, 20261027114500}},
} {
p := newProvider(t, db, dialect, test.dir)
assertState(t, ctx, p, 0, test.versions, 0)
results, err := p.UpTo(ctx, test.versions[1]-1)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, test.versions[:1])
assertState(t, ctx, p, test.versions[0], test.versions, 1)
results, err = p.Up(ctx)
if err != nil {
t.Fatal(err)
}
assertResults(t, results, test.versions[1:])
assertState(t, ctx, p, test.versions[len(test.versions)-1], test.versions, len(test.versions))
assertEvents(t, ctx, db, test.versions)
results, err = p.DownTo(ctx, test.versions[1]-1)
if err != nil {
t.Fatal(err)
}
wantDown := slices.Clone(test.versions[1:])
slices.Reverse(wantDown)
assertResults(t, results, wantDown)
assertState(t, ctx, p, test.versions[0], test.versions, 1)
if _, err := p.DownTo(ctx, 0); err != nil {
t.Fatal(err)
}
assertState(t, ctx, p, 0, test.versions, 0)
}
}
func testFailure(t *testing.T, db *sql.DB, dialect goose.Dialect) {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
@@ -165,6 +206,13 @@ func newProvider(t *testing.T, db *sql.DB, dialect goose.Dialect, dir string, mi
func assertState(t *testing.T, ctx context.Context, p *goose.Provider, current int64, versions []int64, applied int) {
t.Helper()
var sources []int64
for _, source := range p.ListSources() {
sources = append(sources, source.Version)
}
if !slices.Equal(sources, versions) {
t.Fatalf("ListSources = %v; want %v", sources, versions)
}
got, err := p.GetDBVersion(ctx)
if err != nil || got != current {
t.Fatalf("GetDBVersion = %d, %v; want %d", got, err, current)
+6
View File
@@ -0,0 +1,6 @@
-- +goose Up
CREATE TABLE migration_events (version BIGINT PRIMARY KEY);
INSERT INTO migration_events (version) VALUES (7);
-- +goose Down
DROP TABLE migration_events;
+5
View File
@@ -0,0 +1,5 @@
-- +goose Up
INSERT INTO migration_events (version) VALUES (10);
-- +goose Down
DELETE FROM migration_events WHERE version = 10;