Skip to content

Commit 3bbf368

Browse files
authored
Better migrations DX (#50)
* update migrations mechanism * update docs
1 parent 044276a commit 3bbf368

13 files changed

Lines changed: 504 additions & 118 deletions

File tree

.agents/architecture.md

Lines changed: 22 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -85,27 +85,33 @@ server.Handle("/api", api)
8585

8686
## DATABASE MIGRATIONS
8787

88-
Migrations live in the repository and run automatically on startup:
88+
Migrations are written in `.sql` files and embedded via `fs.FS`. Each file contains up and down scripts separated by markers:
89+
90+
```sql
91+
-- 001_create_users.sql
92+
-- +migrate Up
93+
CREATE TABLE users (
94+
id UUID PRIMARY KEY,
95+
email TEXT UNIQUE NOT NULL
96+
);
97+
98+
-- +migrate Down
99+
DROP TABLE users;
100+
```
101+
102+
Repository returns embedded migrations:
89103

90104
```go
91-
func (r *Repository) Migrations() []database.Migration {
92-
return []database.Migration{
93-
{
94-
Name: "001_create_users_table",
95-
Up: `CREATE TABLE users (
96-
id UUID PRIMARY KEY,
97-
email TEXT UNIQUE NOT NULL,
98-
created_at TIMESTAMP NOT NULL DEFAULT NOW()
99-
)`,
100-
},
101-
{
102-
Name: "002_add_status_column",
103-
Up: `ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'`,
104-
},
105-
}
105+
//go:embed *.sql
106+
var migrations embed.FS
107+
108+
func (r *Repository) Migrations() fs.FS {
109+
return migrations
106110
}
107111
```
108112

113+
Migration files are sorted lexicographically by filename. Use numeric prefixes for ordering: `001_`, `002_`, etc.
114+
109115
## BACKGROUND JOBS
110116

111117
Implement the queue handler interface:

auth/001_init.sql

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
-- +migrate Up
2+
CREATE TABLE IF NOT EXISTS users (
3+
id VARCHAR(255) PRIMARY KEY,
4+
username VARCHAR(255) UNIQUE,
5+
password TEXT,
6+
salt TEXT,
7+
created TIMESTAMP,
8+
updated TIMESTAMP,
9+
status VARCHAR(50)
10+
);
11+
12+
-- +migrate Down
13+
DROP TABLE users;

auth/repository.go

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@ package auth
33
import (
44
"context"
55
"database/sql"
6+
"embed"
67
"fmt"
7-
8-
"github.com/platforma-dev/platforma/database"
8+
"io/fs"
99
)
1010

1111
type db interface {
@@ -25,21 +25,11 @@ func NewRepository(db db) *Repository {
2525
}
2626
}
2727

28-
func (r *Repository) Migrations() []database.Migration {
29-
return []database.Migration{{
30-
ID: "init",
31-
Up: `CREATE TABLE IF NOT EXISTS users (
32-
id VARCHAR(255) PRIMARY KEY,
33-
username VARCHAR(255) UNIQUE,
34-
password TEXT,
35-
salt TEXT,
36-
created TIMESTAMP,
37-
updated TIMESTAMP,
38-
status VARCHAR(50)
39-
)`,
40-
Down: "DROP TABLE users",
41-
}}
28+
//go:embed *.sql
29+
var migrations embed.FS
4230

31+
func (r *Repository) Migrations() fs.FS {
32+
return migrations
4333
}
4434

4535
func (r *Repository) Get(ctx context.Context, id string) (*User, error) {

database/database.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,11 @@ func (db *Database) Migrate(ctx context.Context) error {
6161
// Get migrations from all migrators
6262
migrations := []Migration{}
6363
for name, migrator := range db.migrators {
64-
for _, migr := range migrator.Migrations() {
64+
parsed, err := ParseMigrations(migrator.Migrations())
65+
if err != nil {
66+
return fmt.Errorf("failed to parse migrations for %s: %w", name, err)
67+
}
68+
for _, migr := range parsed {
6569
migr.repository = name
6670
migrations = append(migrations, migr)
6771
}

database/database_test.go

Lines changed: 65 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ package database_test
44

55
import (
66
"context"
7+
"io/fs"
78
"slices"
89
"testing"
10+
"testing/fstest"
911
"time"
1012

1113
"github.com/platforma-dev/platforma/database"
@@ -148,11 +150,11 @@ func TestMigrate(t *testing.T) {
148150
t.Fatalf("database is nil")
149151
}
150152

151-
db.RegisterRepository("some_repo", simpleRepo{migrations: []database.Migration{{
152-
ID: "init",
153+
db.RegisterRepository("some_repo", simpleRepo{fsys: migrationFS(database.Migration{
154+
ID: "001_init",
153155
Up: "CREATE TABLE IF NOT EXISTS simple_repo (id TEXT)",
154156
Down: "DROP TABLE simple_repo",
155-
}}})
157+
})})
156158

157159
err = db.Migrate(ctx)
158160
if err != nil {
@@ -171,7 +173,7 @@ func TestMigrate(t *testing.T) {
171173
}
172174

173175
if !slices.ContainsFunc(migrationLogs, func(log migrationLog) bool {
174-
return log.Repository == "some_repo" && log.MigrationID == "init"
176+
return log.Repository == "some_repo" && log.MigrationID == "001_init"
175177
}) {
176178
t.Fatalf("expected migration log to contain init migration for some_repo")
177179
}
@@ -199,17 +201,17 @@ func TestMigrate(t *testing.T) {
199201
t.Fatalf("database is nil")
200202
}
201203

202-
db.RegisterRepository("some_repo", simpleRepo{migrations: []database.Migration{{
203-
ID: "init",
204+
db.RegisterRepository("some_repo", simpleRepo{fsys: migrationFS(database.Migration{
205+
ID: "001_init",
204206
Up: "CREATE TABLE IF NOT EXISTS simple_repo (id TEXT)",
205207
Down: "DROP TABLE simple_repo",
206-
}}})
208+
})})
207209

208-
db.RegisterRepository("other_repo", simpleRepo{migrations: []database.Migration{{
209-
ID: "init",
210+
db.RegisterRepository("other_repo", simpleRepo{fsys: migrationFS(database.Migration{
211+
ID: "001_init",
210212
Up: "CREATE TABLE IF NOT EXISTS other_repo (id TEXT)",
211213
Down: "DROP TABLE other_repo",
212-
}}})
214+
})})
213215

214216
err = db.Migrate(ctx)
215217
if err != nil {
@@ -228,7 +230,7 @@ func TestMigrate(t *testing.T) {
228230
}
229231

230232
if !slices.ContainsFunc(migrationLogs, func(log migrationLog) bool {
231-
return log.Repository == "some_repo" && log.MigrationID == "init"
233+
return log.Repository == "some_repo" && log.MigrationID == "001_init"
232234
}) {
233235
t.Fatalf("expected migration log to contain init migration for some_repo")
234236
}
@@ -239,7 +241,7 @@ func TestMigrate(t *testing.T) {
239241
}
240242

241243
if !slices.ContainsFunc(migrationLogs, func(log migrationLog) bool {
242-
return log.Repository == "other_repo" && log.MigrationID == "init"
244+
return log.Repository == "other_repo" && log.MigrationID == "001_init"
243245
}) {
244246
t.Fatalf("expected migration log to contain init migration for other_repo, but only got: %s", migrationLogs)
245247
}
@@ -267,21 +269,24 @@ func TestMigrate(t *testing.T) {
267269
t.Fatalf("database is nil")
268270
}
269271

270-
db.RegisterRepository("some_repo", simpleRepo{migrations: []database.Migration{{
271-
ID: "init",
272+
db.RegisterRepository("some_repo", simpleRepo{fsys: migrationFS(database.Migration{
273+
ID: "001_init",
272274
Up: "CREATE TABLE IF NOT EXISTS simple_repo (id TEXT)",
273275
Down: "DROP TABLE simple_repo",
274-
}}})
275-
276-
db.RegisterRepository("other_repo", simpleRepo{migrations: []database.Migration{{
277-
ID: "init",
278-
Up: "CREATE TABLE IF NOT EXISTS other_repo (id TEXT)",
279-
Down: "DROP TABLE other_repo",
280-
}, {
281-
ID: "failing",
282-
Up: "not even SQL here",
283-
Down: "no need for this",
284-
}}})
276+
})})
277+
278+
db.RegisterRepository("other_repo", simpleRepo{fsys: migrationFS(
279+
database.Migration{
280+
ID: "001_init",
281+
Up: "CREATE TABLE IF NOT EXISTS other_repo (id TEXT)",
282+
Down: "DROP TABLE other_repo",
283+
},
284+
database.Migration{
285+
ID: "002_failing",
286+
Up: "not even SQL here",
287+
Down: "no need for this",
288+
},
289+
)})
285290

286291
err = db.Migrate(ctx)
287292
if err == nil {
@@ -309,7 +314,7 @@ func TestMigrate(t *testing.T) {
309314

310315
// because migration should be reverted
311316
if slices.ContainsFunc(migrationLogs, func(log migrationLog) bool {
312-
return log.Repository == "some_repo" && log.MigrationID == "init"
317+
return log.Repository == "some_repo" && log.MigrationID == "001_init"
313318
}) {
314319
t.Fatalf("expected migration log to not contain init migration for some_repo")
315320
}
@@ -321,7 +326,7 @@ func TestMigrate(t *testing.T) {
321326

322327
// because migration should be reverted
323328
if slices.ContainsFunc(migrationLogs, func(log migrationLog) bool {
324-
return log.Repository == "other_repo" && log.MigrationID == "init"
329+
return log.Repository == "other_repo" && log.MigrationID == "001_init"
325330
}) {
326331
t.Fatalf("expected migration log to not contain init migration for other_repo, but only got: %s", migrationLogs)
327332
}
@@ -349,21 +354,24 @@ func TestMigrate(t *testing.T) {
349354
t.Fatalf("database is nil")
350355
}
351356

352-
db.RegisterRepository("some_repo", simpleRepo{migrations: []database.Migration{{
353-
ID: "init",
357+
db.RegisterRepository("some_repo", simpleRepo{fsys: migrationFS(database.Migration{
358+
ID: "001_init",
354359
Up: "CREATE TABLE IF NOT EXISTS simple_repo (id TEXT)",
355360
Down: "broken SQL",
356-
}}})
357-
358-
db.RegisterRepository("other_repo", simpleRepo{migrations: []database.Migration{{
359-
ID: "init",
360-
Up: "CREATE TABLE IF NOT EXISTS other_repo (id TEXT)",
361-
Down: "DROP TABLE other_repo",
362-
}, {
363-
ID: "failing",
364-
Up: "not even SQL here",
365-
Down: "no need for this",
366-
}}})
361+
})})
362+
363+
db.RegisterRepository("other_repo", simpleRepo{fsys: migrationFS(
364+
database.Migration{
365+
ID: "001_init",
366+
Up: "CREATE TABLE IF NOT EXISTS other_repo (id TEXT)",
367+
Down: "DROP TABLE other_repo",
368+
},
369+
database.Migration{
370+
ID: "002_failing",
371+
Up: "not even SQL here",
372+
Down: "no need for this",
373+
},
374+
)})
367375

368376
err = db.Migrate(ctx)
369377
if err == nil {
@@ -391,14 +399,14 @@ func TestMigrate(t *testing.T) {
391399

392400
// because migration should be reverted or not even attempted
393401
if slices.ContainsFunc(migrationLogs, func(log migrationLog) bool {
394-
return log.Repository == "some_repo" && log.MigrationID == "init"
402+
return log.Repository == "some_repo" && log.MigrationID == "001_init"
395403
}) {
396404
t.Fatalf("expected migration log to not contain init migration for some_repo")
397405
}
398406

399407
// because migration should be reverted
400408
if slices.ContainsFunc(migrationLogs, func(log migrationLog) bool {
401-
return log.Repository == "other_repo" && log.MigrationID == "init"
409+
return log.Repository == "other_repo" && log.MigrationID == "001_init"
402410
}) {
403411
t.Fatalf("expected migration log to not contain init migration for other_repo, but only got: %s", migrationLogs)
404412
}
@@ -417,9 +425,21 @@ type migrationLog struct {
417425
}
418426

419427
type simpleRepo struct {
420-
migrations []database.Migration
428+
fsys fs.FS
429+
}
430+
431+
func (r simpleRepo) Migrations() fs.FS {
432+
return r.fsys
421433
}
422434

423-
func (r simpleRepo) Migrations() []database.Migration {
424-
return r.migrations
435+
func migrationFS(migrations ...database.Migration) fs.FS {
436+
mapFS := make(fstest.MapFS)
437+
for _, m := range migrations {
438+
content := "-- +migrate Up\n" + m.Up
439+
if m.Down != "" {
440+
content += "\n\n-- +migrate Down\n" + m.Down
441+
}
442+
mapFS[m.ID+".sql"] = &fstest.MapFile{Data: []byte(content)}
443+
}
444+
return mapFS
425445
}

database/migration.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package database
22

33
import (
4+
"io/fs"
45
"time"
56
)
67

@@ -19,5 +20,5 @@ type Migration struct {
1920
}
2021

2122
type migrator interface {
22-
Migrations() []Migration
23+
Migrations() fs.FS
2324
}

0 commit comments

Comments
 (0)