Files
x0gp/server/internal/storage/postgres/pgstore.go
T
x0gp 978d36c505 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.
2026-06-22 22:01:09 +04:00

373 lines
13 KiB
Go

package postgres
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/x0gp/server/internal/catalog"
)
// PgStore implements catalog.Store backed by Postgres + TimescaleDB.
type PgStore struct {
pool *pgxpool.Pool
}
// NewPgStore wraps an open pool.
func NewPgStore(pool *pgxpool.Pool) *PgStore {
return &PgStore{pool: pool}
}
// Compile-time check.
var _ catalog.Store = (*PgStore)(nil)
// ---------------------------------------------------------------------------
// Load
// ---------------------------------------------------------------------------
// Load reads the full catalog into memory. Used at startup.
func (s *PgStore) Load(ctx context.Context) ([]catalog.TrackMeta, []catalog.CarMeta, error) {
tracks, err := s.loadTracks(ctx)
if err != nil {
return nil, nil, fmt.Errorf("load tracks: %w", err)
}
cars, err := s.loadCars(ctx)
if err != nil {
return nil, nil, fmt.Errorf("load cars: %w", err)
}
return tracks, cars, nil
}
func (s *PgStore) loadTracks(ctx context.Context) ([]catalog.TrackMeta, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, description, author_id, visibility, scale,
length_m, width_m, lane_width_m, surface,
best_lap_ms, best_lap_holder,
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
created_ms, updated_ms
FROM tracks
ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]catalog.TrackMeta, 0)
for rows.Next() {
var t catalog.TrackMeta
if err := rows.Scan(
&t.ID, &t.Name, &t.Description, &t.AuthorID, &t.Visibility, &t.Scale,
&t.LengthM, &t.WidthM, &t.LaneWidthM, &t.Surface,
&t.BestLapMs, &t.BestLapHolder,
&t.Bounds.MinX, &t.Bounds.MinY, &t.Bounds.MaxX, &t.Bounds.MaxY,
&t.CreatedMs, &t.UpdatedMs,
); err != nil {
return nil, err
}
// Derive bounds length/width if not stored explicitly.
t.Bounds.LengthM = t.Bounds.MaxX - t.Bounds.MinX
t.Bounds.WidthM = t.Bounds.MaxY - t.Bounds.MinY
out = append(out, t)
}
if err := rows.Err(); err != nil {
return nil, err
}
// Fetch waypoints and tags per track.
for i := range out {
wps, err := s.loadWaypoints(ctx, out[i].ID)
if err != nil {
return nil, fmt.Errorf("waypoints %s: %w", out[i].ID, err)
}
out[i].Centerline = wps
tags, err := s.loadTags(ctx, out[i].ID)
if err != nil {
return nil, fmt.Errorf("tags %s: %w", out[i].ID, err)
}
out[i].Tags = tags
}
return out, nil
}
func (s *PgStore) loadWaypoints(ctx context.Context, trackID string) ([]catalog.Waypoint, error) {
rows, err := s.pool.Query(ctx, `
SELECT x, y, heading_rad, speed_ms, curvature
FROM track_waypoints
WHERE track_id = $1
ORDER BY seq`, trackID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]catalog.Waypoint, 0)
for rows.Next() {
var w catalog.Waypoint
if err := rows.Scan(&w.X, &w.Y, &w.HeadingRad, &w.SpeedMs, &w.Curvature); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
func (s *PgStore) loadTags(ctx context.Context, trackID string) ([]string, error) {
rows, err := s.pool.Query(ctx,
`SELECT tag FROM track_tags WHERE track_id = $1 ORDER BY tag`, trackID)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]string, 0)
for rows.Next() {
var tag string
if err := rows.Scan(&tag); err != nil {
return nil, err
}
out = append(out, tag)
}
return out, rows.Err()
}
func (s *PgStore) loadCars(ctx context.Context) ([]catalog.CarMeta, error) {
rows, err := s.pool.Query(ctx, `
SELECT id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed,
chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
total_distance_m, total_races, total_laps, best_lap_ms,
created_ms, updated_ms
FROM cars
ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]catalog.CarMeta, 0)
for rows.Next() {
var c catalog.CarMeta
if err := rows.Scan(
&c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale,
&c.LengthMm, &c.WidthMm, &c.HeightMm, &c.WeightG,
&c.Chassis.Model, &c.Chassis.Material, &c.Chassis.Printed,
&c.Chassis.WheelbaseMm, &c.Chassis.TrackMm,
&c.Motor.Kind, &c.Motor.Class, &c.Motor.KV, &c.Motor.PowerW,
&c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry,
&c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active,
&c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs,
&c.CreatedMs, &c.UpdatedMs,
); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// ---------------------------------------------------------------------------
// Mutators
// ---------------------------------------------------------------------------
// UpsertTrack writes the track, its waypoints, and its tags in one transaction.
func (s *PgStore) UpsertTrack(ctx context.Context, t catalog.TrackMeta) error {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx, `
INSERT INTO tracks (id, name, description, author_id, visibility, scale,
length_m, width_m, lane_width_m, surface,
best_lap_ms, best_lap_holder,
bounds_min_x, bounds_min_y, bounds_max_x, bounds_max_y,
created_ms, updated_ms)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
description = EXCLUDED.description,
author_id = EXCLUDED.author_id,
visibility = EXCLUDED.visibility,
scale = EXCLUDED.scale,
length_m = EXCLUDED.length_m,
width_m = EXCLUDED.width_m,
lane_width_m = EXCLUDED.lane_width_m,
surface = EXCLUDED.surface,
best_lap_ms = EXCLUDED.best_lap_ms,
best_lap_holder = EXCLUDED.best_lap_holder,
bounds_min_x = EXCLUDED.bounds_min_x,
bounds_min_y = EXCLUDED.bounds_min_y,
bounds_max_x = EXCLUDED.bounds_max_x,
bounds_max_y = EXCLUDED.bounds_max_y,
updated_ms = EXCLUDED.updated_ms`,
t.ID, t.Name, t.Description, t.AuthorID, string(t.Visibility), t.Scale,
t.LengthM, t.WidthM, t.LaneWidthM, string(t.Surface),
t.BestLapMs, t.BestLapHolder,
t.Bounds.MinX, t.Bounds.MinY, t.Bounds.MaxX, t.Bounds.MaxY,
t.CreatedMs, t.UpdatedMs,
); err != nil {
return err
}
if _, err := tx.Exec(ctx, `DELETE FROM track_waypoints WHERE track_id = $1`, t.ID); err != nil {
return err
}
for i, w := range t.Centerline {
if _, err := tx.Exec(ctx, `
INSERT INTO track_waypoints (track_id, seq, x, y, heading_rad, speed_ms, curvature)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
t.ID, i, w.X, w.Y, w.HeadingRad, w.SpeedMs, w.Curvature,
); err != nil {
return err
}
}
if _, err := tx.Exec(ctx, `DELETE FROM track_tags WHERE track_id = $1`, t.ID); err != nil {
return err
}
for _, tag := range t.Tags {
if _, err := tx.Exec(ctx,
`INSERT INTO track_tags (track_id, tag) VALUES ($1, $2)`,
t.ID, tag,
); err != nil {
return err
}
}
return tx.Commit(ctx)
}
// DeleteTrack removes a track and all child rows (cascade on FK).
func (s *PgStore) DeleteTrack(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM tracks WHERE id = $1`, id)
return err
}
// UpsertCar writes a car (insert or update).
func (s *PgStore) UpsertCar(ctx context.Context, c catalog.CarMeta) error {
_, err := s.pool.Exec(ctx, `
INSERT INTO cars (id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed,
chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
total_distance_m, total_races, total_laps, best_lap_ms,
created_ms, updated_ms)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32)
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
owner_id = EXCLUDED.owner_id,
visibility = EXCLUDED.visibility,
scale = EXCLUDED.scale,
length_mm = EXCLUDED.length_mm,
width_mm = EXCLUDED.width_mm,
height_mm = EXCLUDED.height_mm,
weight_g = EXCLUDED.weight_g,
chassis_model = EXCLUDED.chassis_model,
chassis_material = EXCLUDED.chassis_material,
chassis_printed = EXCLUDED.chassis_printed,
chassis_wheelbase_mm= EXCLUDED.chassis_wheelbase_mm,
chassis_track_mm = EXCLUDED.chassis_track_mm,
motor_kind = EXCLUDED.motor_kind,
motor_class = EXCLUDED.motor_class,
motor_kv = EXCLUDED.motor_kv,
motor_power_w = EXCLUDED.motor_power_w,
battery_voltage_v = EXCLUDED.battery_voltage_v,
battery_capacity_mah= EXCLUDED.battery_capacity_mah,
battery_cells = EXCLUDED.battery_cells,
battery_chemistry = EXCLUDED.battery_chemistry,
drive = EXCLUDED.drive,
top_speed_ms = EXCLUDED.top_speed_ms,
color_hex = EXCLUDED.color_hex,
avatar_url = EXCLUDED.avatar_url,
active = EXCLUDED.active,
total_distance_m = EXCLUDED.total_distance_m,
total_races = EXCLUDED.total_races,
total_laps = EXCLUDED.total_laps,
best_lap_ms = EXCLUDED.best_lap_ms,
updated_ms = EXCLUDED.updated_ms`,
c.ID, c.Name, c.OwnerID, string(c.Visibility), c.Scale,
c.LengthMm, c.WidthMm, c.HeightMm, c.WeightG,
c.Chassis.Model, c.Chassis.Material, c.Chassis.Printed,
c.Chassis.WheelbaseMm, c.Chassis.TrackMm,
c.Motor.Kind, c.Motor.Class, c.Motor.KV, c.Motor.PowerW,
c.Battery.VoltageV, c.Battery.CapacityMah, c.Battery.Cells, c.Battery.Chemistry,
string(c.Drive), c.TopSpeedMs, c.ColorHex, c.AvatarURL, c.Active,
c.TotalDistanceM, c.TotalRaces, c.TotalLaps, c.BestLapMs,
c.CreatedMs, c.UpdatedMs,
)
return err
}
// DeleteCar removes a car by id.
func (s *PgStore) DeleteCar(ctx context.Context, id string) error {
_, err := s.pool.Exec(ctx, `DELETE FROM cars WHERE id = $1`, id)
return err
}
// UpdateCarStats applies a partial mutation to the stats columns. We
// first read the row, run the callback, then write the mutated fields
// back. This mirrors the in-memory implementation used by tests.
func (s *PgStore) UpdateCarStats(ctx context.Context, id string, fn func(*catalog.CarMeta)) error {
tx, err := s.pool.BeginTx(ctx, pgx.TxOptions{})
if err != nil {
return err
}
defer tx.Rollback(ctx)
var c catalog.CarMeta
err = tx.QueryRow(ctx, `
SELECT id, name, owner_id, visibility, scale,
length_mm, width_mm, height_mm, weight_g,
chassis_model, chassis_material, chassis_printed,
chassis_wheelbase_mm, chassis_track_mm,
motor_kind, motor_class, motor_kv, motor_power_w,
battery_voltage_v, battery_capacity_mah, battery_cells, battery_chemistry,
drive, top_speed_ms, color_hex, avatar_url, active,
total_distance_m, total_races, total_laps, best_lap_ms,
created_ms, updated_ms
FROM cars WHERE id = $1 FOR UPDATE`, id,
).Scan(
&c.ID, &c.Name, &c.OwnerID, &c.Visibility, &c.Scale,
&c.LengthMm, &c.WidthMm, &c.HeightMm, &c.WeightG,
&c.Chassis.Model, &c.Chassis.Material, &c.Chassis.Printed,
&c.Chassis.WheelbaseMm, &c.Chassis.TrackMm,
&c.Motor.Kind, &c.Motor.Class, &c.Motor.KV, &c.Motor.PowerW,
&c.Battery.VoltageV, &c.Battery.CapacityMah, &c.Battery.Cells, &c.Battery.Chemistry,
&c.Drive, &c.TopSpeedMs, &c.ColorHex, &c.AvatarURL, &c.Active,
&c.TotalDistanceM, &c.TotalRaces, &c.TotalLaps, &c.BestLapMs,
&c.CreatedMs, &c.UpdatedMs,
)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return catalog.ErrNotFound
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return err
}
return err
}
fn(&c)
if _, err := tx.Exec(ctx, `
UPDATE cars SET
total_distance_m = $2,
total_races = $3,
total_laps = $4,
best_lap_ms = $5,
updated_ms = $6
WHERE id = $1`,
c.ID, c.TotalDistanceM, c.TotalRaces, c.TotalLaps, c.BestLapMs, c.UpdatedMs,
); err != nil {
return err
}
return tx.Commit(ctx)
}