Skip to content

Commit e7b226d

Browse files
committed
feat: migrate - dead simple database migration library
1 parent 8ff896a commit e7b226d

11 files changed

Lines changed: 941 additions & 2 deletions

File tree

‎.github/workflows/go-coverage.yml‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Coverage
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
workflow_dispatch:
9+
10+
jobs:
11+
coverage:
12+
runs-on: ubuntu-latest
13+
steps:
14+
- uses: actions/checkout@v5
15+
with:
16+
fetch-depth: '0'
17+
18+
- name: Set up Go
19+
uses: actions/setup-go@v6
20+
with:
21+
go-version: 1.24
22+
cache: 'true'
23+
24+
- name: Build
25+
run: go install
26+
27+
- name: Test
28+
run: |
29+
go test -v -cover ./... -coverprofile coverage.out -coverpkg ./...
30+
go tool cover -func coverage.out -o coverage.out
31+
32+
- name: Go Coverage Badge
33+
uses: tj-actions/coverage-badge-go@v3
34+
with:
35+
green: 80
36+
filename: coverage.out
37+
38+
- uses: stefanzweifel/git-auto-commit-action@v6
39+
id: auto-commit-action
40+
with:
41+
commit_message: Apply Code Coverage Badge
42+
file_pattern: ./README.md

‎.github/workflows/go-quality.yml‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
name: Go Quality Checks
2+
3+
on:
4+
push:
5+
branches: [ main ]
6+
pull_request:
7+
branches: [ main ]
8+
9+
jobs:
10+
quality-checks:
11+
name: Code Quality
12+
runs-on: ubuntu-latest
13+
steps:
14+
- name: Set up Go
15+
uses: actions/setup-go@v6
16+
with:
17+
go-version: 1.24
18+
cache: true
19+
20+
- name: Check out code
21+
uses: actions/checkout@v5
22+
23+
- name: Run golangci-lint
24+
uses: golangci/golangci-lint-action@v8
25+
26+
- name: Run tests
27+
run: |
28+
go test -v ./...
29+
30+
- name: Install govulncheck
31+
run: |
32+
go install golang.org/x/vuln/cmd/govulncheck@latest
33+
34+
- name: Verify go.mod is tidy
35+
run: |
36+
go mod tidy
37+
git diff --exit-code go.mod|| (echo "Please run 'go mod tidy' and commit the changes" && exit 1)
38+
39+
- name: Check formatting
40+
run: |
41+
go fmt ./...
42+
git diff --exit-code || (echo "Please run 'go fmt ./...' and commit the changes" && exit 1)
43+
44+
- name: Run go vet
45+
run: |
46+
go vet ./...
47+
48+
- name: Run govulncheck
49+
run: |
50+
govulncheck ./...
51+

‎LICENSE‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
MIT License
22

3-
Copyright (c) 2026 struct0x
3+
Copyright (c) 2026 Radosław Dejnek
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

