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,65 @@
-- 010_unify_races.sql — unify finished_races and live_races into a single
-- `races` table with a 5-state status.
--
-- After this migration the `races` table holds both the live
-- surface (lobby | countdown | racing) and the finished archive
-- (finished | cancelled). live_race_drivers keeps the same shape
-- (one row per (race, driver)). The former `finished_races` columns
-- are kept verbatim; the former `live_races` columns are added
-- (started_ms is reused, created_ms was there too).
-- 1) Rename finished_races -> races.
ALTER TABLE finished_races RENAME TO races;
-- 2) Drop the old finished-only check and replace with a unified one.
ALTER TABLE races DROP CONSTRAINT IF EXISTS finished_races_status_check;
ALTER TABLE races ADD CONSTRAINT races_status_check
CHECK (status IN ('lobby', 'countdown', 'racing', 'finished', 'cancelled'));
-- 2a) Add the live-only columns to the (former) finished table.
ALTER TABLE races ADD COLUMN IF NOT EXISTS updated_ms BIGINT NOT NULL DEFAULT 0;
-- 3) Drop the old index that referenced the old table name and
-- recreate it. The new keyset index sorts on (status, finished_ms
-- DESC, id DESC) so the list view can read live+finished together
-- when needed.
DROP INDEX IF EXISTS finished_races_finished_ms_id_idx;
CREATE INDEX IF NOT EXISTS races_status_finished_idx
ON races (status, finished_ms DESC, id DESC);
DROP INDEX IF EXISTS finished_races_host_idx; -- host_id no longer exists, but the
-- index name may have survived. Defensive drop.
CREATE INDEX IF NOT EXISTS races_started_idx
ON races (status, started_ms DESC, id DESC);
-- 4) Move live rows from live_races into races BEFORE renaming
-- live_race_drivers, so the FK has somewhere to point.
INSERT INTO races
(id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms, finished_ms, updated_ms, podium)
SELECT
id, name, track_id, max_cars, laps, time_limit_s, status,
created_ms, started_ms, 0, updated_ms, '[]'::jsonb
FROM live_races
ON CONFLICT (id) DO UPDATE SET
name = EXCLUDED.name,
track_id = EXCLUDED.track_id,
max_cars = EXCLUDED.max_cars,
laps = EXCLUDED.laps,
time_limit_s = EXCLUDED.time_limit_s,
status = EXCLUDED.status,
created_ms = EXCLUDED.created_ms,
started_ms = EXCLUDED.started_ms,
updated_ms = EXCLUDED.updated_ms;
-- 5) live_race_drivers: rename to race_drivers and re-target the FK
-- at the unified races table.
ALTER TABLE live_race_drivers RENAME TO race_drivers;
ALTER TABLE race_drivers
DROP CONSTRAINT IF EXISTS live_race_drivers_race_id_fkey;
ALTER TABLE race_drivers
ADD CONSTRAINT race_drivers_race_id_fkey
FOREIGN KEY (race_id) REFERENCES races(id) ON DELETE CASCADE;
-- 6) Drop the now-redundant live tables.
DROP TABLE IF EXISTS live_race_drivers;
DROP TABLE IF EXISTS live_races;