Compare commits
3
Commits
main
...
3d903403c8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d903403c8 | ||
|
|
d912b475b5 | ||
|
|
badeefb9a9 |
No files matched your search
@@ -15,6 +15,17 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
check:
|
check:
|
||||||
runs-on: ubuntu-latest
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
@@ -26,6 +37,9 @@ jobs:
|
|||||||
run: test -z "$(gofmt -l .)"
|
run: test -z "$(gofmt -l .)"
|
||||||
- run: go vet ./...
|
- run: go vet ./...
|
||||||
- run: go test -race ./...
|
- 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
|
- name: Require issue URLs in TODO comments
|
||||||
run: |
|
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
|
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.
|
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
|
## docs
|
||||||
|
|
||||||
Install:
|
Install:
|
||||||
|
|||||||
@@ -3,6 +3,29 @@ module go.auvem.com/corestack
|
|||||||
go 1.27.1
|
go 1.27.1
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
|
github.com/pressly/goose/v3 v3.29.0
|
||||||
github.com/yuin/goldmark v1.8.6
|
github.com/yuin/goldmark v1.8.6
|
||||||
go.yaml.in/yaml/v3 v3.0.5
|
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 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
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 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
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,118 @@
|
|||||||
|
# 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 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
|
||||||
|
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,65 @@
|
|||||||
|
// 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, DownTo, Status, GetDBVersion, GetVersions,
|
||||||
|
// 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 {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,457 @@
|
|||||||
|
package migrate_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"database/sql"
|
||||||
|
"embed"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"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}, {"sql-only", testSQLOnly}, {"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}, {"sql-only", testSQLOnly}, {"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 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)
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConstructorDirectory(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqlite", ":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Directory validation and Go-only construction must remain offline.
|
||||||
|
if err := db.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
for _, test := range []struct {
|
||||||
|
dir string
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{dir: "missing", wantErr: "directory"},
|
||||||
|
{dir: "testdata/migrations/00007_events.sql", wantErr: "not a directory"},
|
||||||
|
{dir: "."},
|
||||||
|
{dir: "testdata"},
|
||||||
|
} {
|
||||||
|
t.Run(test.dir, func(t *testing.T) {
|
||||||
|
p, err := migrate.New(db, goose.DialectSQLite3, fixtures, test.dir, logger, goose.NewGoMigration(10, nil, nil))
|
||||||
|
if test.wantErr != "" {
|
||||||
|
if err == nil || p != nil || !strings.Contains(err.Error(), test.wantErr) || !strings.Contains(err.Error(), test.dir) {
|
||||||
|
t.Fatalf("New = %v, %v; want directory error containing %q and %q", p, err, test.wantErr, test.dir)
|
||||||
|
}
|
||||||
|
if test.dir == "missing" && !errors.Is(err, fs.ErrNotExist) {
|
||||||
|
t.Fatalf("New error = %v; want fs.ErrNotExist", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if sources := p.ListSources(); len(sources) != 1 || sources[0].Version != 10 {
|
||||||
|
t.Fatalf("ListSources = %v; want Go migration 10 only", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+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;
|
||||||
+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;
|
||||||
+5
@@ -0,0 +1,5 @@
|
|||||||
|
-- +goose Up
|
||||||
|
INSERT INTO migration_events (version) VALUES (10);
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
DELETE FROM migration_events WHERE version = 10;
|
||||||
Reference in new issue
Block a user