Skip to content

Commit 1e42c04

Browse files
feat(db): migration init
1 parent 8927c35 commit 1e42c04

14 files changed

Lines changed: 359 additions & 37 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ bin/
1111
.vscode/
1212
*.swp
1313
*.swo
14+
.sisyphus/
1415

1516
# OS
1617
.DS_Store

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ RUN apk add --no-cache ca-certificates tzdata wget
1212
COPY --from=builder /capy-server .
1313
COPY --from=builder /app/docs ./docs
1414
COPY --from=builder /app/schema.sql ./schema.sql
15+
COPY --from=builder /app/migrations ./migrations
1516
RUN adduser -D -g '' appuser
1617
USER appuser
1718
EXPOSE 8080

Makefile

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1-
.PHONY: generate build run test test-integration test-all lint swagger docker docker-down ci benchmark
1+
.PHONY: generate build run test test-integration test-all lint swagger docker docker-down ci benchmark migrate-up migrate-down migrate-version migrate-create
2+
3+
MIGRATIONS_PATH ?= migrations
24

35
# Code generation
46
generate:
@@ -33,6 +35,20 @@ benchmark:
3335
echo "Running benchmarks and saving to $$log_file..."; \
3436
go test -bench=. -benchmem -run=^$$ ./tests/benchmarks/... | tee $$log_file
3537

38+
# Database migrations (requires DATABASE_URL)
39+
migrate-up:
40+
migrate -path $(MIGRATIONS_PATH) -database "$(DATABASE_URL)" up
41+
42+
migrate-down:
43+
migrate -path $(MIGRATIONS_PATH) -database "$(DATABASE_URL)" down 1
44+
45+
migrate-version:
46+
migrate -path $(MIGRATIONS_PATH) -database "$(DATABASE_URL)" version
47+
48+
migrate-create:
49+
@if [ -z "$(name)" ]; then echo "usage: make migrate-create name=add_users_index"; exit 1; fi
50+
migrate create -ext sql -dir $(MIGRATIONS_PATH) -seq $(name)
51+
3652
# Linting
3753
lint:
3854
golangci-lint run ./...

README.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,10 @@ make docker
5454

5555
### 3. Run Migrations & Generate Code
5656
```bash
57-
# Install tools if needed (see Step 5 in agents.md or CI workflow)
57+
# Apply DB migrations (DATABASE_URL must point at your DB)
58+
make migrate-up
59+
60+
# Generate sqlc + swagger artifacts
5861
make generate
5962
```
6063

