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:
x0gp
2026-06-22 22:01:09 +04:00
commit 978d36c505
71 changed files with 23500 additions and 0 deletions
@@ -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
}