Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 31 additions & 3 deletions workspace-server/internal/db/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ func InitPostgres(databaseURL string) error {
// Migration authors must write idempotent SQL. A real schema_migrations
// tracking table would be better; tracked as follow-up.
func RunMigrations(migrationsDir string) error {
// Create tracking table if it doesn't exist.
if _, err := DB.Exec(`CREATE TABLE IF NOT EXISTS schema_migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)`); err != nil {
return fmt.Errorf("create schema_migrations: %w", err)
}

allFiles, err := filepath.Glob(filepath.Join(migrationsDir, "*.sql"))
if err != nil {
return fmt.Errorf("glob migrations: %w", err)
Expand All @@ -66,16 +74,36 @@ func RunMigrations(migrationsDir string) error {
}
sort.Strings(files)

applied := 0
skipped := 0
for _, f := range files {
log.Printf("Applying migration: %s", filepath.Base(f))
base := filepath.Base(f)

// Check if already applied.
var exists bool
if err := DB.QueryRow("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)", base).Scan(&exists); err != nil {
return fmt.Errorf("check migration %s: %w", base, err)
}
if exists {
skipped++
continue
}

log.Printf("Applying migration: %s", base)
content, err := os.ReadFile(f)
if err != nil {
return fmt.Errorf("read %s: %w", f, err)
}
if _, err := DB.Exec(string(content)); err != nil {
return fmt.Errorf("exec %s: %w", filepath.Base(f), err)
return fmt.Errorf("exec %s: %w", base, err)
}

// Record as applied.
if _, err := DB.Exec("INSERT INTO schema_migrations (filename) VALUES ($1)", base); err != nil {
return fmt.Errorf("record migration %s: %w", base, err)
}
applied++
}
log.Printf("Applied %d migrations", len(files))
log.Printf("Applied %d migrations (%d already applied)", applied, skipped)
return nil
}
166 changes: 166 additions & 0 deletions workspace-server/internal/db/postgres_schema_migrations_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
package db

import (
"os"
"path/filepath"
"regexp"
"testing"

"github.com/DATA-DOG/go-sqlmock"
)

// Tests for schema_migrations tracking — verifies migrations only run once.

func TestRunMigrations_FirstBoot_AppliesAndRecords(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
DB = mockDB

tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_init.up.sql"), []byte("CREATE TABLE foo();"), 0o644)

// Expect: CREATE tracking table
mock.ExpectExec(regexp.QuoteMeta("CREATE TABLE IF NOT EXISTS schema_migrations")).
WillReturnResult(sqlmock.NewResult(0, 0))

// Expect: check if 001_init.up.sql already applied → returns false
mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)")).
WithArgs("001_init.up.sql").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))

// Expect: apply migration
mock.ExpectExec(regexp.QuoteMeta("CREATE TABLE foo();")).
WillReturnResult(sqlmock.NewResult(0, 0))

// Expect: record as applied
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO schema_migrations (filename) VALUES ($1)")).
WithArgs("001_init.up.sql").
WillReturnResult(sqlmock.NewResult(0, 1))

if err := RunMigrations(tmp); err != nil {
t.Fatalf("RunMigrations: %v", err)
}

if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}

func TestRunMigrations_SecondBoot_SkipsApplied(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
DB = mockDB

tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_init.up.sql"), []byte("CREATE TABLE foo();"), 0o644)
os.WriteFile(filepath.Join(tmp, "002_next.up.sql"), []byte("CREATE TABLE bar();"), 0o644)

// Tracking table create is always attempted
mock.ExpectExec(regexp.QuoteMeta("CREATE TABLE IF NOT EXISTS schema_migrations")).
WillReturnResult(sqlmock.NewResult(0, 0))

// 001 already applied → skip
mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)")).
WithArgs("001_init.up.sql").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))

// 002 also already applied → skip
mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)")).
WithArgs("002_next.up.sql").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))

// No ExecExec for the migration bodies — they shouldn't run

if err := RunMigrations(tmp); err != nil {
t.Fatalf("RunMigrations: %v", err)
}

if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}

func TestRunMigrations_MixedState_AppliesOnlyNew(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
DB = mockDB

tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_old.up.sql"), []byte("SELECT 1;"), 0o644)
os.WriteFile(filepath.Join(tmp, "002_new.up.sql"), []byte("SELECT 2;"), 0o644)

mock.ExpectExec(regexp.QuoteMeta("CREATE TABLE IF NOT EXISTS schema_migrations")).
WillReturnResult(sqlmock.NewResult(0, 0))

// 001 already applied
mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)")).
WithArgs("001_old.up.sql").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(true))

// 002 not yet applied
mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)")).
WithArgs("002_new.up.sql").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))

// Apply 002
mock.ExpectExec(regexp.QuoteMeta("SELECT 2;")).
WillReturnResult(sqlmock.NewResult(0, 0))

// Record 002
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO schema_migrations (filename) VALUES ($1)")).
WithArgs("002_new.up.sql").
WillReturnResult(sqlmock.NewResult(0, 1))

if err := RunMigrations(tmp); err != nil {
t.Fatalf("RunMigrations: %v", err)
}

if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}

func TestRunMigrations_SkipsDownSqlFilesEvenInTracking(t *testing.T) {
mockDB, mock, err := sqlmock.New()
if err != nil {
t.Fatalf("sqlmock: %v", err)
}
defer mockDB.Close()
DB = mockDB

tmp := t.TempDir()
os.WriteFile(filepath.Join(tmp, "001_init.up.sql"), []byte("CREATE TABLE foo();"), 0o644)
os.WriteFile(filepath.Join(tmp, "001_init.down.sql"), []byte("DROP TABLE foo;"), 0o644)

mock.ExpectExec(regexp.QuoteMeta("CREATE TABLE IF NOT EXISTS schema_migrations")).
WillReturnResult(sqlmock.NewResult(0, 0))

// Only .up.sql should be checked — not .down.sql
mock.ExpectQuery(regexp.QuoteMeta("SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE filename = $1)")).
WithArgs("001_init.up.sql").
WillReturnRows(sqlmock.NewRows([]string{"exists"}).AddRow(false))

mock.ExpectExec(regexp.QuoteMeta("CREATE TABLE foo();")).
WillReturnResult(sqlmock.NewResult(0, 0))

mock.ExpectExec(regexp.QuoteMeta("INSERT INTO schema_migrations (filename) VALUES ($1)")).
WithArgs("001_init.up.sql").
WillReturnResult(sqlmock.NewResult(0, 1))

if err := RunMigrations(tmp); err != nil {
t.Fatalf("RunMigrations: %v", err)
}

if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
Loading