feat(db): add embedded auto-migrating schema system

Replace 4 independent schema management code paths (Docker shell
script, setup wizard pg_restore, test helpers, manual psql) with a
single migration runner embedded in the server binary.

The new server/migrations/ package uses Go embed to bundle all SQL
schemas. On startup, Migrate() creates a schema_version tracking
table, detects existing databases (auto-marks baseline as applied),
and runs pending migrations in transactions.

Key changes:
- Consolidated init.sql + 9.2-update + 33 patches into 0001_init.sql
- Setup wizard simplified to single "Apply schema" checkbox
- Test helpers use migrations.Migrate() instead of pg_restore
- Docker no longer needs schema volume mounts or init script
- Seed data (shops, events, gacha) embedded and applied via API
- Future migrations just add 0002_*.sql files — no manual steps
This commit is contained in:
Houmgaor
2026-02-23 21:19:21 +01:00
parent 6a7db47723
commit 27fb0faa1e
62 changed files with 4736 additions and 932 deletions

View File

@@ -3,17 +3,16 @@ package channelserver
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
"erupe-ce/server/channelserver/compression/nullcomp"
"erupe-ce/server/migrations"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"go.uber.org/zap"
)
var (
@@ -104,147 +103,14 @@ func CleanTestDB(t *testing.T, db *sqlx.DB) {
}
}
// ApplyTestSchema applies the database schema from init.sql using pg_restore
// ApplyTestSchema applies the database schema using the embedded migration system.
func ApplyTestSchema(t *testing.T, db *sqlx.DB) {
t.Helper()
// Find the project root (where schemas/ directory is located)
projectRoot := findProjectRoot(t)
schemaPath := filepath.Join(projectRoot, "schemas", "init.sql")
// Get the connection config
config := DefaultTestDBConfig()
// Use pg_restore to load the schema dump
// The init.sql file is a pg_dump custom format, so we need pg_restore
cmd := exec.Command("pg_restore",
"-h", config.Host,
"-p", config.Port,
"-U", config.User,
"-d", config.DBName,
"--no-owner",
"--no-acl",
schemaPath,
)
cmd.Env = append(os.Environ(), fmt.Sprintf("PGPASSWORD=%s", config.Password))
output, err := cmd.CombinedOutput()
logger, _ := zap.NewDevelopment()
_, err := migrations.Migrate(db, logger.Named("test-migrations"))
if err != nil {
out := string(output)
// pg_restore reports non-fatal warnings (version mismatches, already exists) as errors.
// Only fail if we see no "errors ignored on restore" summary, which means a real failure.
if !strings.Contains(out, "errors ignored on restore") {
t.Fatalf("pg_restore failed: %v\n%s", err, out)
}
t.Logf("pg_restore completed with non-fatal warnings (ignored)")
}
// Apply the 9.2 update schema (init.sql bootstraps to 9.1.0)
applyUpdateSchema(t, db, projectRoot)
// Apply patch schemas in order
applyPatchSchemas(t, db, projectRoot)
}
// applyUpdateSchema applies the 9.2 update schema that bridges init.sql (v9.1.0) to v9.2.0.
// It runs each statement individually to tolerate partial failures (e.g. role references).
func applyUpdateSchema(t *testing.T, db *sqlx.DB, projectRoot string) {
t.Helper()
updatePath := filepath.Join(projectRoot, "schemas", "update-schema", "9.2-update.sql")
updateSQL, err := os.ReadFile(updatePath)
if err != nil {
t.Logf("Warning: Could not read 9.2 update schema: %v", err)
return
}
// Strip the outer BEGIN/END transaction wrapper so we can run statements individually.
content := string(updateSQL)
content = strings.Replace(content, "BEGIN;", "", 1)
// Remove trailing END; (last occurrence)
if idx := strings.LastIndex(content, "END;"); idx >= 0 {
content = content[:idx] + content[idx+4:]
}
// Split on semicolons and execute each statement, tolerating errors from
// role references or already-applied changes.
for _, stmt := range strings.Split(content, ";") {
stmt = strings.TrimSpace(stmt)
if stmt == "" {
continue
}
_, _ = db.Exec(stmt) // Errors expected for role mismatches, already-applied changes, etc.
}
}
// applyPatchSchemas applies all patch schema files in numeric order
func applyPatchSchemas(t *testing.T, db *sqlx.DB, projectRoot string) {
t.Helper()
patchDir := filepath.Join(projectRoot, "schemas", "patch-schema")
entries, err := os.ReadDir(patchDir)
if err != nil {
t.Logf("Warning: Could not read patch-schema directory: %v", err)
return
}
// Sort patch files numerically
var patchFiles []string
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
patchFiles = append(patchFiles, entry.Name())
}
}
sort.Strings(patchFiles)
// Apply each patch in its own transaction
for _, filename := range patchFiles {
patchPath := filepath.Join(patchDir, filename)
patchSQL, err := os.ReadFile(patchPath)
if err != nil {
t.Logf("Warning: Failed to read patch file %s: %v", filename, err)
continue
}
// Start a new transaction for each patch
tx, err := db.Begin()
if err != nil {
t.Logf("Warning: Failed to start transaction for patch %s: %v", filename, err)
continue
}
_, err = tx.Exec(string(patchSQL))
if err != nil {
_ = tx.Rollback()
t.Logf("Warning: Failed to apply patch %s: %v", filename, err)
// Continue with other patches even if one fails
} else {
_ = tx.Commit()
}
}
}
// findProjectRoot finds the project root directory by looking for the schemas directory
func findProjectRoot(t *testing.T) string {
t.Helper()
// Start from current directory and walk up
dir, err := os.Getwd()
if err != nil {
t.Fatalf("Failed to get working directory: %v", err)
}
for {
schemasPath := filepath.Join(dir, "schemas")
if stat, err := os.Stat(schemasPath); err == nil && stat.IsDir() {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal("Could not find project root (schemas directory not found)")
}
dir = parent
t.Fatalf("Failed to apply schema migrations: %v", err)
}
}