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
+299
View File
@@ -0,0 +1,299 @@
// WebSocket catalog handlers + event broadcaster.
//
// The HTTP side lives in catalog_handlers.go. This file owns the WS side:
// - the catalogBroadcaster subscribes to catalog events and pushes
// track_snapshot / car_snapshot / catalog_summary frames to all
// connected clients via the realtime hub.
// - handleTrackWSMessage / handleCarWSMessage dispatch individual
// track_* / car_* requests from a single client.
package main
import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/x0gp/server/internal/catalog"
"github.com/x0gp/server/internal/realtime"
"github.com/x0gp/server/internal/transport"
)
// wsCtx returns a context for catalog mutations triggered from a
// WebSocket message. We deliberately use Background() because the
// per-message pgxpool query is already bounded by pgx's own
// statement_timeout / connection settings.
func wsCtx() context.Context {
return context.Background()
}
// ---------------------------------------------------------------------------
// Broadcaster
// ---------------------------------------------------------------------------
type catalogBroadcaster struct {
svc *catalog.Service
hub *Hub
log *slog.Logger
stop chan struct{}
done chan struct{}
}
func newCatalogBroadcaster(svc *catalog.Service, hub *Hub, log *slog.Logger) *catalogBroadcaster {
return &catalogBroadcaster{
svc: svc,
hub: hub,
log: log,
stop: make(chan struct{}),
done: make(chan struct{}),
}
}
func (cb *catalogBroadcaster) Run() {
defer close(cb.done)
events, cancel := cb.svc.Subscribe()
defer cancel()
for {
select {
case <-cb.stop:
return
case ev, ok := <-events:
if !ok {
return
}
cb.broadcast(ev)
}
}
}
func (cb *catalogBroadcaster) Stop() {
select {
case <-cb.stop:
return
default:
close(cb.stop)
}
<-cb.done
}
func (cb *catalogBroadcaster) broadcast(ev catalog.Event) {
switch ev.Kind {
case catalog.EventSnapshot:
// Initial subscription push; clients request via track_list/car_list.
return
case catalog.EventTrackUpsert, catalog.EventTrackDelete:
snap := cb.svc.Snapshot()
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeTrackSnapshot,
Payload: transport.TrackSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Tracks: tracksToWire(snap.Tracks),
},
})
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeCatalogSummary,
Payload: summaryToWire(cb.svc.Stats()),
})
case catalog.EventCarUpsert, catalog.EventCarDelete:
snap := cb.svc.Snapshot()
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeCarSnapshot,
Payload: transport.CarSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Cars: carsToWire(snap.Cars),
},
})
cb.hub.Publish(&transport.Envelope{
Type: transport.TypeCatalogSummary,
Payload: summaryToWire(cb.svc.Stats()),
})
}
}
// ---------------------------------------------------------------------------
// Per-message WS handlers
// ---------------------------------------------------------------------------
// handleTrackWSMessage is called from the readPump dispatch on track_* messages.
func handleTrackWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) {
payloadBytes, _ := json.Marshal(env.Payload)
switch env.Type {
case transport.TypeTrackList:
var req transport.TrackListRequest
_ = json.Unmarshal(payloadBytes, &req)
var out []catalog.TrackMeta
switch strings.ToLower(req.Scope) {
case "system":
out = svc.ListTracksByVisibility(catalog.VisibilitySystem)
case "public":
out = svc.ListTracksByVisibility(catalog.VisibilityPublic)
case "private":
out = svc.ListTracksByVisibility(catalog.VisibilityPrivate)
default:
out = svc.ListTracks()
}
if req.Tag != "" {
out = filterTracksByTag(out, req.Tag)
}
snap := svc.Snapshot()
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Tracks: tracksToWire(out),
})
case transport.TypeTrackGet:
var req transport.TrackGetRequest
_ = json.Unmarshal(payloadBytes, &req)
if req.ID == "" {
sendErrorWS(c, "track_get", "missing id")
return
}
t, ok := svc.GetTrack(req.ID)
if !ok {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: "not_found"})
return
}
sendWS(c, transport.TypeTrackSnapshot, transport.TrackSnapshot{
Tracks: []transport.TrackWire{trackToWire(t)},
})
case transport.TypeTrackCreate:
var req transport.TrackCreateRequest
_ = json.Unmarshal(payloadBytes, &req)
in := trackFromWire(req.Track)
created, err := svc.CreateTrack(wsCtx(), catalog.CreateTrackOptions{
ID: in.ID, Name: in.Name, Description: in.Description,
AuthorID: in.AuthorID, Visibility: in.Visibility, Scale: in.Scale,
LengthM: in.LengthM, WidthM: in.WidthM, LaneWidthM: in.LaneWidthM,
Surface: in.Surface, Tags: in.Tags, Centerline: in.Centerline,
})
if err != nil {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, Error: err.Error()})
return
}
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: created.ID, Track: trackToWire(created)})
case transport.TypeTrackUpdate:
var req transport.TrackUpdateRequest
_ = json.Unmarshal(payloadBytes, &req)
patch := trackPatchFromWire(req.Patch)
updated, err := svc.UpdateTrack(wsCtx(), req.ID, patch)
if err != nil {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: updated.ID, Track: trackToWire(updated)})
case transport.TypeTrackDelete:
var req transport.TrackDeleteRequest
_ = json.Unmarshal(payloadBytes, &req)
if err := svc.DeleteTrack(wsCtx(), req.ID); err != nil {
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeTrackAck, transport.TrackAck{Ok: true, ID: req.ID})
}
}
func handleCarWSMessage(c *realtime.Client, svc *catalog.Service, env *transport.Envelope) {
payloadBytes, _ := json.Marshal(env.Payload)
switch env.Type {
case transport.TypeCarList:
var req transport.CarListRequest
_ = json.Unmarshal(payloadBytes, &req)
var out []catalog.CarMeta
if req.OwnerID != "" {
out = svc.ListCarsByOwner(req.OwnerID)
} else {
switch strings.ToLower(req.Scope) {
case "system":
out = svc.ListCarsByVisibility(catalog.VisibilitySystem)
case "public":
out = svc.ListCarsByVisibility(catalog.VisibilityPublic)
case "private":
out = svc.ListCarsByVisibility(catalog.VisibilityPrivate)
default:
out = svc.ListCars()
}
}
snap := svc.Snapshot()
sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{
GeneratedMs: snap.GeneratedMs,
Version: snap.Version,
Cars: carsToWire(out),
})
case transport.TypeCarGet:
var req transport.CarGetRequest
_ = json.Unmarshal(payloadBytes, &req)
if req.ID == "" {
sendErrorWS(c, "car_get", "missing id")
return
}
car, ok := svc.GetCar(req.ID)
if !ok {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: "not_found"})
return
}
sendWS(c, transport.TypeCarSnapshot, transport.CarSnapshot{
Cars: []transport.CarWire{carToWire(car)},
})
case transport.TypeCarCreate:
var req transport.CarCreateRequest
_ = json.Unmarshal(payloadBytes, &req)
in := carFromWire(req.Car)
created, err := svc.CreateCar(wsCtx(), catalog.CreateCarOptions{
ID: in.ID, Name: in.Name, OwnerID: in.OwnerID, Visibility: in.Visibility,
Scale: in.Scale, LengthMm: in.LengthMm, WidthMm: in.WidthMm,
HeightMm: in.HeightMm, WeightG: in.WeightG, Chassis: in.Chassis,
Motor: in.Motor, Battery: in.Battery, Drive: in.Drive,
TopSpeedMs: in.TopSpeedMs, ColorHex: in.ColorHex, AvatarURL: in.AvatarURL,
})
if err != nil {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, Error: err.Error()})
return
}
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: created.ID, Car: carToWire(created)})
case transport.TypeCarUpdate:
var req transport.CarUpdateRequest
_ = json.Unmarshal(payloadBytes, &req)
patch := carPatchFromWire(req.Patch)
updated, err := svc.UpdateCar(wsCtx(), req.ID, patch)
if err != nil {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: updated.ID, Car: carToWire(updated)})
case transport.TypeCarDelete:
var req transport.CarDeleteRequest
_ = json.Unmarshal(payloadBytes, &req)
if err := svc.DeleteCar(wsCtx(), req.ID); err != nil {
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: false, ID: req.ID, Error: err.Error()})
return
}
sendWS(c, transport.TypeCarAck, transport.CarAck{Ok: true, ID: req.ID})
}
}
// ---------------------------------------------------------------------------
// Send helpers (non-blocking on full send buffer)
// ---------------------------------------------------------------------------
func sendWS(c *realtime.Client, t transport.MessageType, payload any) {
data, err := transport.Encode(&transport.Envelope{
Type: t,
TSMs: time.Now().UnixMilli(),
Payload: payload,
})
if err != nil {
return
}
select {
case c.Send <- data:
default:
// Drop if the client is too slow.
}
}
func sendErrorWS(c *realtime.Client, code, msg string) {
sendWS(c, transport.TypeError, transport.ErrorMsg{Code: code, Message: msg})
}