feat(migrate): configure embedded Goose providers
Use provider-local SQL and Go migrations, injected slog logging, and PostgreSQL session locking. Exercise timestamp migration flows and failures with SQLite and PostgreSQL, including concurrent startup, and require PostgreSQL integration coverage in CI. Refs #7
This commit is contained in:
1 parent
46c4416ed4
commit
badeefb9a9
13 files changed
+686
No files matched your search
@@ -15,6 +15,17 @@ on:
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:18
|
||||
env:
|
||||
POSTGRES_PASSWORD: migrate-test
|
||||
POSTGRES_DB: migrate_test
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d migrate_test"
|
||||
--health-interval 5s
|
||||
--health-timeout 5s
|
||||
--health-retries 10
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
@@ -26,6 +37,9 @@ jobs:
|
||||
run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: go test -race ./...
|
||||
env:
|
||||
CI: "true"
|
||||
MIGRATE_POSTGRES_DSN: postgres://postgres:migrate-test@postgres:5432/migrate_test?sslmode=disable
|
||||
- name: Require issue URLs in TODO comments
|
||||
run: |
|
||||
find . -type d \( -name .git -o -name node_modules -o -name vendor -o -name generated -o -name gen -o -name target -o -name dist -o -name build -o -name .svelte-kit -o -name .next \) -prune -o -type f \( -name '*.go' -o -name '*.ts' -o -name '*.tsx' -o -name '*.js' -o -name '*.svelte' -o -name '*.rs' -o -name '*.sql' -o -name '*.sh' -o -name '*.proto' -o -name '*.graphql' -o -name '*.yml' -o -name '*.yaml' -o -name '*.toml' \) -exec awk 'match($0, "((^|[^:])[/][/]|[/][*]|(^|[[:space:]])(#|--)|^[[:space:]]*([*]|<!--))") { comment=substr($0, RSTART + RLENGTH); todo=index(comment, "TO" "DO"); if (todo && substr(comment, todo + 4) !~ "https?:[/][/]") { print FILENAME ":" FNR ":" $0; bad=1 } } END { exit bad }' {} + # shortcut: line-based scan can flag comment-like strings and misses multiline issue links, use a parser when these need accurate coverage
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
|
||||
Shared Go building blocks. The first one is `docs`, a checker for architecture decision records (ADRs) and implementation plans.
|
||||
|
||||
## migrate
|
||||
|
||||
[`migrate`](migrate/README.md) configures Goose providers for embedded SQL and Go
|
||||
migrations, with structured logging and Postgres startup locking.
|
||||
|
||||
## docs
|
||||
|
||||
Install:
|
||||
|
||||
@@ -3,6 +3,29 @@ module go.auvem.com/corestack
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/pressly/goose/v3 v3.29.0
|
||||
github.com/yuin/goldmark v1.8.6
|
||||
go.yaml.in/yaml/v3 v3.0.5
|
||||
modernc.org/sqlite v1.60.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.1.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.3 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mfridman/interpolate v0.0.2 // indirect
|
||||
github.com/ncruces/go-strftime v1.1.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/sethvargo/go-retry v0.5.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
golang.org/x/sync v0.24.0 // indirect
|
||||
golang.org/x/sys v0.49.0 // indirect
|
||||
golang.org/x/text v0.43.0 // indirect
|
||||
modernc.org/libc v1.78.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
)
|
||||
@@ -1,4 +1,81 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.1.0 h1:dbKTrvD0klcbBV/h4AWJdMuZogJACoMlvWIWZ5b2xWg=
|
||||
github.com/dustin/go-humanize v1.1.0/go.mod h1:hc1CvRkJMsgxqjmjMQF3QNRAZBwY8AXBAzKYoSX9sFI=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
|
||||
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.3 h1:f8HoREISb3l5eO9coPgK7U3hKLhWGokOFVcThl5jrao=
|
||||
github.com/jackc/puddle/v2 v2.2.3/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg=
|
||||
github.com/ncruces/go-strftime v1.1.0 h1:vtPmPWMkb539RGV9WpWT9EwJGvrOCKvMNI4obWL47Hk=
|
||||
github.com/ncruces/go-strftime v1.1.0/go.mod h1:yVaq7iwCN09vHRrKBrrflXT0M9tTMG2KmZ9Fj7xeG/g=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.29.0 h1:dtEnBCT7CqkHIUCdgrXsrmeisaN8gFkRU4Y5OEwif4o=
|
||||
github.com/pressly/goose/v3 v3.29.0/go.mod h1:plA4aP4+u+Q3FMTgbFbhF8TuAWpAdfWpoFGkJsIFWh0=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/sethvargo/go-retry v0.5.0 h1:JYfFKmVJ8ER06GReN1efzc5cm52BrVsFVzmcHiKJwfY=
|
||||
github.com/sethvargo/go-retry v0.5.0/go.mod h1:tvsjdKG6xfiCx4LSiUZ06kcv38xvdVQwv8R6/VnnVWg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/cc/v4 v4.30.1 h1:ZXInYH2CD24GPWSwOFPwB6JrnFt4/6d7ceW1Ukd30Bk=
|
||||
modernc.org/cc/v4 v4.30.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.36.2 h1:j6d5HuXtBxF00vEcI5RE0O6EIrG6RyRpgMiGV2IK9ys=
|
||||
modernc.org/ccgo/v4 v4.36.2/go.mod h1:Wh8QlPZq/US2DrsCc31FxJVBOEEcN2BBfla6OQDHydA=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.2.0 h1:/Y3LGDMQaoYf55PebP1qsZ5FDGgAZyG1RYKEHeQzh1o=
|
||||
modernc.org/gc/v3 v3.2.0/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.78.0 h1:Lpm+e9K6ruVYEGwWfuBO1xXtYEaXDouFOG1SuWjFDP4=
|
||||
modernc.org/libc v1.78.0/go.mod h1:wvxR55nOF2/66NkJoNV7/coXBTCvT3HR22rcqW6lOuA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
@@ -0,0 +1,110 @@
|
||||
# migrate
|
||||
|
||||
`go.auvem.com/corestack/migrate` sets the house configuration for
|
||||
[Goose's Provider API](https://pressly.github.io/goose/documentation/provider/).
|
||||
It returns `*goose.Provider` directly: Goose owns execution, version tracking,
|
||||
status, and errors. There is no lifecycle dependency or background work.
|
||||
|
||||
Embed migrations in the application's binary and run them before serving traffic:
|
||||
|
||||
```go
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"log/slog"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
"go.auvem.com/corestack/migrate"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrations embed.FS
|
||||
|
||||
func migrateDatabase(ctx context.Context, db *sql.DB, logger *slog.Logger) error {
|
||||
provider, err := migrate.New(db, goose.DialectPostgres, migrations, "migrations", logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = provider.Up(ctx)
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
The caller opens and closes `db`, selects a matching database driver and dialect,
|
||||
supplies a nonnil logger, and controls the operation context/deadline. Construction
|
||||
is offline; SQL is parsed lazily by Goose. `Provider.Close` also closes `db`, so do
|
||||
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.
|
||||
|
||||
```sql
|
||||
-- +goose Up
|
||||
CREATE TABLE users (id BIGINT PRIMARY KEY);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE users;
|
||||
```
|
||||
|
||||
Goose runs each migration in a transaction by default. Its annotations support
|
||||
statement blocks and explicit `NO TRANSACTION` where required; such migrations
|
||||
and nontransactional database DDL can leave partial effects on failure. Down
|
||||
migrations execute application-authored SQL and may delete data.
|
||||
|
||||
| Operation | Provider method |
|
||||
| --- | --- |
|
||||
| Apply all pending migrations | `Up(ctx)` |
|
||||
| Apply pending versions through a target, inclusive | `UpTo(ctx, version)` |
|
||||
| Roll back versions above a target; zero removes all | `DownTo(ctx, version)` |
|
||||
| Inspect each migration's state | `Status(ctx)` |
|
||||
| Current database version | `GetDBVersion(ctx)` |
|
||||
| Current and latest available versions | `GetVersions(ctx)` |
|
||||
| Detect pending migrations | `HasPending(ctx)` |
|
||||
|
||||
Targets are numeric version bounds, so gaps and timestamps work without special
|
||||
handling. Up on a fully migrated database succeeds with no results. Operations
|
||||
may create Goose's `goose_db_version` table even when only inspecting state.
|
||||
Execution errors retain Goose's details; use `errors.As` with
|
||||
`*goose.PartialError` to inspect applied and failed migrations. Transactional
|
||||
failure leaves the database at the last committed migration version. Operation
|
||||
logs use the injected slog logger; callers handle and log returned errors.
|
||||
|
||||
For Go functions, pass migrations created with `goose.NewGoMigration(version,
|
||||
up, down)` as the trailing arguments to `migrate.New`. Use `goose.GoFunc.RunTx`
|
||||
for transactional work or `RunDB` for work requiring its own connections. Each
|
||||
provider should receive its own migration instances. Versions must be unique
|
||||
across SQL and Go migrations. Global registration is disabled; no `init` or
|
||||
`goose.AddMigration` calls are needed.
|
||||
|
||||
Postgres providers automatically use Goose's default session advisory locker.
|
||||
Concurrent startup calls from separate providers/processes serialize migration
|
||||
execution and recheck applied versions under the lock. The default lock ID
|
||||
serializes Goose migrations throughout the same database, including different
|
||||
schemas; lock acquisition retries for about five minutes and unlock retries for
|
||||
about one minute, subject to the operation context. Use a direct connection or
|
||||
a session-pooling proxy: transaction pooling cannot preserve session locks.
|
||||
`RunDB` Go migrations need spare pool connections while the locked session is
|
||||
held; avoid a one-connection pool for them.
|
||||
|
||||
Other dialects have no cross-provider/process lock configured by this package.
|
||||
Serialize migration runs externally, for example with a single deployment job.
|
||||
SQLite tests validate the SQL convention, not concurrent daemon startup safety.
|
||||
|
||||
Run the integration suite against an existing Postgres database with schema
|
||||
creation privileges:
|
||||
|
||||
```sh
|
||||
MIGRATE_POSTGRES_DSN='postgres://user:password@localhost/testdb?sslmode=disable' go test -race ./migrate
|
||||
```
|
||||
|
||||
Tests create and clean only random schemas they own. Without a DSN, Postgres
|
||||
tests skip locally; in CI a missing DSN fails, and the CI job supplies a Postgres
|
||||
service. SQLite tests always run in process using temporary databases.
|
||||
@@ -0,0 +1,56 @@
|
||||
// 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.
|
||||
// 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, 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.
|
||||
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)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
package migrate_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"embed"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/pressly/goose/v3"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"go.auvem.com/corestack/migrate"
|
||||
)
|
||||
|
||||
// invariant: embedded fixtures are read-only; no test mutates shared migration state.
|
||||
//
|
||||
//go:embed testdata
|
||||
var fixtures embed.FS
|
||||
|
||||
func TestSQLite(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T, *sql.DB, goose.Dialect)
|
||||
}{
|
||||
{"flow", testFlow}, {"failure", testFailure},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "migrate.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
test.run(t, db, goose.DialectSQLite3)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgres(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
run func(*testing.T, *sql.DB, goose.Dialect)
|
||||
}{
|
||||
{"flow", testFlow}, {"failure", testFailure},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) { test.run(t, postgresDatabase(t)(), goose.DialectPostgres) })
|
||||
}
|
||||
}
|
||||
|
||||
func testFlow(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
var logs bytes.Buffer
|
||||
goMigration := goose.NewGoMigration(20261027120000,
|
||||
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "INSERT INTO migration_events (version) VALUES (20261027120000)")
|
||||
return err
|
||||
}},
|
||||
&goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "DELETE FROM migration_events WHERE version = 20261027120000")
|
||||
return err
|
||||
}})
|
||||
p, err := migrate.New(db, dialect, fixtures, "testdata/migrations", slog.New(slog.NewJSONHandler(&logs, nil)), goMigration)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
versions := []int64{7, 20261026093000, 20261027114500, 20261027120000}
|
||||
// Registering on one provider must not leak into another provider in the same process.
|
||||
other := newProvider(t, db, dialect, "testdata/migrations")
|
||||
if len(other.ListSources()) != 3 {
|
||||
t.Fatal("Go migration leaked to another provider")
|
||||
}
|
||||
assertState(t, ctx, p, 0, versions, 0)
|
||||
results, err := p.UpTo(ctx, versions[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions[:2])
|
||||
assertState(t, ctx, p, versions[1], versions, 2)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions[2:])
|
||||
assertState(t, ctx, p, versions[3], versions, 4)
|
||||
assertEvents(t, ctx, db, versions)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil || len(results) != 0 {
|
||||
t.Fatalf("idempotent Up = %v, %v", results, err)
|
||||
}
|
||||
results, err = p.DownTo(ctx, versions[1])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, []int64{versions[3], versions[2]})
|
||||
assertState(t, ctx, p, versions[1], versions, 2)
|
||||
assertEvents(t, ctx, db, versions[:2])
|
||||
results, err = p.DownTo(ctx, versions[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, []int64{versions[1]})
|
||||
assertState(t, ctx, p, versions[0], versions, 1)
|
||||
assertEvents(t, ctx, db, versions[:1])
|
||||
if _, err := p.DownTo(ctx, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertState(t, ctx, p, 0, versions, 0)
|
||||
results, err = p.Up(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertResults(t, results, versions)
|
||||
assertEvents(t, ctx, db, versions)
|
||||
if !strings.Contains(logs.String(), `"logger":"goose"`) || !strings.Contains(logs.String(), `"version":20261027114500`) {
|
||||
t.Fatalf("missing structured migration logs: %s", logs.String())
|
||||
}
|
||||
}
|
||||
|
||||
func testFailure(t *testing.T, db *sql.DB, dialect goose.Dialect) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
p := newProvider(t, db, dialect, "testdata/failure")
|
||||
_, err := p.Up(ctx)
|
||||
var partial *goose.PartialError
|
||||
if !errors.As(err, &partial) || partial.Failed.Source.Version != 20261027114500 ||
|
||||
!strings.Contains(err.Error(), "missing_migration_table") || !strings.Contains(err.Error(), "20261027114500") {
|
||||
t.Fatalf("expected clear failed timestamp migration error, got %v", err)
|
||||
}
|
||||
assertResults(t, partial.Applied, []int64{7, 20261026093000})
|
||||
assertState(t, ctx, p, 20261026093000, []int64{7, 20261026093000, 20261027114500}, 2)
|
||||
assertEvents(t, ctx, db, []int64{7, 20261026093000})
|
||||
// A second attempt must fail at the same version without repeating committed work.
|
||||
if _, err := p.Up(ctx); err == nil {
|
||||
t.Fatal("retry unexpectedly succeeded")
|
||||
}
|
||||
assertEvents(t, ctx, db, []int64{7, 20261026093000})
|
||||
}
|
||||
|
||||
func newProvider(t *testing.T, db *sql.DB, dialect goose.Dialect, dir string, migrations ...*goose.Migration) *goose.Provider {
|
||||
t.Helper()
|
||||
p, err := migrate.New(db, dialect, fixtures, dir, slog.New(slog.NewTextHandler(io.Discard, nil)), migrations...)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func assertState(t *testing.T, ctx context.Context, p *goose.Provider, current int64, versions []int64, applied int) {
|
||||
t.Helper()
|
||||
got, err := p.GetDBVersion(ctx)
|
||||
if err != nil || got != current {
|
||||
t.Fatalf("GetDBVersion = %d, %v; want %d", got, err, current)
|
||||
}
|
||||
got, latest, err := p.GetVersions(ctx)
|
||||
if err != nil || got != current || latest != versions[len(versions)-1] {
|
||||
t.Fatalf("GetVersions = %d, %d, %v", got, latest, err)
|
||||
}
|
||||
pending, err := p.HasPending(ctx)
|
||||
if err != nil || pending != (applied < len(versions)) {
|
||||
t.Fatalf("HasPending = %v, %v", pending, err)
|
||||
}
|
||||
status, err := p.Status(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(status) != len(versions) {
|
||||
t.Fatalf("Status length = %d; want %d", len(status), len(versions))
|
||||
}
|
||||
for i, s := range status {
|
||||
want := goose.StatePending
|
||||
if i < applied {
|
||||
want = goose.StateApplied
|
||||
}
|
||||
if s.Source.Version != versions[i] || s.State != want {
|
||||
t.Fatalf("Status[%d] = %+v, version %d; want %s, %d", i, s, s.Source.Version, want, versions[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertResults(t *testing.T, results []*goose.MigrationResult, want []int64) {
|
||||
t.Helper()
|
||||
got := make([]int64, 0, len(results))
|
||||
for _, r := range results {
|
||||
got = append(got, r.Source.Version)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("applied versions = %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func assertEvents(t *testing.T, ctx context.Context, db *sql.DB, want []int64) {
|
||||
t.Helper()
|
||||
rows, err := db.QueryContext(ctx, "SELECT version FROM migration_events ORDER BY version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var got []int64
|
||||
for rows.Next() {
|
||||
var v int64
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got = append(got, v)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !slices.Equal(got, want) {
|
||||
t.Fatalf("events = %v; want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// postgresDatabase owns only its random schema, never the supplied database.
|
||||
func postgresDatabase(t *testing.T) func() *sql.DB {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("MIGRATE_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
if os.Getenv("CI") != "" {
|
||||
t.Fatal("MIGRATE_POSTGRES_DSN is required in CI")
|
||||
}
|
||||
t.Skip("set MIGRATE_POSTGRES_DSN to run Postgres integration tests")
|
||||
}
|
||||
config, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
admin := stdlib.OpenDB(*config)
|
||||
t.Cleanup(func() {
|
||||
if err := admin.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
schema := "migrate_" + strings.ToLower(rand.Text())
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "CREATE SCHEMA "+schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
if _, err := admin.ExecContext(ctx, "DROP SCHEMA "+schema+" CASCADE"); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
config.RuntimeParams["search_path"] = schema
|
||||
return func() *sql.DB {
|
||||
db := stdlib.OpenDB(*config)
|
||||
t.Cleanup(func() {
|
||||
if err := db.Close(); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return db
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresConcurrentUp(t *testing.T) {
|
||||
open := postgresDatabase(t)
|
||||
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
|
||||
defer cancel()
|
||||
const workers = 4
|
||||
providers := make([]*goose.Provider, workers)
|
||||
for i := range providers {
|
||||
// Each provider owns a distinct pool and Go migration instance, like separate daemons.
|
||||
pause := goose.NewGoMigration(3, &goose.GoFunc{RunTx: func(ctx context.Context, tx *sql.Tx) error {
|
||||
_, err := tx.ExecContext(ctx, "SELECT pg_sleep(0.2)")
|
||||
return err
|
||||
}}, nil)
|
||||
providers[i] = newProvider(t, open(), goose.DialectPostgres, "testdata/migrations", pause)
|
||||
}
|
||||
type outcome struct {
|
||||
results []*goose.MigrationResult
|
||||
err error
|
||||
}
|
||||
start := make(chan struct{})
|
||||
done := make(chan outcome, workers)
|
||||
for _, p := range providers {
|
||||
go func() {
|
||||
select {
|
||||
case <-start:
|
||||
case <-ctx.Done():
|
||||
done <- outcome{err: ctx.Err()}
|
||||
return
|
||||
}
|
||||
results, err := p.Up(ctx)
|
||||
done <- outcome{results, err}
|
||||
}()
|
||||
}
|
||||
close(start)
|
||||
var applied []int64
|
||||
for range workers {
|
||||
result := <-done
|
||||
if result.err != nil {
|
||||
t.Error(result.err)
|
||||
}
|
||||
for _, r := range result.results {
|
||||
applied = append(applied, r.Source.Version)
|
||||
}
|
||||
}
|
||||
slices.Sort(applied)
|
||||
if want := []int64{3, 7, 20261026093000, 20261027114500}; !slices.Equal(applied, want) {
|
||||
t.Fatalf("concurrent Up applied = %v; want exactly once each: %v", applied, want)
|
||||
}
|
||||
assertEvents(t, ctx, open(), []int64{7, 20261026093000, 20261027114500})
|
||||
for _, p := range providers {
|
||||
assertState(t, ctx, p, 20261027114500, applied, len(applied))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConstructor(t *testing.T) {
|
||||
db, err := sql.Open("sqlite", ":memory:")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
db *sql.DB
|
||||
dialect goose.Dialect
|
||||
dir string
|
||||
logger *slog.Logger
|
||||
migrations []*goose.Migration
|
||||
contains string
|
||||
}{
|
||||
{name: "nil database", dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, contains: "db must not be nil"},
|
||||
{name: "nil logger", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", contains: "slog logger must not be nil"},
|
||||
{name: "invalid directory", db: db, dialect: goose.DialectSQLite3, dir: "../migrations", logger: logger, contains: "directory"},
|
||||
{name: "missing directory", db: db, dialect: goose.DialectSQLite3, dir: "missing", logger: logger, contains: "migrate:"},
|
||||
{name: "invalid dialect", db: db, dialect: "unknown", dir: "testdata/migrations", logger: logger, contains: "dialect"},
|
||||
{name: "nil migration", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{nil}, contains: "must not be nil"},
|
||||
{name: "duplicate version", db: db, dialect: goose.DialectSQLite3, dir: "testdata/migrations", logger: logger, migrations: []*goose.Migration{goose.NewGoMigration(7, nil, nil)}, contains: "7"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
p, err := migrate.New(test.db, test.dialect, fixtures, test.dir, test.logger, test.migrations...)
|
||||
if err == nil || p != nil || !strings.Contains(err.Error(), test.contains) {
|
||||
t.Fatalf("New = %v, %v; want error containing %q", p, err, test.contains)
|
||||
}
|
||||
})
|
||||
}
|
||||
// Construction and validation are offline, even when the database is closed.
|
||||
if err := db.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := migrate.New(db, goose.DialectSQLite3, fixtures, "testdata/migrations", logger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
+6
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261026093000);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261026093000;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261027114500);
|
||||
INSERT INTO missing_migration_table (version) VALUES (20261027114500);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261027114500;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261026093000);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261026093000;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- +goose Up
|
||||
INSERT INTO migration_events (version) VALUES (20261027114500);
|
||||
|
||||
-- +goose Down
|
||||
DELETE FROM migration_events WHERE version = 20261027114500;
|
||||
Reference in new issue
Block a user