‎README.md‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,84 @@
11
# migrate
2-
Dead simple in app migration system.
2+
3+
[![Go Reference](https://pkg.go.dev/badge/github.com/struct0x/migrate.svg)](https://pkg.go.dev/github.com/struct0x/migrate)
4+
[![Go Report Card](https://goreportcard.com/badge/github.com/struct0x/migrate)](https://goreportcard.com/report/github.com/struct0x/migrate)
5+
![Coverage](https://img.shields.io/badge/Coverage-95.0%25-brightgreen)
6+
7+
Dead simple in-app migration system.
8+
9+
## Install
10+
11+
```
12+
go get github.com/struct0x/migrate
13+
```
14+
15+
## Usage
16+
17+
In your module, declare migrations
18+
19+
```go
20+
package users
21+
22+
import "github.com/struct0x/migrate"
23+
24+
var StoreMigrations = migrate.Migrations{
25+
ModuleName: "users",
26+
Migrations: []migrate.Migration{
27+
{
28+
Name: "001_create_users",
29+
SQL: `CREATE TABLE users (
30+
id TEXT PRIMARY KEY,
31+
name TEXT NOT NULL
32+
)`,
33+
},
34+
{
35+
Name: "002_add_email",
36+
SQL: `ALTER TABLE users ADD COLUMN email TEXT NOT NULL DEFAULT ''`,
37+
},
38+
},
39+
}
40+
41+
```
42+
43+
At startup, pass all module groups together:
44+
45+
```go
46+
package main
47+
48+
func main() {
49+
// ...
50+
if err := migrate.Migrate(ctx, db, migrate.Postgres,
51+
users.StoreMigrations,
52+
payments.StoreMigrations,
53+
licensing.StoreMigrations,
54+
); err != nil {
55+
return fmt.Errorf("migrate: %w", err)
56+
}
57+
}
58+
59+
```
60+
61+
## Dialects
62+
63+
| Dialect | Var |
64+
|------------|--------------------|
65+
| SQLite | `migrate.SQLite` |
66+
| PostgreSQL | `migrate.Postgres` |
67+
| MySQL | `migrate.MySQL` |
68+
69+
## Options
70+
71+
| Option | Description |
72+
|----------------------------|---------------------------------------------------------|
73+
| `WithLogger(*slog.Logger)` | Sets the logger used to print what migrations were run. |
74+
75+
## Behavior
76+
77+
- Idempotent — safe to call on every startup
78+
- Concurrent-safe — multiple processes can call Migrate simultaneously
79+
- Each migration runs exactly once
80+
- A failed migration is rolled back and returns an error; subsequent migrations in the group are not applied
81+
82+
## License
83+
84+
MIT License

‎go.mod‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module github.com/struct0x/migrate
2+
3+
go 1.26.3

‎migrate.go‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package migrate
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"fmt"
7+
"log/slog"
8+
"time"
9+
)
10+
11+
type Migration struct {
12+
Name string
13+
SQL string
14+
}
15+
16+
type Migrations struct {
17+
ModuleName string
18+
Migrations []Migration
19+
}
20+
21+
type Dialect struct {
22+
createSQL string
23+
claimSQL string
24+
}
25+
26+
var (
27+
SQLite = Dialect{
28+
createSQL: `CREATE TABLE if NOT EXISTS schema_migrations (
29+
module_name TEXT NOT NULL,
30+
migration TEXT NOT NULL,
31+
applied_at TEXT NOT NULL,
32+
PRIMARY KEY (module_name, migration)
33+
)`,
34+
claimSQL: `INSERT INTO schema_migrations (module_name, migration, applied_at) VALUES (?, ?, ?) ON CONFLICT(module_name, migration) DO NOTHING`,
35+
}
36+
Postgres = Dialect{
37+
createSQL: `DO $$ BEGIN
38+
CREATE TABLE IF NOT EXISTS schema_migrations (
39+
module_name TEXT NOT NULL,
40+
migration TEXT NOT NULL,
41+
applied_at TIMESTAMPTZ NOT NULL,
42+
PRIMARY KEY (module_name, migration)
43+
);
44+
EXCEPTION
45+
WHEN duplicate_table THEN NULL;
46+
WHEN duplicate_object THEN NULL;
47+
WHEN unique_violation THEN NULL;
48+
END $$`,
49+
claimSQL: `INSERT INTO schema_migrations (module_name, migration, applied_at) VALUES ($1, $2, $3) ON CONFLICT(module_name, migration) DO NOTHING`,
50+
}
51+
MySQL = Dialect{
52+
createSQL: `CREATE TABLE if NOT EXISTS schema_migrations (
53+
module_name VARCHAR(255) NOT NULL,
54+
migration VARCHAR(255) NOT NULL,
55+
applied_at DATETIME NOT NULL,
56+
PRIMARY KEY (module_name, migration)
57+
)`,
58+
claimSQL: `INSERT IGNORE INTO schema_migrations (module_name, migration, applied_at) VALUES (?, ?, ?)`,
59+
}
60+
)
61+
62+
type Option interface {
63+
apply(*config)
64+
}
65+
66+
type config struct {
67+
migrations []Migrations
68+
logger *slog.Logger
69+
}
70+
71+
func (m Migrations) apply(c *config) {
72+
c.migrations = append(c.migrations, m)
73+
}
74+
75+
type loggerOption struct{ logger *slog.Logger }
76+
77+
func (o loggerOption) apply(c *config) { c.logger = o.logger }
78+
79+
func WithLogger(logger *slog.Logger) Option { return loggerOption{logger} }
80+
81+
// Migrate runs database migrations in a safe and idempotent way.
82+
//
83+
// It creates a schema_migrations table if it doesn't exist, then applies each migration
84+
// exactly once by claiming it first. Migrations are grouped by module name and executed
85+
// in order. Each migration runs in its own transaction - if it fails, the transaction
86+
// is rolled back and an error is returned.
87+
//
88+
// The function is safe for concurrent execution: only one instance will successfully
89+
// claim and apply each migration. Already applied migrations are skipped automatically.
90+
//
91+
// Parameters:
92+
// - ctx: context for cancellation and timeout control
93+
// - db: database connection to run migrations on
94+
// - dialect: database-specific SQL syntax (SQLite, Postgres, or MySQL)
95+
// - opts: optional configuration (migrations to run, logger for output)
96+
//
97+
// Returns an error if table creation fails, transaction handling fails, or any
98+
// migration SQL fails to execute.
99+
func Migrate(
100+
ctx context.Context,
101+
db *sql.DB,
102+
dialect Dialect,
103+
opts ...Option,
104+
) error {
105+
var cfg config
106+
for _, o := range opts {
107+
o.apply(&cfg)
108+
}
109+
110+
_, err := db.ExecContext(ctx, dialect.createSQL)
111+
if err != nil {
112+
return fmt.Errorf("create schema_migrations: %w", err)
113+
}
114+
115+
for _, group := range cfg.migrations {
116+
for _, m := range group.Migrations {
117+
tx, err := db.BeginTx(ctx, nil)
118+
if err != nil {
119+
return fmt.Errorf("begin tx for %s/%s: %w", group.ModuleName, m.Name, err)
120+
}
121+
122+
res, err := tx.ExecContext(ctx, dialect.claimSQL,
123+
group.ModuleName, m.Name, time.Now().UTC(),
124+
)
125+
if err != nil {
126+
_ = tx.Rollback()
127+
return fmt.Errorf("claim migration %s/%s: %w", group.ModuleName, m.Name, err)
128+
}
129+
130+
n, _ := res.RowsAffected()
131+
if n == 0 {
132+
_ = tx.Rollback()
133+
continue
134+
}
135+
136+
if _, err := tx.ExecContext(ctx, m.SQL); err != nil {
137+
_ = tx.Rollback()
138+
return fmt.Errorf("apply migration %s/%s: %w", group.ModuleName, m.Name, err)
139+
}
140+
141+
if err := tx.Commit(); err != nil {
142+
return fmt.Errorf("commit migration %s/%s: %w", group.ModuleName, m.Name, err)
143+
}
144+
145+
if cfg.logger != nil {
146+
cfg.logger.InfoContext(ctx, "applied migration",
147+
"module", group.ModuleName,
148+
"name", m.Name,
149+
)
150+
}
151+
}
152+
}
153+
154+
return nil
155+
}

0 commit comments

Comments
 (0)