a00bd620a1
Self-hostable game-server control panel: controller + agent + 26 game modules. One-line install (prebuilt release, no Go required): curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
96 lines
2.9 KiB
Go
96 lines
2.9 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"embed"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
//go:embed migrations/*.sql
|
|
var migrationsFS embed.FS
|
|
|
|
// migrateLockKey is the pg advisory-lock key serializing Migrate across
|
|
// controllers sharing one database. Arbitrary but fixed: fnv-ish of
|
|
// "panel:schema_migrations". Session-scoped (not transaction-scoped) so it
|
|
// spans the whole multi-transaction run and releases on disconnect if the
|
|
// process dies mid-migrate.
|
|
const migrateLockKey int64 = 0x70616e656c6d6967 // "panelmig"
|
|
|
|
// Migrate applies any pending SQL files in migrations/ in filename-sorted
|
|
// order. Each file is wrapped in a transaction alongside its schema_migrations
|
|
// insert so failure leaves the DB in a consistent state. A session-level
|
|
// pg advisory lock serializes concurrent controllers: the second one blocks
|
|
// here, then finds every version already recorded and no-ops.
|
|
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
|
// Dedicated connection: session advisory locks belong to a connection,
|
|
// and pool.Exec could lock on one conn and unlock on another.
|
|
lockConn, err := pool.Acquire(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("acquire migrate lock conn: %w", err)
|
|
}
|
|
defer lockConn.Release()
|
|
if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockKey); err != nil {
|
|
return fmt.Errorf("acquire migrate advisory lock: %w", err)
|
|
}
|
|
defer func() {
|
|
_, _ = lockConn.Exec(context.WithoutCancel(ctx), `SELECT pg_advisory_unlock($1)`, migrateLockKey)
|
|
}()
|
|
|
|
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
version TEXT PRIMARY KEY,
|
|
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
|
)`); err != nil {
|
|
return fmt.Errorf("create schema_migrations: %w", err)
|
|
}
|
|
|
|
entries, err := migrationsFS.ReadDir("migrations")
|
|
if err != nil {
|
|
return fmt.Errorf("read embedded migrations: %w", err)
|
|
}
|
|
var files []string
|
|
for _, e := range entries {
|
|
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
|
files = append(files, e.Name())
|
|
}
|
|
}
|
|
sort.Strings(files)
|
|
|
|
for _, name := range files {
|
|
var exists bool
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT EXISTS(SELECT 1 FROM schema_migrations WHERE version = $1)`, name,
|
|
).Scan(&exists); err != nil {
|
|
return fmt.Errorf("check %s: %w", name, err)
|
|
}
|
|
if exists {
|
|
continue
|
|
}
|
|
|
|
body, err := migrationsFS.ReadFile("migrations/" + name)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", name, err)
|
|
}
|
|
|
|
tx, err := pool.Begin(ctx)
|
|
if err != nil {
|
|
return fmt.Errorf("begin tx for %s: %w", name, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, string(body)); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("apply %s: %w", name, err)
|
|
}
|
|
if _, err := tx.Exec(ctx, `INSERT INTO schema_migrations(version) VALUES ($1)`, name); err != nil {
|
|
_ = tx.Rollback(ctx)
|
|
return fmt.Errorf("record %s: %w", name, err)
|
|
}
|
|
if err := tx.Commit(ctx); err != nil {
|
|
return fmt.Errorf("commit %s: %w", name, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|