@@ -175,7 +178,7 @@ docker run -d `
175178
To run the full stack (API + Postgres + Cloudflare Tunnel), update your `.env` file with the required credentials and use the following `docker-compose.yml`.
176179

177180
> [!IMPORTANT]
178-
> Ensure your `.env` file contains all necessary OAuth credentials (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URL`, `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_REDIRECT_URL`, etc.), the `TUNNEL_TOKEN`, and `SCHEMA_PATH` (defaults to `schema.sql`). The `api` service will pull these automatically via the `env_file` directive.
181+
> Ensure your `.env` file contains all necessary OAuth credentials (`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `GOOGLE_REDIRECT_URL`, `MICROSOFT_CLIENT_ID`, `MICROSOFT_CLIENT_SECRET`, `MICROSOFT_REDIRECT_URL`, etc.), the `TUNNEL_TOKEN`, and `MIGRATIONS_PATH` (defaults to `migrations`). The `api` service will pull these automatically via the `env_file` directive.
179182
180183
```yaml
181184
services:

cmd/server/main.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,16 @@ func main() {
5050

5151
slog.Info("starting server", "env", cfg.Env)
5252

53-
// Connect to database
5453
ctx := context.Background()
54+
55+
// Apply database migrations before serving traffic.
56+
if err := database.RunMigrations(ctx, cfg.Database.URL, cfg.Database.MigrationsPath); err != nil {
57+
slog.Error("failed to run database migrations", "error", err, "path", cfg.Database.MigrationsPath)
58+
os.Exit(1)
59+
}
60+
slog.Info("database migrations applied", "path", cfg.Database.MigrationsPath)
61+
62+
// Connect to database
5563
pool, err := database.NewPool(ctx, cfg.Database.URL)
5664
if err != nil {
5765
slog.Error("failed to connect to database", "error", err)
@@ -61,13 +69,6 @@ func main() {
6169

6270
slog.Info("connected to database")
6371

64-
// Initialize schema
65-
if err := database.InitSchema(ctx, pool, cfg.Database.SchemaPath); err != nil {
66-
slog.Error("failed to initialize database schema", "error", err)
67-
os.Exit(1)
68-
}
69-
slog.Info("database schema initialized")
70-
7172
// Create queries and handler
7273
queries := database.New(pool)
7374
h := handler.New(queries, cfg)

go.mod

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ go 1.25.6
55
require (
66
github.com/go-chi/chi/v5 v5.2.4
77
github.com/golang-jwt/jwt/v5 v5.3.1
8+
github.com/golang-migrate/migrate/v4 v4.19.1
89
github.com/google/uuid v1.6.0
910
github.com/ilyakaznacheev/cleanenv v1.5.0
1011
github.com/jackc/pgx/v5 v5.8.0
@@ -21,7 +22,7 @@ require (
2122
require (
2223
cloud.google.com/go/compute/metadata v0.9.0 // indirect
2324
dario.cat/mergo v1.0.2 // indirect
24-
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
25+
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
2526
github.com/BurntSushi/toml v1.2.1 // indirect
2627
github.com/KyleBanks/depth v1.2.1 // indirect
2728
github.com/Microsoft/go-winio v0.6.2 // indirect
@@ -51,6 +52,7 @@ require (
5152
github.com/jackc/puddle/v2 v2.2.2 // indirect
5253
github.com/josharian/intern v1.0.0 // indirect
5354
github.com/klauspost/compress v1.18.0 // indirect
55+
github.com/lib/pq v1.10.9 // indirect
5456
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
5557
github.com/magiconair/properties v1.8.10 // indirect
5658
github.com/mailru/easyjson v0.7.6 // indirect
@@ -75,16 +77,16 @@ require (
7577
github.com/tklauser/numcpus v0.6.1 // indirect
7678
github.com/yusufpapurcu/wmi v1.2.4 // indirect
7779
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
78-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 // indirect
80+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
7981
go.opentelemetry.io/otel v1.38.0 // indirect
8082
go.opentelemetry.io/otel/metric v1.38.0 // indirect
8183
go.opentelemetry.io/otel/sdk v1.38.0 // indirect
84+
go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect
8285
go.opentelemetry.io/otel/trace v1.38.0 // indirect
8386
golang.org/x/mod v0.31.0 // indirect
8487
golang.org/x/sync v0.19.0 // indirect
8588
golang.org/x/sys v0.40.0 // indirect
8689
golang.org/x/text v0.33.0 // indirect
87-
golang.org/x/time v0.8.0 // indirect
8890
golang.org/x/tools v0.40.0 // indirect
8991
google.golang.org/grpc v1.78.0 // indirect
9092
google.golang.org/protobuf v1.36.11 // indirect

go.sum

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
44
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
55
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
66
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
7-
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
8-
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
7+
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
8+
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
99
github.com/BurntSushi/toml v1.2.1 h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=
1010
github.com/BurntSushi/toml v1.2.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
1111
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
@@ -31,6 +31,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
3131
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3232
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
3333
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
34+
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
35+
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
3436
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
3537
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
3638
github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM=
@@ -64,6 +66,8 @@ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyr
6466
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
6567
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
6668
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
69+
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
70+
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
6771
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
6872
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
6973
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
@@ -168,22 +172,24 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
168172
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
169173
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
170174
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
171-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
172-
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
175+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
176+
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
173177
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
174178
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
175-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0 h1:Mne5On7VWdx7omSrSSZvM4Kw7cS7NQkOOmLcgscI51U=
176-
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.19.0/go.mod h1:IPtUMKL4O3tH5y+iXVyAXqpAwMuzC1IrxVS81rummfE=
179+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0 h1:dIIDULZJpgdiHz5tXrTgKIMLkus6jEFa7x5SOKcyR7E=
180+
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.29.0/go.mod h1:jlRVBe7+Z1wyxFSUs48L6OBQZ5JwH2Hg/Vbl+t9rAgI=
177181
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
178182
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
179183
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
180184
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
181185
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
182186
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
187+
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
188+
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
183189
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
184190
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
185-
go.opentelemetry.io/proto/otlp v1.0.0 h1:T0TX0tmXU8a3CbNXzEKGeU5mIVOdf0oykP+u2lIVU/I=
186-
go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM=
191+
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
192+
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
187193
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
188194
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
189195
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
@@ -206,11 +212,12 @@ golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY=
206212
golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww=
207213
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
208214
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
209-
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
210-
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
215+
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
216+
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
211217
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
212218
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
213219
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
220+
google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4=
214221
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M=
215222
google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I=
216223
google.golang.org/genproto/googleapis/rpc v0.0.0-20260128011058-8636f8732409 h1:H86B94AW+VfJWDqFeEbBPhEtHzJwJfTbgE2lZa54ZAQ=

internal/config/config.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,9 @@ type ServerConfig struct {
2020
}
2121

2222
type DatabaseConfig struct {
23-
URL string `env:"DATABASE_URL" env-required:"true"`
24-
SchemaPath string `env:"SCHEMA_PATH" env-default:"schema.sql"`
23+
URL string `env:"DATABASE_URL" env-required:"true"`
24+
SchemaPath string `env:"SCHEMA_PATH" env-default:"schema.sql"`
25+
MigrationsPath string `env:"MIGRATIONS_PATH" env-default:"migrations"`
2526
}
2627

2728
type JWTConfig struct {

internal/database/migrations.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package database
2+
3+
import (
4+
"context"
5+
"errors"
6+
"fmt"
7+
"path/filepath"
8+
"strings"
9+
10+
"github.com/golang-migrate/migrate/v4"
11+
_ "github.com/golang-migrate/migrate/v4/database/postgres"
12+
_ "github.com/golang-migrate/migrate/v4/source/file"
13+
)
14+
15+
// RunMigrations applies all pending migrations from the given path.
16+
func RunMigrations(ctx context.Context, databaseURL, migrationsPath string) error {
17+
_ = ctx
18+
19+
m, err := newMigrator(databaseURL, migrationsPath)
20+
if err != nil {
21+
return err
22+
}
23+
defer closeMigrator(m)
24+
25+
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
26+
return fmt.Errorf("failed to apply migrations: %w", err)
27+
}
28+
29+
return nil
30+
}
31+
32+
// RunMigrationsDown rolls back a positive number of migration steps.
33+
func RunMigrationsDown(ctx context.Context, databaseURL, migrationsPath string, steps int) error {
34+
_ = ctx
35+
if steps <= 0 {
36+
return fmt.Errorf("steps must be greater than 0")
37+
}
38+
39+
m, err := newMigrator(databaseURL, migrationsPath)
40+
if err != nil {
41+
return err
42+
}
43+
defer closeMigrator(m)
44+
45+
if err := m.Steps(-steps); err != nil && !errors.Is(err, migrate.ErrNoChange) {
46+
return fmt.Errorf("failed to roll back migrations: %w", err)
47+
}
48+
49+
return nil
50+
}
51+
52+
func newMigrator(databaseURL, migrationsPath string) (*migrate.Migrate, error) {
53+
sourceURL, err := fileSourceURL(migrationsPath)
54+
if err != nil {
55+
return nil, err
56+
}
57+
58+
m, err := migrate.New(sourceURL, databaseURL)
59+
if err != nil {
60+
return nil, fmt.Errorf("failed to create migrator: %w", err)
61+
}
62+
63+
return m, nil
64+
}
65+
66+
func fileSourceURL(path string) (string, error) {
67+
if strings.Contains(path, "://") {
68+
return path, nil
69+
}
70+
71+
absPath, err := filepath.Abs(path)
72+
if err != nil {
73+
return "", fmt.Errorf("failed to resolve migrations path %q: %w", path, err)
74+
}
75+
76+
return "file://" + filepath.ToSlash(absPath), nil
77+
}
78+
79+
func closeMigrator(m *migrate.Migrate) {
80+
sourceErr, dbErr := m.Close()
81+
if err := errors.Join(sourceErr, dbErr); err != nil {
82+
// Best-effort cleanup; callers care about migration result more than close errors.
83+
return
84+
}
85+
}

0 commit comments

Comments
 (0)