Files
Ilia Denisov 6d6a384bee local-dev: auto-purge terminal Dev Sandbox games on every boot
Previously a cancelled / finished / start_failed sandbox game would
hang in the dev user's lobby until manually cleaned up — `make up`
would create a new running game alongside it but the dead tiles
piled up. Now backend's `devsandbox.Bootstrap` deletes every
terminal sandbox game owned by the dev user before find-or-create
runs, so the lobby always shows exactly one running tile.

Schema: `runtime_records` and `player_mappings` gain
`ON DELETE CASCADE` on their `game_id` foreign keys so a single
`DELETE FROM games` cleans every referencing row in one write.
Pre-prod migration rule applies — change goes into
`00001_init.sql`, not a new migration.

API: `lobby.Service.DeleteGame` is the new destructive helper that
backs the bootstrap purge. It bypasses the cancel-cascade-notify
pipeline; production callers must stay on the regular lifecycle.
The dev-sandbox docs in `tools/local-dev/README.md` spell out the
new behaviour.

Tests:
- backend/internal/lobby/lobby_e2e_test.go gains
  `TestDeleteGameCascadesEverything` proving CASCADE works
  end-to-end against a real Postgres testcontainer.
- backend/internal/devsandbox keeps its existing terminal-status
  contract test; the new `purgeTerminalSandboxGames` helper rides
  on the same `terminalSandboxStatus` predicate.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 14:06:04 +02:00

1341 lines
44 KiB
Go

