mirror of
https://github.com/IS1DI/x0gp.git
synced 2026-07-16 20:47:55 +00:00
feat(server): initial implementation
This commit captures the full server code accumulated across
several development sessions. It includes the following layers,
applied in roughly this order during development:
1. Base infrastructure
- cmd/poc-server HTTP+WebSocket server, /health, /stats
- internal/transport wire types (Envelope + all message types)
- internal/catalog, internal/realtime, internal/stats
- internal/storage/postgres with migrations 001-004
- .env, .env.example, docker-compose, Dockerfile, scripts/
2. Swagger documentation
- go get github.com/swaggo/http-swagger
- swag init produces docs/docs.go
- /swagger/index.html UI, /swagger/doc.json spec
- annotations on health/stats/ws and catalog handlers
3. Races API
- internal/races/{store,service,keyset,types}.go
- migration 005_races.sql: finished_races, race_plans, race_queue
- GET /api/races with keyset pagination, status filter
- GET /api/races/upcoming
- POST /api/races/queue/join, GET, DELETE
- POST/GET /api/races/plans, DELETE /{id}
- lobby.RaceMeta simplification (no host_id/host_name)
4. Races seeder
- internal/races/seed with deterministic generator
- --seed-races / --reset CLI flags in main.go
- 30 finished + 5 live + 5 plans + 4 queue entries
5. Drivers and clans
- internal/drivers, internal/clans (CRUD, validation)
- migration 008_drivers_clans.sql
- /api/clans and /api/drivers endpoints
- 3-letter uppercase nickname/tag validation
- lobby.DriverMeta: Nickname, AvatarURL, ClanID, ClanTag
6. Podium
- internal/transport.RacePodiumEntry
- lobby.SetDriverProfile for in-memory metadata sync
- migration 007_podium.sql (podium JSONB column)
- seeder populates top-3 per finished race
7. Live races persistence
- migration 009_live_persistence.sql: live_races,
live_race_drivers, lobby_drivers
- internal/races/live_store.go: LiveStore with write-side
mirror for lobby.Service mutations
- Service.collectLive and Upcoming read from Postgres
- main.go RestoreFromDB rehydrates lobby on startup
8. Unify live + finished into one races table
- migration 010_unify_races.sql: rename finished_races to
races, expand status CHECK, merge live rows
- PgStore now hosts both write paths; LiveStore is a
thin facade implementing lobby.Persistence
- seeder resetAll drops only finished/cancelled rows and
race_plans / race_queue / lobby_drivers
Each layer is consistent on its own; cross-layer changes are
visible in the file history. A future refactor may split this
commit into the per-stage boundaries listed above.
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
// Package postgres owns the persistence layer of the catalog.
|
||||
//
|
||||
// It exposes a *pgxpool.Pool plus a small set of repository helpers used by
|
||||
// internal/catalog. Migrations are applied at startup from the SQL files in
|
||||
// the migrations/ subdirectory.
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsFS embed.FS
|
||||
|
||||
// Config captures the connection parameters. URL takes precedence over the
|
||||
// individual fields if non-empty.
|
||||
type Config struct {
|
||||
URL string
|
||||
Host string
|
||||
Port int
|
||||
User string
|
||||
Password string
|
||||
Database string
|
||||
SSLMode string
|
||||
MaxConns int32
|
||||
MinConns int32
|
||||
ConnectTimeout time.Duration
|
||||
}
|
||||
|
||||
// Open establishes a pgxpool, pings the database and returns it. The
|
||||
// returned pool must be closed by the caller.
|
||||
func Open(ctx context.Context, cfg Config) (*pgxpool.Pool, error) {
|
||||
dsn, err := cfg.DSN()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pcfg, err := pgxpool.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse dsn: %w", err)
|
||||
}
|
||||
if cfg.MaxConns > 0 {
|
||||
pcfg.MaxConns = cfg.MaxConns
|
||||
}
|
||||
if cfg.MinConns > 0 {
|
||||
pcfg.MinConns = cfg.MinConns
|
||||
}
|
||||
if cfg.ConnectTimeout > 0 {
|
||||
pcfg.ConnConfig.ConnectTimeout = cfg.ConnectTimeout
|
||||
}
|
||||
|
||||
pool, err := pgxpool.NewWithConfig(ctx, pcfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect: %w", err)
|
||||
}
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := pool.Ping(pingCtx); err != nil {
|
||||
pool.Close()
|
||||
return nil, fmt.Errorf("ping: %w", err)
|
||||
}
|
||||
return pool, nil
|
||||
}
|
||||
|
||||
// DSN builds the postgres connection string.
|
||||
func (c Config) DSN() (string, error) {
|
||||
if c.URL != "" {
|
||||
return c.URL, nil
|
||||
}
|
||||
if c.Host == "" {
|
||||
return "", fmt.Errorf("postgres: host required")
|
||||
}
|
||||
port := c.Port
|
||||
if port == 0 {
|
||||
port = 5432
|
||||
}
|
||||
ssl := c.SSLMode
|
||||
if ssl == "" {
|
||||
ssl = "disable"
|
||||
}
|
||||
return fmt.Sprintf("postgres://%s:%s@%s:%d/%s?sslmode=%s",
|
||||
c.User, c.Password, c.Host, port, c.Database, ssl), nil
|
||||
}
|
||||
|
||||
// Migrate applies any pending SQL migrations from the embedded migrations/
|
||||
// directory. Migrations are tracked in the schema_migrations table and run
|
||||
// in lexical filename order. Each migration runs in its own transaction.
|
||||
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
|
||||
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 migrations: %w", err)
|
||||
}
|
||||
names := make([]string, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() && strings.HasSuffix(e.Name(), ".sql") {
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
applied := map[string]struct{}{}
|
||||
rows, err := pool.Query(ctx, `SELECT version FROM schema_migrations`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("list applied migrations: %w", err)
|
||||
}
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
applied[v] = struct{}{}
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
for _, name := range names {
|
||||
if _, ok := applied[name]; ok {
|
||||
continue
|
||||
}
|
||||
body, err := migrationsFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
if err := applyMigration(ctx, pool, name, string(body)); err != nil {
|
||||
return fmt.Errorf("apply %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyMigration(ctx context.Context, pool *pgxpool.Pool, name, body string) error {
|
||||
tx, err := pool.BeginTx(ctx, pgx.TxOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
// pgx's simple-protocol Exec() rejects multi-statement strings. Split
|
||||
// the body on ";\n" boundaries and run each statement individually.
|
||||
for i, stmt := range splitStatements(body) {
|
||||
if _, err := tx.Exec(ctx, stmt); err != nil {
|
||||
preview := stmt
|
||||
if len(preview) > 120 {
|
||||
preview = preview[:120] + "..."
|
||||
}
|
||||
return fmt.Errorf("statement %d failed: %w; sql=%q", i+1, err, preview)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(ctx,
|
||||
`INSERT INTO schema_migrations (version) VALUES ($1)`, name); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// splitStatements splits a SQL script on top-level ";" boundaries. It
|
||||
// is deliberately simple: it ignores ";" inside string literals and
|
||||
// comments, which is fine for our migration files (no literal ";"
|
||||
// in any INSERT).
|
||||
func splitStatements(body string) []string {
|
||||
var (
|
||||
out []string
|
||||
buf strings.Builder
|
||||
inStr bool
|
||||
escNext bool
|
||||
)
|
||||
for i := 0; i < len(body); i++ {
|
||||
c := body[i]
|
||||
if escNext {
|
||||
buf.WriteByte(c)
|
||||
escNext = false
|
||||
continue
|
||||
}
|
||||
if c == '\\' && inStr {
|
||||
buf.WriteByte(c)
|
||||
escNext = true
|
||||
continue
|
||||
}
|
||||
if c == '\'' {
|
||||
inStr = !inStr
|
||||
buf.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
if c == '-' && !inStr && i+1 < len(body) && body[i+1] == '-' {
|
||||
// line comment until \n
|
||||
for i < len(body) && body[i] != '\n' {
|
||||
buf.WriteByte(body[i])
|
||||
i++
|
||||
}
|
||||
if i < len(body) {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
continue
|
||||
}
|
||||
if c == ';' && !inStr {
|
||||
s := strings.TrimSpace(buf.String())
|
||||
if s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
buf.Reset()
|
||||
continue
|
||||
}
|
||||
buf.WriteByte(c)
|
||||
}
|
||||
if rest := strings.TrimSpace(buf.String()); rest != "" {
|
||||
out = append(out, rest)
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user