package lobby
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"galaxy/backend/internal/postgres/jet/backend/model"
"galaxy/backend/internal/postgres/jet/backend/table"
"github.com/go-jet/jet/v2/postgres"
"github.com/go-jet/jet/v2/qrm"
"github.com/google/uuid"
)
// Constraint names mirror the names declared in
// `backend/internal/postgres/migrations/00001_init.sql`. Keeping them as
// constants keeps error classification robust against typos.
const (
constraintMembershipsGameUserUnique = "memberships_game_user_unique"
constraintApplicationsActiveUnique = "applications_active_per_user_game_uidx"
constraintInvitesCodeUnique = "invites_code_uidx"
constraintRaceNamesPK = "race_names_pkey"
constraintRaceNamesRegisteredUnique = "race_names_registered_uidx"
)
// Store is the Postgres-backed query surface for the lobby package. All
// queries are built through go-jet against the generated table bindings
// under `backend/internal/postgres/jet/backend/table`.
type Store struct {
db *sql.DB
}
// NewStore constructs a Store wrapping db.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// gameColumns is the canonical projection for game reads.
func gameColumns() postgres.ColumnList {
g := table.Games
return postgres.ColumnList{
g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName, g.Description,
g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers,
g.EnrollmentEndsAt, g.TurnSchedule, g.TargetEngineVersion,
g.RuntimeSnapshot, g.CreatedAt, g.UpdatedAt, g.StartedAt, g.FinishedAt,
}
}
// applicationColumns is the canonical projection for application reads.
func applicationColumns() postgres.ColumnList {
a := table.Applications
return postgres.ColumnList{
a.ApplicationID, a.GameID, a.ApplicantUserID, a.RaceName, a.Status,
a.CreatedAt, a.DecidedAt,
}
}
// inviteColumns is the canonical projection for invite reads.
func inviteColumns() postgres.ColumnList {
i := table.Invites
return postgres.ColumnList{
i.InviteID, i.GameID, i.InviterUserID, i.InvitedUserID, i.Code, i.Status,
i.RaceName, i.CreatedAt, i.ExpiresAt, i.DecidedAt,
}
}
// membershipColumns is the canonical projection for membership reads.
func membershipColumns() postgres.ColumnList {
m := table.Memberships
return postgres.ColumnList{
m.MembershipID, m.GameID, m.UserID, m.RaceName, m.CanonicalKey, m.Status,
m.JoinedAt, m.RemovedAt,
}
}
// raceNameColumns is the canonical projection for race-name reads.
func raceNameColumns() postgres.ColumnList {
r := table.RaceNames
return postgres.ColumnList{
r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID,
r.ReservedAt, r.ExpiresAt, r.RegisteredAt,
}
}
// gameInsert is the parameter struct for InsertGame.
type gameInsert struct {
GameID uuid.UUID
OwnerUserID *uuid.UUID
Visibility string
GameName string
Description string
MinPlayers int32
MaxPlayers int32
StartGapHours int32
StartGapPlayers int32
EnrollmentEndsAt time.Time
TurnSchedule string
TargetEngineVersion string
}
// InsertGame persists a brand-new draft game record together with an
// empty runtime snapshot.
func (s *Store) InsertGame(ctx context.Context, in gameInsert) (GameRecord, error) {
emptySnapshot, err := json.Marshal(RuntimeSnapshot{})
if err != nil {
return GameRecord{}, fmt.Errorf("lobby store: marshal empty snapshot: %w", err)
}
g := table.Games
stmt := g.INSERT(
g.GameID, g.OwnerUserID, g.Visibility, g.Status, g.GameName, g.Description,
g.MinPlayers, g.MaxPlayers, g.StartGapHours, g.StartGapPlayers,
g.EnrollmentEndsAt, g.TurnSchedule, g.TargetEngineVersion,
g.RuntimeSnapshot,
).VALUES(
in.GameID, ownerArg(in.OwnerUserID), in.Visibility, GameStatusDraft,
in.GameName, in.Description,
in.MinPlayers, in.MaxPlayers, in.StartGapHours, in.StartGapPlayers,
in.EnrollmentEndsAt, in.TurnSchedule, in.TargetEngineVersion,
string(emptySnapshot),
).RETURNING(gameColumns())
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
return GameRecord{}, fmt.Errorf("lobby store: insert game: %w", err)
}
return modelToGameRecord(row)
}
// LoadGame returns the game record for gameID. Returns ErrNotFound when
// no row matches.
func (s *Store) LoadGame(ctx context.Context, gameID uuid.UUID) (GameRecord, error) {
g := table.Games
stmt := postgres.SELECT(gameColumns()).
FROM(g).
WHERE(g.GameID.EQ(postgres.UUID(gameID))).
LIMIT(1)
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return GameRecord{}, ErrNotFound
}
return GameRecord{}, fmt.Errorf("lobby store: load game %s: %w", gameID, err)
}
return modelToGameRecord(row)
}
// ListPublicGames returns the requested page of public games together
// with the total count for pagination.
func (s *Store) ListPublicGames(ctx context.Context, page, pageSize int) ([]GameRecord, int, error) {
g := table.Games
totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
FROM(g).
WHERE(g.Visibility.EQ(postgres.String(VisibilityPublic)))
var totalDest struct {
Count int64 `alias:"count"`
}
if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil {
return nil, 0, fmt.Errorf("lobby store: count public games: %w", err)
}
offset := (page - 1) * pageSize
listStmt := postgres.SELECT(gameColumns()).
FROM(g).
WHERE(g.Visibility.EQ(postgres.String(VisibilityPublic))).
ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC()).
LIMIT(int64(pageSize)).OFFSET(int64(offset))
var rows []model.Games
if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, 0, fmt.Errorf("lobby store: list public games: %w", err)
}
games, err := modelsToGameRecords(rows)
if err != nil {
return nil, 0, err
}
return games, int(totalDest.Count), nil
}
// ListAllGames returns every game row, used by Cache.Warm at startup.
func (s *Store) ListAllGames(ctx context.Context) ([]GameRecord, error) {
stmt := postgres.SELECT(gameColumns()).FROM(table.Games)
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list all games: %w", err)
}
return modelsToGameRecords(rows)
}
// ListAdminGames returns the requested page of every game (admin view)
// together with the total count.
func (s *Store) ListAdminGames(ctx context.Context, page, pageSize int) ([]GameRecord, int, error) {
g := table.Games
totalStmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).FROM(g)
var totalDest struct {
Count int64 `alias:"count"`
}
if err := totalStmt.QueryContext(ctx, s.db, &totalDest); err != nil {
return nil, 0, fmt.Errorf("lobby store: count games: %w", err)
}
offset := (page - 1) * pageSize
listStmt := postgres.SELECT(gameColumns()).
FROM(g).
ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC()).
LIMIT(int64(pageSize)).OFFSET(int64(offset))
var rows []model.Games
if err := listStmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, 0, fmt.Errorf("lobby store: list admin games: %w", err)
}
games, err := modelsToGameRecords(rows)
if err != nil {
return nil, 0, err
}
return games, int(totalDest.Count), nil
}
// ListMyGames returns every game where userID has an active membership,
// ordered by created_at DESC.
func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord, error) {
g := table.Games
m := table.Memberships
stmt := postgres.SELECT(gameColumns()).
FROM(g.INNER_JOIN(m, m.GameID.EQ(g.GameID))).
WHERE(
m.UserID.EQ(postgres.UUID(userID)).
AND(m.Status.EQ(postgres.String(MembershipStatusActive))),
).
ORDER_BY(g.CreatedAt.DESC(), g.GameID.DESC())
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list my games: %w", err)
}
return modelsToGameRecords(rows)
}
// DeleteGame removes the row at gameID. Cascades through every
// referencing table (memberships / applications / invites /
// runtime_records / player_mappings — all declared with ON DELETE
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
// matches. Used by the dev-sandbox bootstrap to scrub terminal
// games on every backend boot so the developer's lobby never piles
// up cancelled tiles.
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
g := table.Games
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("lobby store: delete game %s: %w", gameID, err)
}
return nil
}
// gameUpdate is the parameter struct for UpdateGame. Nil pointers leave
// the corresponding column alone.
type gameUpdate struct {
GameName *string
Description *string
EnrollmentEndsAt *time.Time
TurnSchedule *string
TargetEngineVersion *string
MinPlayers *int32
MaxPlayers *int32
StartGapHours *int32
StartGapPlayers *int32
}
func (u gameUpdate) empty() bool {
return u.GameName == nil && u.Description == nil && u.EnrollmentEndsAt == nil &&
u.TurnSchedule == nil && u.TargetEngineVersion == nil &&
u.MinPlayers == nil && u.MaxPlayers == nil &&
u.StartGapHours == nil && u.StartGapPlayers == nil
}
// UpdateGame patches the supplied columns and bumps updated_at. Returns
// ErrNotFound when no row matches.
func (s *Store) UpdateGame(ctx context.Context, gameID uuid.UUID, patch gameUpdate, now time.Time) (GameRecord, error) {
if patch.empty() {
return s.LoadGame(ctx, gameID)
}
g := table.Games
rest := []any{}
if patch.GameName != nil {
rest = append(rest, g.GameName.SET(postgres.String(*patch.GameName)))
}
if patch.Description != nil {
rest = append(rest, g.Description.SET(postgres.String(*patch.Description)))
}
if patch.EnrollmentEndsAt != nil {
rest = append(rest, g.EnrollmentEndsAt.SET(postgres.TimestampzT(*patch.EnrollmentEndsAt)))
}
if patch.TurnSchedule != nil {
rest = append(rest, g.TurnSchedule.SET(postgres.String(*patch.TurnSchedule)))
}
if patch.TargetEngineVersion != nil {
rest = append(rest, g.TargetEngineVersion.SET(postgres.String(*patch.TargetEngineVersion)))
}
if patch.MinPlayers != nil {
rest = append(rest, g.MinPlayers.SET(postgres.Int(int64(*patch.MinPlayers))))
}
if patch.MaxPlayers != nil {
rest = append(rest, g.MaxPlayers.SET(postgres.Int(int64(*patch.MaxPlayers))))
}
if patch.StartGapHours != nil {
rest = append(rest, g.StartGapHours.SET(postgres.Int(int64(*patch.StartGapHours))))
}
if patch.StartGapPlayers != nil {
rest = append(rest, g.StartGapPlayers.SET(postgres.Int(int64(*patch.StartGapPlayers))))
}
stmt := g.UPDATE().
SET(g.UpdatedAt.SET(postgres.TimestampzT(now)), rest...).
WHERE(g.GameID.EQ(postgres.UUID(gameID))).
RETURNING(gameColumns())
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return GameRecord{}, ErrNotFound
}
return GameRecord{}, fmt.Errorf("lobby store: update game %s: %w", gameID, err)
}
return modelToGameRecord(row)
}
// statusUpdate carries the parameters for UpdateGameStatus. SetStarted
// /ClearStarted/SetFinished are mutually-exclusive flags driving the
// timestamp columns.
type statusUpdate struct {
NewStatus string
UpdatedAt time.Time
SetStarted bool
StartedAt time.Time
SetFinished bool
FinishedAt time.Time
ClearStarted bool
}
// UpdateGameStatus transitions status and (optionally) updates the
// started_at / finished_at columns. Returns the refreshed row.
func (s *Store) UpdateGameStatus(ctx context.Context, gameID uuid.UUID, in statusUpdate) (GameRecord, error) {
g := table.Games
rest := []any{}
switch {
case in.SetStarted:
rest = append(rest, g.StartedAt.SET(postgres.TimestampzT(in.StartedAt)))
case in.ClearStarted:
rest = append(rest, g.StartedAt.SET(postgres.TimestampzExp(postgres.NULL)))
}
if in.SetFinished {
rest = append(rest, g.FinishedAt.SET(postgres.TimestampzT(in.FinishedAt)))
}
stmt := g.UPDATE().
SET(
g.Status.SET(postgres.String(in.NewStatus)),
append([]any{g.UpdatedAt.SET(postgres.TimestampzT(in.UpdatedAt))}, rest...)...,
).
WHERE(g.GameID.EQ(postgres.UUID(gameID))).
RETURNING(gameColumns())
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return GameRecord{}, ErrNotFound
}
return GameRecord{}, fmt.Errorf("lobby store: update game status %s: %w", gameID, err)
}
return modelToGameRecord(row)
}
// UpdateGameRuntimeSnapshot replaces the JSON-encoded runtime snapshot
// for gameID. Used by `OnRuntimeSnapshot` and the per-event hooks.
func (s *Store) UpdateGameRuntimeSnapshot(ctx context.Context, gameID uuid.UUID, snapshot RuntimeSnapshot, now time.Time) (GameRecord, error) {
encoded, err := json.Marshal(snapshot)
if err != nil {
return GameRecord{}, fmt.Errorf("lobby store: marshal snapshot: %w", err)
}
g := table.Games
stmt := g.UPDATE().
SET(
g.RuntimeSnapshot.SET(postgres.StringExp(postgres.CAST(postgres.String(string(encoded))).AS("jsonb"))),
g.UpdatedAt.SET(postgres.TimestampzT(now)),
).
WHERE(g.GameID.EQ(postgres.UUID(gameID))).
RETURNING(gameColumns())
var row model.Games
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return GameRecord{}, ErrNotFound
}
return GameRecord{}, fmt.Errorf("lobby store: update runtime snapshot %s: %w", gameID, err)
}
return modelToGameRecord(row)
}
// CountActiveMemberships returns the number of memberships in `active`
// status for gameID. Drives `approved_count >= min_players` checks.
func (s *Store) CountActiveMemberships(ctx context.Context, gameID uuid.UUID) (int, error) {
m := table.Memberships
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
FROM(m).
WHERE(
m.GameID.EQ(postgres.UUID(gameID)).
AND(m.Status.EQ(postgres.String(MembershipStatusActive))),
)
var dest struct {
Count int64 `alias:"count"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("lobby store: count active memberships %s: %w", gameID, err)
}
return int(dest.Count), nil
}
// applicationInsert carries the parameters for InsertApplication.
type applicationInsert struct {
ApplicationID uuid.UUID
GameID uuid.UUID
ApplicantUserID uuid.UUID
RaceName string
}
// InsertApplication creates a fresh `pending` application. Returns
// ErrConflict on the partial UNIQUE violation against the per-user
// per-game active constraint.
func (s *Store) InsertApplication(ctx context.Context, in applicationInsert) (Application, error) {
a := table.Applications
stmt := a.INSERT(
a.ApplicationID, a.GameID, a.ApplicantUserID, a.RaceName, a.Status,
).VALUES(
in.ApplicationID, in.GameID, in.ApplicantUserID, in.RaceName, ApplicationStatusPending,
).RETURNING(applicationColumns())
var row model.Applications
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if isUniqueViolation(err, constraintApplicationsActiveUnique) {
return Application{}, fmt.Errorf("%w: application already exists for this user", ErrConflict)
}
return Application{}, fmt.Errorf("lobby store: insert application: %w", err)
}
return modelToApplication(row), nil
}
// LoadApplication returns the application for applicationID.
func (s *Store) LoadApplication(ctx context.Context, applicationID uuid.UUID) (Application, error) {
a := table.Applications
stmt := postgres.SELECT(applicationColumns()).
FROM(a).
WHERE(a.ApplicationID.EQ(postgres.UUID(applicationID))).
LIMIT(1)
var row model.Applications
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Application{}, ErrNotFound
}
return Application{}, fmt.Errorf("lobby store: load application %s: %w", applicationID, err)
}
return modelToApplication(row), nil
}
// UpdateApplicationStatus patches status and decided_at; returns the
// refreshed row.
func (s *Store) UpdateApplicationStatus(ctx context.Context, applicationID uuid.UUID, status string, decidedAt time.Time) (Application, error) {
a := table.Applications
stmt := a.UPDATE().
SET(
a.Status.SET(postgres.String(status)),
a.DecidedAt.SET(postgres.TimestampzT(decidedAt)),
).
WHERE(a.ApplicationID.EQ(postgres.UUID(applicationID))).
RETURNING(applicationColumns())
var row model.Applications
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Application{}, ErrNotFound
}
return Application{}, fmt.Errorf("lobby store: update application status %s: %w", applicationID, err)
}
return modelToApplication(row), nil
}
// ListApplicationsForGame returns every application for gameID ordered
// by created_at ASC.
func (s *Store) ListApplicationsForGame(ctx context.Context, gameID uuid.UUID) ([]Application, error) {
a := table.Applications
stmt := postgres.SELECT(applicationColumns()).
FROM(a).
WHERE(a.GameID.EQ(postgres.UUID(gameID))).
ORDER_BY(a.CreatedAt.ASC())
var rows []model.Applications
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list applications for game %s: %w", gameID, err)
}
out := make([]Application, 0, len(rows))
for _, row := range rows {
out = append(out, modelToApplication(row))
}
return out, nil
}
// ListMyApplications returns every application owned by userID.
func (s *Store) ListMyApplications(ctx context.Context, userID uuid.UUID) ([]Application, error) {
a := table.Applications
stmt := postgres.SELECT(applicationColumns()).
FROM(a).
WHERE(a.ApplicantUserID.EQ(postgres.UUID(userID))).
ORDER_BY(a.CreatedAt.DESC())
var rows []model.Applications
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list my applications: %w", err)
}
out := make([]Application, 0, len(rows))
for _, row := range rows {
out = append(out, modelToApplication(row))
}
return out, nil
}
// inviteInsert carries the parameters for InsertInvite.
type inviteInsert struct {
InviteID uuid.UUID
GameID uuid.UUID
InviterUserID uuid.UUID
InvitedUserID *uuid.UUID
Code string
RaceName string
ExpiresAt time.Time
}
// InsertInvite creates a fresh `pending` invite.
func (s *Store) InsertInvite(ctx context.Context, in inviteInsert) (Invite, error) {
i := table.Invites
stmt := i.INSERT(
i.InviteID, i.GameID, i.InviterUserID, i.InvitedUserID, i.Code,
i.Status, i.RaceName, i.ExpiresAt,
).VALUES(
in.InviteID, in.GameID, in.InviterUserID, invitedArg(in.InvitedUserID), codeArg(in.Code),
InviteStatusPending, in.RaceName, in.ExpiresAt,
).RETURNING(inviteColumns())
var row model.Invites
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if isUniqueViolation(err, constraintInvitesCodeUnique) {
return Invite{}, fmt.Errorf("%w: invite code collision", ErrConflict)
}
return Invite{}, fmt.Errorf("lobby store: insert invite: %w", err)
}
return modelToInvite(row), nil
}
// LoadInvite returns the invite for inviteID.
func (s *Store) LoadInvite(ctx context.Context, inviteID uuid.UUID) (Invite, error) {
i := table.Invites
stmt := postgres.SELECT(inviteColumns()).
FROM(i).
WHERE(i.InviteID.EQ(postgres.UUID(inviteID))).
LIMIT(1)
var row model.Invites
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Invite{}, ErrNotFound
}
return Invite{}, fmt.Errorf("lobby store: load invite %s: %w", inviteID, err)
}
return modelToInvite(row), nil
}
// UpdateInviteStatus patches status and decided_at.
func (s *Store) UpdateInviteStatus(ctx context.Context, inviteID uuid.UUID, status string, decidedAt time.Time) (Invite, error) {
i := table.Invites
stmt := i.UPDATE().
SET(
i.Status.SET(postgres.String(status)),
i.DecidedAt.SET(postgres.TimestampzT(decidedAt)),
).
WHERE(i.InviteID.EQ(postgres.UUID(inviteID))).
RETURNING(inviteColumns())
var row model.Invites
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Invite{}, ErrNotFound
}
return Invite{}, fmt.Errorf("lobby store: update invite status %s: %w", inviteID, err)
}
return modelToInvite(row), nil
}
// ListInvitesForGame returns every invite for gameID ordered by
// created_at ASC.
func (s *Store) ListInvitesForGame(ctx context.Context, gameID uuid.UUID) ([]Invite, error) {
i := table.Invites
stmt := postgres.SELECT(inviteColumns()).
FROM(i).
WHERE(i.GameID.EQ(postgres.UUID(gameID))).
ORDER_BY(i.CreatedAt.ASC())
var rows []model.Invites
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list invites for game %s: %w", gameID, err)
}
out := make([]Invite, 0, len(rows))
for _, row := range rows {
out = append(out, modelToInvite(row))
}
return out, nil
}
// ListMyInvites returns every invite for which userID is the recipient.
func (s *Store) ListMyInvites(ctx context.Context, userID uuid.UUID) ([]Invite, error) {
i := table.Invites
stmt := postgres.SELECT(inviteColumns()).
FROM(i).
WHERE(i.InvitedUserID.EQ(postgres.UUID(userID))).
ORDER_BY(i.CreatedAt.DESC())
var rows []model.Invites
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list my invites: %w", err)
}
out := make([]Invite, 0, len(rows))
for _, row := range rows {
out = append(out, modelToInvite(row))
}
return out, nil
}
// membershipInsert carries the parameters for InsertMembership.
type membershipInsert struct {
MembershipID uuid.UUID
GameID uuid.UUID
UserID uuid.UUID
RaceName string
CanonicalKey CanonicalKey
}
// InsertMembership creates an `active` membership row. Returns
// ErrConflict on the per-game UNIQUE collision (user already a member).
func (s *Store) InsertMembership(ctx context.Context, in membershipInsert) (Membership, error) {
m := table.Memberships
stmt := m.INSERT(
m.MembershipID, m.GameID, m.UserID, m.RaceName, m.CanonicalKey, m.Status,
).VALUES(
in.MembershipID, in.GameID, in.UserID, in.RaceName, string(in.CanonicalKey), MembershipStatusActive,
).RETURNING(membershipColumns())
var row model.Memberships
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if isUniqueViolation(err, constraintMembershipsGameUserUnique) {
return Membership{}, fmt.Errorf("%w: user already a member of this game", ErrConflict)
}
return Membership{}, fmt.Errorf("lobby store: insert membership: %w", err)
}
return modelToMembership(row), nil
}
// LoadMembership returns the membership for membershipID.
func (s *Store) LoadMembership(ctx context.Context, membershipID uuid.UUID) (Membership, error) {
m := table.Memberships
stmt := postgres.SELECT(membershipColumns()).
FROM(m).
WHERE(m.MembershipID.EQ(postgres.UUID(membershipID))).
LIMIT(1)
var row model.Memberships
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Membership{}, ErrNotFound
}
return Membership{}, fmt.Errorf("lobby store: load membership %s: %w", membershipID, err)
}
return modelToMembership(row), nil
}
// UpdateMembershipStatus patches status (and removed_at when removing or
// blocking).
func (s *Store) UpdateMembershipStatus(ctx context.Context, membershipID uuid.UUID, status string, removedAt time.Time) (Membership, error) {
m := table.Memberships
var removedExpr postgres.TimestampzExpression
if status != MembershipStatusActive {
removedExpr = postgres.TimestampzT(removedAt)
} else {
removedExpr = postgres.TimestampzExp(postgres.NULL)
}
stmt := m.UPDATE().
SET(
m.Status.SET(postgres.String(status)),
m.RemovedAt.SET(removedExpr),
).
WHERE(m.MembershipID.EQ(postgres.UUID(membershipID))).
RETURNING(membershipColumns())
var row model.Memberships
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return Membership{}, ErrNotFound
}
return Membership{}, fmt.Errorf("lobby store: update membership status %s: %w", membershipID, err)
}
return modelToMembership(row), nil
}
// ListMembershipsForGame returns every membership row for gameID
// ordered by joined_at ASC.
func (s *Store) ListMembershipsForGame(ctx context.Context, gameID uuid.UUID) ([]Membership, error) {
m := table.Memberships
stmt := postgres.SELECT(membershipColumns()).
FROM(m).
WHERE(m.GameID.EQ(postgres.UUID(gameID))).
ORDER_BY(m.JoinedAt.ASC())
var rows []model.Memberships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list memberships for game %s: %w", gameID, err)
}
out := make([]Membership, 0, len(rows))
for _, row := range rows {
out = append(out, modelToMembership(row))
}
return out, nil
}
// ListAllMemberships returns every membership row, used by Cache.Warm.
func (s *Store) ListAllMemberships(ctx context.Context) ([]Membership, error) {
stmt := postgres.SELECT(membershipColumns()).FROM(table.Memberships)
var rows []model.Memberships
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list all memberships: %w", err)
}
out := make([]Membership, 0, len(rows))
for _, row := range rows {
out = append(out, modelToMembership(row))
}
return out, nil
}
// raceNameInsert carries the parameters for InsertRaceName.
type raceNameInsert struct {
Name string
Canonical CanonicalKey
Status string
OwnerUserID uuid.UUID
GameID uuid.UUID
SourceGameID *uuid.UUID
ReservedAt *time.Time
ExpiresAt *time.Time
RegisteredAt *time.Time
}
// InsertRaceName creates a fresh row in `race_names`. Returns
// ErrConflict on either UNIQUE violation (registered uniqueness or
// composite PK).
func (s *Store) InsertRaceName(ctx context.Context, in raceNameInsert) (RaceNameEntry, error) {
r := table.RaceNames
stmt := r.INSERT(
r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID,
r.ReservedAt, r.ExpiresAt, r.RegisteredAt,
).VALUES(
in.Name, string(in.Canonical), in.Status, in.OwnerUserID,
in.GameID, sourceGameArg(in.SourceGameID),
timePtrArg(in.ReservedAt), timePtrArg(in.ExpiresAt), timePtrArg(in.RegisteredAt),
).RETURNING(raceNameColumns())
var row model.RaceNames
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
switch {
case isUniqueViolation(err, constraintRaceNamesPK):
return RaceNameEntry{}, fmt.Errorf("%w: race name already bound to this game", ErrRaceNameTaken)
case isUniqueViolation(err, constraintRaceNamesRegisteredUnique):
return RaceNameEntry{}, fmt.Errorf("%w: race name is already registered", ErrRaceNameTaken)
}
return RaceNameEntry{}, fmt.Errorf("lobby store: insert race_name: %w", err)
}
return modelToRaceName(row), nil
}
// FindRaceNameByCanonical returns every row matching canonical.
func (s *Store) FindRaceNameByCanonical(ctx context.Context, canonical CanonicalKey) ([]RaceNameEntry, error) {
r := table.RaceNames
stmt := postgres.SELECT(raceNameColumns()).
FROM(r).
WHERE(r.Canonical.EQ(postgres.String(string(canonical))))
var rows []model.RaceNames
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: find race name by canonical: %w", err)
}
out := make([]RaceNameEntry, 0, len(rows))
for _, row := range rows {
out = append(out, modelToRaceName(row))
}
return out, nil
}
// FindRaceNameByCanonicalAndGame returns the row matching canonical
// inside game (or the registered sentinel game).
func (s *Store) FindRaceNameByCanonicalAndGame(ctx context.Context, canonical CanonicalKey, gameID uuid.UUID) (RaceNameEntry, error) {
r := table.RaceNames
stmt := postgres.SELECT(raceNameColumns()).
FROM(r).
WHERE(
r.Canonical.EQ(postgres.String(string(canonical))).
AND(r.GameID.EQ(postgres.UUID(gameID))),
).
LIMIT(1)
var row model.RaceNames
if err := stmt.QueryContext(ctx, s.db, &row); err != nil {
if errors.Is(err, qrm.ErrNoRows) {
return RaceNameEntry{}, ErrNotFound
}
return RaceNameEntry{}, fmt.Errorf("lobby store: find race name: %w", err)
}
return modelToRaceName(row), nil
}
// ListRaceNamesForUser returns every race-name row owned by userID
// across all statuses.
func (s *Store) ListRaceNamesForUser(ctx context.Context, userID uuid.UUID) ([]RaceNameEntry, error) {
r := table.RaceNames
stmt := postgres.SELECT(raceNameColumns()).
FROM(r).
WHERE(r.OwnerUserID.EQ(postgres.UUID(userID))).
ORDER_BY(r.Status.ASC(), r.Canonical.ASC())
var rows []model.RaceNames
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list race names for user %s: %w", userID, err)
}
out := make([]RaceNameEntry, 0, len(rows))
for _, row := range rows {
out = append(out, modelToRaceName(row))
}
return out, nil
}
// ListAllRaceNames returns every race-name row, used by Cache.Warm.
func (s *Store) ListAllRaceNames(ctx context.Context) ([]RaceNameEntry, error) {
stmt := postgres.SELECT(raceNameColumns()).FROM(table.RaceNames)
var rows []model.RaceNames
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list all race names: %w", err)
}
out := make([]RaceNameEntry, 0, len(rows))
for _, row := range rows {
out = append(out, modelToRaceName(row))
}
return out, nil
}
// CountRegisteredRaceNamesByUser returns the number of registered rows
// owned by userID. Drives the entitlement quota check at register-time.
func (s *Store) CountRegisteredRaceNamesByUser(ctx context.Context, userID uuid.UUID) (int, error) {
r := table.RaceNames
stmt := postgres.SELECT(postgres.COUNT(postgres.STAR).AS("count")).
FROM(r).
WHERE(
r.OwnerUserID.EQ(postgres.UUID(userID)).
AND(r.Status.EQ(postgres.String(RaceNameStatusRegistered))),
)
var dest struct {
Count int64 `alias:"count"`
}
if err := stmt.QueryContext(ctx, s.db, &dest); err != nil {
return 0, fmt.Errorf("lobby store: count registered race names: %w", err)
}
return int(dest.Count), nil
}
// DeleteRaceName removes the row at (canonical, gameID).
func (s *Store) DeleteRaceName(ctx context.Context, canonical CanonicalKey, gameID uuid.UUID) error {
r := table.RaceNames
stmt := r.DELETE().
WHERE(
r.Canonical.EQ(postgres.String(string(canonical))).
AND(r.GameID.EQ(postgres.UUID(gameID))),
)
if _, err := stmt.ExecContext(ctx, s.db); err != nil {
return fmt.Errorf("lobby store: delete race name: %w", err)
}
return nil
}
// PromotePendingToRegistered promotes a pending row to registered in one
// transaction: deletes the (canonical, originGameID) reservation/pending
// row and inserts the registered row keyed by the sentinel game_id.
func (s *Store) PromotePendingToRegistered(ctx context.Context, canonical CanonicalKey, ownerUserID, originGameID uuid.UUID, name string, now time.Time) (RaceNameEntry, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return RaceNameEntry{}, fmt.Errorf("lobby store: begin promote tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
r := table.RaceNames
deleteStmt := r.DELETE().
WHERE(
r.Canonical.EQ(postgres.String(string(canonical))).
AND(r.GameID.EQ(postgres.UUID(originGameID))).
AND(r.OwnerUserID.EQ(postgres.UUID(ownerUserID))).
AND(r.Status.EQ(postgres.String(RaceNameStatusPendingRegistration))),
)
res, err := deleteStmt.ExecContext(ctx, tx)
if err != nil {
return RaceNameEntry{}, fmt.Errorf("lobby store: delete pending: %w", err)
}
if affected, _ := res.RowsAffected(); affected == 0 {
return RaceNameEntry{}, ErrNotFound
}
insertStmt := r.INSERT(
r.Name, r.Canonical, r.Status, r.OwnerUserID, r.GameID, r.SourceGameID, r.RegisteredAt,
).VALUES(
name, string(canonical), RaceNameStatusRegistered, ownerUserID,
raceNameRegisteredGameSentinel, originGameID, now,
).RETURNING(raceNameColumns())
var row model.RaceNames
if err := insertStmt.QueryContext(ctx, tx, &row); err != nil {
if isUniqueViolation(err, constraintRaceNamesRegisteredUnique) {
return RaceNameEntry{}, fmt.Errorf("%w: race name already registered", ErrRaceNameTaken)
}
return RaceNameEntry{}, fmt.Errorf("lobby store: insert registered: %w", err)
}
if err := tx.Commit(); err != nil {
return RaceNameEntry{}, fmt.Errorf("lobby store: commit promote tx: %w", err)
}
return modelToRaceName(row), nil
}
// ListPendingRegistrationsExpired returns every pending_registration
// row with expires_at <= now. The sweeper consumes the result.
func (s *Store) ListPendingRegistrationsExpired(ctx context.Context, now time.Time) ([]RaceNameEntry, error) {
r := table.RaceNames
stmt := postgres.SELECT(raceNameColumns()).
FROM(r).
WHERE(
r.Status.EQ(postgres.String(RaceNameStatusPendingRegistration)).
AND(r.ExpiresAt.IS_NOT_NULL()).
AND(r.ExpiresAt.LT_EQ(postgres.TimestampzT(now))),
)
var rows []model.RaceNames
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list expired pending: %w", err)
}
out := make([]RaceNameEntry, 0, len(rows))
for _, row := range rows {
out = append(out, modelToRaceName(row))
}
return out, nil
}
// ListEnrollmentExpiredGames returns every game in `enrollment_open`
// status whose enrollment_ends_at has passed `now`. The sweeper uses
// the result to drive the auto-close transition.
func (s *Store) ListEnrollmentExpiredGames(ctx context.Context, now time.Time) ([]GameRecord, error) {
g := table.Games
stmt := postgres.SELECT(gameColumns()).
FROM(g).
WHERE(
g.Status.EQ(postgres.String(GameStatusEnrollmentOpen)).
AND(g.EnrollmentEndsAt.LT_EQ(postgres.TimestampzT(now))),
)
var rows []model.Games
if err := stmt.QueryContext(ctx, s.db, &rows); err != nil {
return nil, fmt.Errorf("lobby store: list expired enrollment games: %w", err)
}
return modelsToGameRecords(rows)
}
// CascadeUserSnapshot returns every state needed by `OnUserBlocked` /
// `OnUserDeleted` in a single read so the cascade transaction does not
// need additional round-trips.
type CascadeUserSnapshot struct {
OwnedGameIDs []uuid.UUID
ActiveMembershipIDs []uuid.UUID
PendingApplications []uuid.UUID
IncomingInvites []uuid.UUID
OutgoingInvites []uuid.UUID
RaceNameKeys []raceNameRef
}
type raceNameRef struct {
Canonical CanonicalKey
GameID uuid.UUID
}
// LoadCascadeSnapshot reads the per-user state for the cascade flow.
func (s *Store) LoadCascadeSnapshot(ctx context.Context, userID uuid.UUID) (CascadeUserSnapshot, error) {
var snap CascadeUserSnapshot
gamesStmt := postgres.SELECT(table.Games.GameID).
FROM(table.Games).
WHERE(table.Games.OwnerUserID.EQ(postgres.UUID(userID)))
if err := loadIDColumn(ctx, s.db, gamesStmt, &snap.OwnedGameIDs); err != nil {
return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: owned games: %w", err)
}
memStmt := postgres.SELECT(table.Memberships.MembershipID).
FROM(table.Memberships).
WHERE(
table.Memberships.UserID.EQ(postgres.UUID(userID)).
AND(table.Memberships.Status.EQ(postgres.String(MembershipStatusActive))),
)
if err := loadIDColumn(ctx, s.db, memStmt, &snap.ActiveMembershipIDs); err != nil {
return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: memberships: %w", err)
}
appStmt := postgres.SELECT(table.Applications.ApplicationID).
FROM(table.Applications).
WHERE(
table.Applications.ApplicantUserID.EQ(postgres.UUID(userID)).
AND(table.Applications.Status.EQ(postgres.String(ApplicationStatusPending))),
)
if err := loadIDColumn(ctx, s.db, appStmt, &snap.PendingApplications); err != nil {
return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: applications: %w", err)
}
inStmt := postgres.SELECT(table.Invites.InviteID).
FROM(table.Invites).
WHERE(
table.Invites.InvitedUserID.EQ(postgres.UUID(userID)).
AND(table.Invites.Status.EQ(postgres.String(InviteStatusPending))),
)
if err := loadIDColumn(ctx, s.db, inStmt, &snap.IncomingInvites); err != nil {
return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: incoming invites: %w", err)
}
outStmt := postgres.SELECT(table.Invites.InviteID).
FROM(table.Invites).
WHERE(
table.Invites.InviterUserID.EQ(postgres.UUID(userID)).
AND(table.Invites.Status.EQ(postgres.String(InviteStatusPending))),
)
if err := loadIDColumn(ctx, s.db, outStmt, &snap.OutgoingInvites); err != nil {
return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: outgoing invites: %w", err)
}
rnStmt := postgres.SELECT(table.RaceNames.Canonical, table.RaceNames.GameID).
FROM(table.RaceNames).
WHERE(table.RaceNames.OwnerUserID.EQ(postgres.UUID(userID)))
var rnRows []model.RaceNames
if err := rnStmt.QueryContext(ctx, s.db, &rnRows); err != nil {
return CascadeUserSnapshot{}, fmt.Errorf("cascade snapshot: race names: %w", err)
}
for _, row := range rnRows {
snap.RaceNameKeys = append(snap.RaceNameKeys, raceNameRef{
Canonical: CanonicalKey(row.Canonical),
GameID: row.GameID,
})
}
return snap, nil
}
// CascadeUser applies the cascade writes captured in snapshot inside a
// single transaction.
func (s *Store) CascadeUser(ctx context.Context, userID uuid.UUID, snap CascadeUserSnapshot, membershipStatus string, now time.Time) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("cascade user: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if len(snap.ActiveMembershipIDs) > 0 {
m := table.Memberships
stmt := m.UPDATE().
SET(
m.Status.SET(postgres.String(membershipStatus)),
m.RemovedAt.SET(postgres.TimestampzT(now)),
).
WHERE(
m.UserID.EQ(postgres.UUID(userID)).
AND(m.Status.EQ(postgres.String(MembershipStatusActive))),
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("cascade user: update memberships: %w", err)
}
}
if len(snap.PendingApplications) > 0 {
a := table.Applications
stmt := a.UPDATE().
SET(
a.Status.SET(postgres.String(ApplicationStatusRejected)),
a.DecidedAt.SET(postgres.TimestampzT(now)),
).
WHERE(
a.ApplicantUserID.EQ(postgres.UUID(userID)).
AND(a.Status.EQ(postgres.String(ApplicationStatusPending))),
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("cascade user: reject applications: %w", err)
}
}
if len(snap.IncomingInvites) > 0 {
i := table.Invites
stmt := i.UPDATE().
SET(
i.Status.SET(postgres.String(InviteStatusDeclined)),
i.DecidedAt.SET(postgres.TimestampzT(now)),
).
WHERE(
i.InvitedUserID.EQ(postgres.UUID(userID)).
AND(i.Status.EQ(postgres.String(InviteStatusPending))),
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("cascade user: decline incoming invites: %w", err)
}
}
if len(snap.OutgoingInvites) > 0 {
i := table.Invites
stmt := i.UPDATE().
SET(
i.Status.SET(postgres.String(InviteStatusRevoked)),
i.DecidedAt.SET(postgres.TimestampzT(now)),
).
WHERE(
i.InviterUserID.EQ(postgres.UUID(userID)).
AND(i.Status.EQ(postgres.String(InviteStatusPending))),
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("cascade user: revoke outgoing invites: %w", err)
}
}
if len(snap.RaceNameKeys) > 0 {
r := table.RaceNames
stmt := r.DELETE().
WHERE(r.OwnerUserID.EQ(postgres.UUID(userID)))
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("cascade user: delete race names: %w", err)
}
}
if len(snap.OwnedGameIDs) > 0 {
g := table.Games
stmt := g.UPDATE().
SET(
g.Status.SET(postgres.String(GameStatusCancelled)),
g.UpdatedAt.SET(postgres.TimestampzT(now)),
).
WHERE(
g.OwnerUserID.EQ(postgres.UUID(userID)).
AND(g.Status.IN(
postgres.String(GameStatusDraft),
postgres.String(GameStatusEnrollmentOpen),
postgres.String(GameStatusReadyToStart),
postgres.String(GameStatusStartFailed),
)),
)
if _, err := stmt.ExecContext(ctx, tx); err != nil {
return fmt.Errorf("cascade user: cancel owned games: %w", err)
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("cascade user: commit tx: %w", err)
}
return nil
}
// loadIDColumn runs stmt and accumulates the single uuid.UUID column it
// returns into out. The destination model is the JSON-tagged shim shared
// across the cascade snapshot loaders.
func loadIDColumn(ctx context.Context, db qrm.DB, stmt postgres.SelectStatement, out *[]uuid.UUID) error {
var rows []idRow
if err := stmt.QueryContext(ctx, db, &rows); err != nil {
return err
}
for _, row := range rows {
*out = append(*out, row.ID)
}
return nil
}
// idRow is the single-column scan destination for loadIDColumn. The
// alias tag matches the un-prefixed alias produced when SELECT only
// asks for one identifier-typed column.
type idRow struct {
ID uuid.UUID `alias:"-"`
}
// =====================================================================
// Argument helpers (INSERT VALUES bindings)
// =====================================================================
func ownerArg(p *uuid.UUID) any {
if p == nil {
return nil
}
return *p
}
func invitedArg(p *uuid.UUID) any {
if p == nil {
return nil
}
return *p
}
func sourceGameArg(p *uuid.UUID) any {
if p == nil {
return nil
}
return *p
}
func codeArg(s string) any {
if s == "" {
return nil
}
return s
}
func timePtrArg(t *time.Time) any {
if t == nil {
return nil
}
return *t
}
// =====================================================================
// Model → domain converters
// =====================================================================
func modelToGameRecord(row model.Games) (GameRecord, error) {
game := GameRecord{
GameID: row.GameID,
Visibility: row.Visibility,
Status: row.Status,
GameName: row.GameName,
Description: row.Description,
MinPlayers: row.MinPlayers,
MaxPlayers: row.MaxPlayers,
StartGapHours: row.StartGapHours,
StartGapPlayers: row.StartGapPlayers,
EnrollmentEndsAt: row.EnrollmentEndsAt,
TurnSchedule: row.TurnSchedule,
TargetEngineVersion: row.TargetEngineVersion,
CreatedAt: row.CreatedAt,
UpdatedAt: row.UpdatedAt,
}
if row.OwnerUserID != nil {
owner := *row.OwnerUserID
game.OwnerUserID = &owner
}
if row.StartedAt != nil {
t := *row.StartedAt
game.StartedAt = &t
}
if row.FinishedAt != nil {
t := *row.FinishedAt
game.FinishedAt = &t
}
if row.RuntimeSnapshot != "" {
var snap RuntimeSnapshot
if err := json.Unmarshal([]byte(row.RuntimeSnapshot), &snap); err != nil {
return GameRecord{}, fmt.Errorf("scan game: snapshot: %w", err)
}
game.RuntimeSnapshot = snap
}
return game, nil
}
func modelsToGameRecords(rows []model.Games) ([]GameRecord, error) {
out := make([]GameRecord, 0, len(rows))
for _, row := range rows {
game, err := modelToGameRecord(row)
if err != nil {
return nil, err
}
out = append(out, game)
}
return out, nil
}
func modelToApplication(row model.Applications) Application {
app := Application{
ApplicationID: row.ApplicationID,
GameID: row.GameID,
ApplicantUserID: row.ApplicantUserID,
RaceName: row.RaceName,
Status: row.Status,
CreatedAt: row.CreatedAt,
}
if row.DecidedAt != nil {
t := *row.DecidedAt
app.DecidedAt = &t
}
return app
}
func modelToInvite(row model.Invites) Invite {
invite := Invite{
InviteID: row.InviteID,
GameID: row.GameID,
InviterUserID: row.InviterUserID,
Status: row.Status,
RaceName: row.RaceName,
CreatedAt: row.CreatedAt,
ExpiresAt: row.ExpiresAt,
}
if row.InvitedUserID != nil {
invited := *row.InvitedUserID
invite.InvitedUserID = &invited
}
if row.Code != nil {
invite.Code = *row.Code
}
if row.DecidedAt != nil {
t := *row.DecidedAt
invite.DecidedAt = &t
}
return invite
}
func modelToMembership(row model.Memberships) Membership {
m := Membership{
MembershipID: row.MembershipID,
GameID: row.GameID,
UserID: row.UserID,
RaceName: row.RaceName,
CanonicalKey: row.CanonicalKey,
Status: row.Status,
JoinedAt: row.JoinedAt,
}
if row.RemovedAt != nil {
t := *row.RemovedAt
m.RemovedAt = &t
}
return m
}
func modelToRaceName(row model.RaceNames) RaceNameEntry {
entry := RaceNameEntry{
Name: row.Name,
Canonical: CanonicalKey(row.Canonical),
Status: row.Status,
OwnerUserID: row.OwnerUserID,
GameID: row.GameID,
}
if row.SourceGameID != nil {
src := *row.SourceGameID
entry.SourceGameID = &src
}
if row.ReservedAt != nil {
t := *row.ReservedAt
entry.ReservedAt = &t
}
if row.ExpiresAt != nil {
t := *row.ExpiresAt
entry.ExpiresAt = &t
}
if row.RegisteredAt != nil {
t := *row.RegisteredAt
entry.RegisteredAt = &t
}
return entry
}